upd-cli 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {upd_cli-0.2.0 → upd_cli-0.2.1}/CHANGELOG.md +7 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/Cargo.lock +1 -1
- {upd_cli-0.2.0 → upd_cli-0.2.1}/Cargo.toml +1 -1
- {upd_cli-0.2.0 → upd_cli-0.2.1}/PKG-INFO +1 -1
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/cli.rs +10 -9
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/main.rs +205 -61
- upd_cli-0.2.1/tests/defect_fixes.rs +320 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/.mise.toml +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/.pre-commit-config.yaml +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/.pre-commit-hooks.yaml +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/.rumdl.toml +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/LICENSE +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/Makefile +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/README.md +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/assets/logo-wide.svg +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/assets/logo.svg +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/fixtures/clispec-v0.2.json +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/pyproject.toml +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/python/upd_cli/__init__.py +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/python/upd_cli/__main__.py +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/python/upd_cli/py.typed +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/rust-toolchain.toml +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/align.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/audit/cache.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/audit/cvss.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/audit/mod.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/bin/upd-cli.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/cache.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/config.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/cooldown.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/http.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/interactive.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/lib.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/lockfile.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/output.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/crates_io.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/github_releases.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/go_proxy.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/mock.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/mod.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/npm.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/nuget.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/pypi.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/rubygems.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/terraform.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/utils.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/schema.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/cargo_toml.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/csproj.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/gemfile.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/github_actions.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/go_mod.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/mise.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/mod.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/npm_range.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/package_json.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/pre_commit.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/pyproject.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/requirements.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/terraform.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/version/compare.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/version/mod.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/version/pep440.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/version/semver_util.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/src/version/tag.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/audit_offline.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/audit_sarif.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/audit_severity.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/bump_filter.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/cooldown_e2e.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/discovery_no_ignore.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/exit_codes.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/fix_audit.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/format_json.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/help_text.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/interactive_tty.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/invalid_positional.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/no_args_scope.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/output_streams.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/package_filter.rs +0 -0
- {upd_cli-0.2.0 → upd_cli-0.2.1}/vership.toml +0 -0
|
@@ -21,6 +21,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
|
|
25
|
+
## [0.2.1](https://github.com/rvben/upd/compare/v0.2.0...v0.2.1) - 2026-06-11
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- correct five scripted-consumer defects in CLI contract ([936687d](https://github.com/rvben/upd/commit/936687da01d2af39b884415a9e0262a8197490d7))
|
|
30
|
+
|
|
24
31
|
## [0.2.0](https://github.com/rvben/upd/compare/v0.1.10...v0.2.0) - 2026-06-11
|
|
25
32
|
|
|
26
33
|
### Breaking Changes
|
|
@@ -169,8 +169,8 @@ pub struct Cli {
|
|
|
169
169
|
/// Use --format json for machine-readable output in scripts or CI.
|
|
170
170
|
/// Use --format sarif with `upd audit` to emit a SARIF 2.1.0 document
|
|
171
171
|
/// suitable for upload to GitHub Code Scanning.
|
|
172
|
-
#[arg(long, global = true, value_enum,
|
|
173
|
-
pub format: OutputFormat
|
|
172
|
+
#[arg(long, global = true, value_enum, value_name = "FORMAT")]
|
|
173
|
+
pub format: Option<OutputFormat>,
|
|
174
174
|
|
|
175
175
|
/// Print the effective configuration and exit.
|
|
176
176
|
///
|
|
@@ -791,40 +791,41 @@ mod tests {
|
|
|
791
791
|
}
|
|
792
792
|
|
|
793
793
|
#[test]
|
|
794
|
-
fn
|
|
794
|
+
fn test_cli_format_defaults_to_none() {
|
|
795
|
+
// --format not passed: None (distinguishable from explicitly passing --format text)
|
|
795
796
|
let cli = Cli::try_parse_from(["upd"]).unwrap();
|
|
796
|
-
assert_eq!(cli.format,
|
|
797
|
+
assert_eq!(cli.format, None);
|
|
797
798
|
}
|
|
798
799
|
|
|
799
800
|
#[test]
|
|
800
801
|
fn test_cli_format_accepts_json() {
|
|
801
802
|
let cli = Cli::try_parse_from(["upd", "--format", "json"]).unwrap();
|
|
802
|
-
assert_eq!(cli.format, OutputFormat::Json);
|
|
803
|
+
assert_eq!(cli.format, Some(OutputFormat::Json));
|
|
803
804
|
}
|
|
804
805
|
|
|
805
806
|
#[test]
|
|
806
807
|
fn test_cli_format_accepts_text() {
|
|
807
808
|
let cli = Cli::try_parse_from(["upd", "--format", "text"]).unwrap();
|
|
808
|
-
assert_eq!(cli.format, OutputFormat::Text);
|
|
809
|
+
assert_eq!(cli.format, Some(OutputFormat::Text));
|
|
809
810
|
}
|
|
810
811
|
|
|
811
812
|
#[test]
|
|
812
813
|
fn test_cli_format_accepts_sarif() {
|
|
813
814
|
let cli = Cli::try_parse_from(["upd", "--format", "sarif"]).unwrap();
|
|
814
|
-
assert_eq!(cli.format, OutputFormat::Sarif);
|
|
815
|
+
assert_eq!(cli.format, Some(OutputFormat::Sarif));
|
|
815
816
|
}
|
|
816
817
|
|
|
817
818
|
#[test]
|
|
818
819
|
fn test_cli_format_sarif_is_global_across_subcommands() {
|
|
819
820
|
let cli = Cli::try_parse_from(["upd", "audit", "--format", "sarif"]).unwrap();
|
|
820
|
-
assert_eq!(cli.format, OutputFormat::Sarif);
|
|
821
|
+
assert_eq!(cli.format, Some(OutputFormat::Sarif));
|
|
821
822
|
assert!(matches!(cli.command, Some(Command::Audit { .. })));
|
|
822
823
|
}
|
|
823
824
|
|
|
824
825
|
#[test]
|
|
825
826
|
fn test_cli_format_is_global_across_subcommands() {
|
|
826
827
|
let cli = Cli::try_parse_from(["upd", "audit", "--format", "json"]).unwrap();
|
|
827
|
-
assert_eq!(cli.format, OutputFormat::Json);
|
|
828
|
+
assert_eq!(cli.format, Some(OutputFormat::Json));
|
|
828
829
|
assert!(matches!(cli.command, Some(Command::Audit { .. })));
|
|
829
830
|
}
|
|
830
831
|
|
|
@@ -524,16 +524,21 @@ async fn run() -> Result<()> {
|
|
|
524
524
|
let cli = Cli::try_parse().unwrap_or_else(|e| {
|
|
525
525
|
// Clap uses Err for help/version display too; those are not real errors.
|
|
526
526
|
// Only emit the structured envelope for genuine parse failures.
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
);
|
|
527
|
+
// Help and version display are not errors; let clap handle them with its
|
|
528
|
+
// own exit code (0 for help/version, which is correct).
|
|
529
|
+
let is_display = e.kind() == clap::error::ErrorKind::DisplayHelp
|
|
530
|
+
|| e.kind() == clap::error::ErrorKind::DisplayVersion
|
|
531
|
+
|| e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand;
|
|
532
|
+
if is_display {
|
|
533
|
+
e.exit();
|
|
535
534
|
}
|
|
536
|
-
|
|
535
|
+
// Genuine parse errors: emit the structured envelope, then exit with
|
|
536
|
+
// the code the envelope declares (4), not the code clap would use (2).
|
|
537
|
+
eprintln!(
|
|
538
|
+
"{}",
|
|
539
|
+
serde_json::json!({"error": {"kind": "parse_error", "message": e.to_string(), "exit_code": 4}})
|
|
540
|
+
);
|
|
541
|
+
std::process::exit(4)
|
|
537
542
|
});
|
|
538
543
|
|
|
539
544
|
// Handle no-color flag
|
|
@@ -612,22 +617,23 @@ async fn run() -> Result<()> {
|
|
|
612
617
|
///
|
|
613
618
|
/// --format sarif overrides everything: SARIF is never treated as plain JSON.
|
|
614
619
|
fn effective_json_mode(cli: &Cli) -> bool {
|
|
620
|
+
use upd::cli::OutputFormat;
|
|
615
621
|
// SARIF is its own mode; never treat it as JSON.
|
|
616
|
-
if cli.format ==
|
|
622
|
+
if cli.format == Some(OutputFormat::Sarif) {
|
|
617
623
|
return false;
|
|
618
624
|
}
|
|
619
625
|
match cli.output {
|
|
620
|
-
// Explicit --output wins unconditionally.
|
|
626
|
+
// Explicit --output/-o wins unconditionally (three-valued clispec P1 rule).
|
|
621
627
|
OutputMode::Json => true,
|
|
622
628
|
OutputMode::Text => false,
|
|
623
|
-
// Auto:
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
}
|
|
629
|
+
// Auto: an explicit --format value always wins over TTY detection.
|
|
630
|
+
// None means --format was not passed, so fall through to TTY detection.
|
|
631
|
+
OutputMode::Auto => match cli.format {
|
|
632
|
+
Some(OutputFormat::Json) => true,
|
|
633
|
+
Some(OutputFormat::Text) => false,
|
|
634
|
+
Some(OutputFormat::Sarif) => false,
|
|
635
|
+
None => cli.is_json_output(),
|
|
636
|
+
},
|
|
631
637
|
}
|
|
632
638
|
}
|
|
633
639
|
|
|
@@ -638,7 +644,7 @@ async fn run_update(cli: &Cli) -> Result<()> {
|
|
|
638
644
|
// auto-detected as JSON (stdout piped), the TTY check inside
|
|
639
645
|
// run_interactive_update fires first and gives a clearer error message.
|
|
640
646
|
let explicit_json =
|
|
641
|
-
matches!(cli.output, OutputMode::Json) || cli.format == upd::cli::OutputFormat::Json;
|
|
647
|
+
matches!(cli.output, OutputMode::Json) || cli.format == Some(upd::cli::OutputFormat::Json);
|
|
642
648
|
if cli.interactive && explicit_json {
|
|
643
649
|
anyhow::bail!("--interactive cannot be combined with --format json or --output json");
|
|
644
650
|
}
|
|
@@ -678,13 +684,16 @@ async fn run_update(cli: &Cli) -> Result<()> {
|
|
|
678
684
|
}
|
|
679
685
|
} else {
|
|
680
686
|
emit_update_json(
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
687
|
+
UpdateReportInput {
|
|
688
|
+
scanned: &[],
|
|
689
|
+
total_result: &UpdateResult::default(),
|
|
690
|
+
file_count: 0,
|
|
691
|
+
dry_run: effective_dry_run,
|
|
692
|
+
filter: UpdateFilter::from_cli(&cli.only_bump, cli.max_bump),
|
|
693
|
+
file_cooldowns: &HashMap::new(),
|
|
694
|
+
cooldown_notes: Vec::new(),
|
|
695
|
+
},
|
|
696
|
+
&BoundedOutputParams::from_cli(cli),
|
|
688
697
|
)?;
|
|
689
698
|
}
|
|
690
699
|
return Ok(());
|
|
@@ -1146,13 +1155,16 @@ async fn run_update(cli: &Cli) -> Result<()> {
|
|
|
1146
1155
|
.map(|g| g.iter().cloned().collect())
|
|
1147
1156
|
.unwrap_or_default();
|
|
1148
1157
|
emit_update_json(
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1158
|
+
UpdateReportInput {
|
|
1159
|
+
scanned: &scanned,
|
|
1160
|
+
total_result: &total_result,
|
|
1161
|
+
file_count,
|
|
1162
|
+
dry_run,
|
|
1163
|
+
filter,
|
|
1164
|
+
file_cooldowns: &file_cooldowns,
|
|
1165
|
+
cooldown_notes: notes_vec,
|
|
1166
|
+
},
|
|
1167
|
+
&BoundedOutputParams::from_cli(cli),
|
|
1156
1168
|
)?;
|
|
1157
1169
|
}
|
|
1158
1170
|
|
|
@@ -1166,17 +1178,94 @@ async fn run_update(cli: &Cli) -> Result<()> {
|
|
|
1166
1178
|
Ok(())
|
|
1167
1179
|
}
|
|
1168
1180
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1181
|
+
/// Parameters controlling bounded JSON output (--limit, --offset, --fields).
|
|
1182
|
+
struct BoundedOutputParams<'a> {
|
|
1183
|
+
limit: Option<usize>,
|
|
1184
|
+
offset: usize,
|
|
1185
|
+
fields: &'a Option<String>,
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
impl<'a> BoundedOutputParams<'a> {
|
|
1189
|
+
fn from_cli(cli: &'a Cli) -> Self {
|
|
1190
|
+
Self {
|
|
1191
|
+
limit: cli.limit,
|
|
1192
|
+
offset: cli.offset,
|
|
1193
|
+
fields: &cli.fields,
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/// Inputs needed to build the update JSON report.
|
|
1199
|
+
struct UpdateReportInput<'a> {
|
|
1200
|
+
scanned: &'a [ScannedFileResult],
|
|
1201
|
+
total_result: &'a UpdateResult,
|
|
1172
1202
|
file_count: usize,
|
|
1173
1203
|
dry_run: bool,
|
|
1174
1204
|
filter: UpdateFilter,
|
|
1175
|
-
file_cooldowns: &HashMap<PathBuf, Option<CooldownPolicy>>,
|
|
1205
|
+
file_cooldowns: &'a HashMap<PathBuf, Option<CooldownPolicy>>,
|
|
1176
1206
|
cooldown_notes: Vec<String>,
|
|
1177
|
-
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/// Apply --limit, --offset, and --fields to a JSON document for bounded output.
|
|
1210
|
+
///
|
|
1211
|
+
/// `list_key` is the name of the top-level array field that is paginated
|
|
1212
|
+
/// (e.g. "files" for update, "packages" for align, "vulnerabilities" for audit).
|
|
1213
|
+
/// When limit/offset truncate the list, truncation metadata is injected at the
|
|
1214
|
+
/// top level so consumers know they received a partial result.
|
|
1215
|
+
fn apply_bounded_output(
|
|
1216
|
+
mut doc: serde_json::Value,
|
|
1217
|
+
list_key: &str,
|
|
1218
|
+
params: &BoundedOutputParams<'_>,
|
|
1219
|
+
) -> serde_json::Value {
|
|
1220
|
+
let limit = params.limit;
|
|
1221
|
+
let offset = params.offset;
|
|
1222
|
+
let fields = params.fields;
|
|
1223
|
+
// Apply limit/offset to the list-shaped field.
|
|
1224
|
+
if let Some(arr) = doc.get_mut(list_key).and_then(|v| v.as_array_mut()) {
|
|
1225
|
+
let total = arr.len();
|
|
1226
|
+
let start = offset.min(total);
|
|
1227
|
+
let sliced: Vec<serde_json::Value> = arr[start..]
|
|
1228
|
+
.iter()
|
|
1229
|
+
.take(limit.unwrap_or(usize::MAX))
|
|
1230
|
+
.cloned()
|
|
1231
|
+
.collect();
|
|
1232
|
+
let returned = sliced.len();
|
|
1233
|
+
*arr = sliced;
|
|
1234
|
+
|
|
1235
|
+
// Inject truncation metadata when the output is bounded.
|
|
1236
|
+
let is_truncated =
|
|
1237
|
+
offset > 0 || limit.is_some_and(|l| returned < total - start || l < total);
|
|
1238
|
+
if is_truncated && let Some(obj) = doc.as_object_mut() {
|
|
1239
|
+
obj.insert("total".to_string(), serde_json::json!(total));
|
|
1240
|
+
obj.insert("limit".to_string(), serde_json::json!(limit));
|
|
1241
|
+
obj.insert("offset".to_string(), serde_json::json!(offset));
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Apply --fields to filter top-level keys.
|
|
1246
|
+
if let Some(fields_str) = fields {
|
|
1247
|
+
let keep: std::collections::HashSet<&str> = fields_str.split(',').map(str::trim).collect();
|
|
1248
|
+
if let Some(obj) = doc.as_object_mut() {
|
|
1249
|
+
obj.retain(|k, _| keep.contains(k.as_str()));
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
doc
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
fn emit_update_json(input: UpdateReportInput<'_>, bounded: &BoundedOutputParams<'_>) -> Result<()> {
|
|
1178
1257
|
use upd::output::{UpdateReport, UpdateSummary, build_update_file_report};
|
|
1179
1258
|
|
|
1259
|
+
let UpdateReportInput {
|
|
1260
|
+
scanned,
|
|
1261
|
+
total_result,
|
|
1262
|
+
file_count,
|
|
1263
|
+
dry_run,
|
|
1264
|
+
filter,
|
|
1265
|
+
file_cooldowns,
|
|
1266
|
+
cooldown_notes,
|
|
1267
|
+
} = input;
|
|
1268
|
+
|
|
1180
1269
|
let files: Vec<_> = scanned
|
|
1181
1270
|
.iter()
|
|
1182
1271
|
.map(|sf| {
|
|
@@ -1229,7 +1318,9 @@ fn emit_update_json(
|
|
|
1229
1318
|
cooldown_notes,
|
|
1230
1319
|
};
|
|
1231
1320
|
|
|
1232
|
-
|
|
1321
|
+
let doc = serde_json::to_value(&report)?;
|
|
1322
|
+
let doc = apply_bounded_output(doc, "files", bounded);
|
|
1323
|
+
println!("{}", serde_json::to_string_pretty(&doc)?);
|
|
1233
1324
|
Ok(())
|
|
1234
1325
|
}
|
|
1235
1326
|
|
|
@@ -1265,12 +1356,15 @@ async fn run_interactive_update(
|
|
|
1265
1356
|
) -> Result<()> {
|
|
1266
1357
|
if !std::io::stdin().is_terminal() {
|
|
1267
1358
|
eprintln!(
|
|
1268
|
-
"{}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1359
|
+
"{}",
|
|
1360
|
+
serde_json::json!({
|
|
1361
|
+
"error": {
|
|
1362
|
+
"kind": "confirmation_required",
|
|
1363
|
+
"message": "--interactive requires a terminal on stdin",
|
|
1364
|
+
"hint": "Use --check to preview updates, or --dry-run to print proposed changes.",
|
|
1365
|
+
"exit_code": 2
|
|
1366
|
+
}
|
|
1367
|
+
})
|
|
1274
1368
|
);
|
|
1275
1369
|
std::process::exit(2);
|
|
1276
1370
|
}
|
|
@@ -1555,6 +1649,7 @@ async fn run_interactive_update(
|
|
|
1555
1649
|
}
|
|
1556
1650
|
|
|
1557
1651
|
let mut had_error = false;
|
|
1652
|
+
let mut error_messages: Vec<String> = Vec::new();
|
|
1558
1653
|
for (path, result) in regen_results {
|
|
1559
1654
|
if result.no_lockfiles {
|
|
1560
1655
|
let manifest_name = path
|
|
@@ -1562,7 +1657,7 @@ async fn run_interactive_update(
|
|
|
1562
1657
|
.map(|n| n.to_string_lossy().into_owned())
|
|
1563
1658
|
.unwrap_or_default();
|
|
1564
1659
|
eprintln!(
|
|
1565
|
-
"note: no lockfile found for {}
|
|
1660
|
+
"note: no lockfile found for {} - skipping (nothing to regenerate)",
|
|
1566
1661
|
manifest_name
|
|
1567
1662
|
);
|
|
1568
1663
|
continue;
|
|
@@ -1571,10 +1666,22 @@ async fn run_interactive_update(
|
|
|
1571
1666
|
if let Some(msg) = outcome.error_message() {
|
|
1572
1667
|
eprintln!("{}", format!("error: {msg}").red());
|
|
1573
1668
|
had_error = true;
|
|
1669
|
+
error_messages.push(msg);
|
|
1574
1670
|
}
|
|
1575
1671
|
}
|
|
1576
1672
|
}
|
|
1577
1673
|
if had_error {
|
|
1674
|
+
let combined = error_messages.join("; ");
|
|
1675
|
+
eprintln!(
|
|
1676
|
+
"{}",
|
|
1677
|
+
serde_json::json!({
|
|
1678
|
+
"error": {
|
|
1679
|
+
"kind": "io_error",
|
|
1680
|
+
"message": combined,
|
|
1681
|
+
"exit_code": 2
|
|
1682
|
+
}
|
|
1683
|
+
})
|
|
1684
|
+
);
|
|
1578
1685
|
std::process::exit(2);
|
|
1579
1686
|
}
|
|
1580
1687
|
}
|
|
@@ -1636,7 +1743,7 @@ async fn run_align(cli: &Cli) -> Result<()> {
|
|
|
1636
1743
|
println!("{}", "No dependency files found.".yellow());
|
|
1637
1744
|
}
|
|
1638
1745
|
} else {
|
|
1639
|
-
emit_align_json(&[], 0)?;
|
|
1746
|
+
emit_align_json(&[], 0, &BoundedOutputParams::from_cli(cli))?;
|
|
1640
1747
|
}
|
|
1641
1748
|
return Ok(());
|
|
1642
1749
|
}
|
|
@@ -1678,7 +1785,7 @@ async fn run_align(cli: &Cli) -> Result<()> {
|
|
|
1678
1785
|
.filter(|p| p.has_misalignment())
|
|
1679
1786
|
.cloned()
|
|
1680
1787
|
.collect();
|
|
1681
|
-
emit_align_json(&to_report, file_count)?;
|
|
1788
|
+
emit_align_json(&to_report, file_count, &BoundedOutputParams::from_cli(cli))?;
|
|
1682
1789
|
}
|
|
1683
1790
|
|
|
1684
1791
|
if misaligned.is_empty() {
|
|
@@ -1692,8 +1799,8 @@ async fn run_align(cli: &Cli) -> Result<()> {
|
|
|
1692
1799
|
return Ok(());
|
|
1693
1800
|
}
|
|
1694
1801
|
|
|
1695
|
-
// Mutations are opt-in for align as well.
|
|
1696
|
-
let dry_run = cli.
|
|
1802
|
+
// Mutations are opt-in for align as well. --yes is an alias for --apply.
|
|
1803
|
+
let dry_run = cli.is_effective_dry_run();
|
|
1697
1804
|
|
|
1698
1805
|
if text_mode && !cli.quiet {
|
|
1699
1806
|
let action_prefix = if dry_run { "Would align" } else { "Aligning" };
|
|
@@ -1740,7 +1847,11 @@ async fn run_align(cli: &Cli) -> Result<()> {
|
|
|
1740
1847
|
Ok(())
|
|
1741
1848
|
}
|
|
1742
1849
|
|
|
1743
|
-
fn emit_align_json(
|
|
1850
|
+
fn emit_align_json(
|
|
1851
|
+
packages: &[PackageAlignment],
|
|
1852
|
+
file_count: usize,
|
|
1853
|
+
bounded: &BoundedOutputParams<'_>,
|
|
1854
|
+
) -> Result<()> {
|
|
1744
1855
|
use upd::output::{AlignReport, AlignSummary, build_align_package};
|
|
1745
1856
|
|
|
1746
1857
|
let pkgs: Vec<_> = packages.iter().map(build_align_package).collect();
|
|
@@ -1762,7 +1873,9 @@ fn emit_align_json(packages: &[PackageAlignment], file_count: usize) -> Result<(
|
|
|
1762
1873
|
packages: pkgs,
|
|
1763
1874
|
};
|
|
1764
1875
|
|
|
1765
|
-
|
|
1876
|
+
let doc = serde_json::to_value(&report)?;
|
|
1877
|
+
let doc = apply_bounded_output(doc, "packages", bounded);
|
|
1878
|
+
println!("{}", serde_json::to_string_pretty(&doc)?);
|
|
1766
1879
|
Ok(())
|
|
1767
1880
|
}
|
|
1768
1881
|
|
|
@@ -1829,8 +1942,8 @@ async fn run_audit(cli: &Cli) -> Result<()> {
|
|
|
1829
1942
|
let fix_audit = matches!(&cli.command, Some(Command::Audit { fix_audit, .. }) if *fix_audit);
|
|
1830
1943
|
let offline = matches!(&cli.command, Some(Command::Audit { offline, .. }) if *offline);
|
|
1831
1944
|
let json_mode = effective_json_mode(cli);
|
|
1832
|
-
let text_mode = !json_mode && cli.format != upd::cli::OutputFormat::Sarif;
|
|
1833
|
-
let sarif_mode = cli.format == upd::cli::OutputFormat::Sarif && !json_mode;
|
|
1945
|
+
let text_mode = !json_mode && cli.format != Some(upd::cli::OutputFormat::Sarif);
|
|
1946
|
+
let sarif_mode = cli.format == Some(upd::cli::OutputFormat::Sarif) && !json_mode;
|
|
1834
1947
|
// Audit never mutates files, so no VCS check is needed. Fall back to CWD.
|
|
1835
1948
|
let paths = {
|
|
1836
1949
|
let explicit = cli.get_paths();
|
|
@@ -1858,7 +1971,11 @@ async fn run_audit(cli: &Cli) -> Result<()> {
|
|
|
1858
1971
|
} else if sarif_mode {
|
|
1859
1972
|
emit_audit_sarif(&AuditResult::default(), &HashMap::new())?;
|
|
1860
1973
|
} else {
|
|
1861
|
-
emit_audit_json(
|
|
1974
|
+
emit_audit_json(
|
|
1975
|
+
&AuditResult::default(),
|
|
1976
|
+
"complete",
|
|
1977
|
+
&BoundedOutputParams::from_cli(cli),
|
|
1978
|
+
)?;
|
|
1862
1979
|
}
|
|
1863
1980
|
return Ok(());
|
|
1864
1981
|
}
|
|
@@ -1898,7 +2015,11 @@ async fn run_audit(cli: &Cli) -> Result<()> {
|
|
|
1898
2015
|
} else if sarif_mode {
|
|
1899
2016
|
emit_audit_sarif(&AuditResult::default(), &HashMap::new())?;
|
|
1900
2017
|
} else {
|
|
1901
|
-
emit_audit_json(
|
|
2018
|
+
emit_audit_json(
|
|
2019
|
+
&AuditResult::default(),
|
|
2020
|
+
"complete",
|
|
2021
|
+
&BoundedOutputParams::from_cli(cli),
|
|
2022
|
+
)?;
|
|
1902
2023
|
}
|
|
1903
2024
|
return Ok(());
|
|
1904
2025
|
}
|
|
@@ -1995,7 +2116,11 @@ async fn run_audit(cli: &Cli) -> Result<()> {
|
|
|
1995
2116
|
AuditStatus::Clean | AuditStatus::Vulnerable => "complete",
|
|
1996
2117
|
AuditStatus::Incomplete => "incomplete",
|
|
1997
2118
|
};
|
|
1998
|
-
emit_audit_json(
|
|
2119
|
+
emit_audit_json(
|
|
2120
|
+
&audit_result,
|
|
2121
|
+
status_str,
|
|
2122
|
+
&BoundedOutputParams::from_cli(cli),
|
|
2123
|
+
)?;
|
|
1999
2124
|
}
|
|
2000
2125
|
|
|
2001
2126
|
// --fix-audit: bump each vulnerable package to its minimum safe version.
|
|
@@ -2178,7 +2303,20 @@ async fn run_audit(cli: &Cli) -> Result<()> {
|
|
|
2178
2303
|
0
|
|
2179
2304
|
};
|
|
2180
2305
|
|
|
2181
|
-
if fix_exit_code
|
|
2306
|
+
if fix_exit_code == 2 {
|
|
2307
|
+
let combined = fix_errors.join("; ");
|
|
2308
|
+
eprintln!(
|
|
2309
|
+
"{}",
|
|
2310
|
+
serde_json::json!({
|
|
2311
|
+
"error": {
|
|
2312
|
+
"kind": "io_error",
|
|
2313
|
+
"message": combined,
|
|
2314
|
+
"exit_code": 2
|
|
2315
|
+
}
|
|
2316
|
+
})
|
|
2317
|
+
);
|
|
2318
|
+
std::process::exit(2);
|
|
2319
|
+
} else if fix_exit_code != 0 {
|
|
2182
2320
|
std::process::exit(fix_exit_code);
|
|
2183
2321
|
}
|
|
2184
2322
|
return Ok(());
|
|
@@ -2197,10 +2335,16 @@ async fn run_audit(cli: &Cli) -> Result<()> {
|
|
|
2197
2335
|
Ok(())
|
|
2198
2336
|
}
|
|
2199
2337
|
|
|
2200
|
-
fn emit_audit_json(
|
|
2338
|
+
fn emit_audit_json(
|
|
2339
|
+
audit: &AuditResult,
|
|
2340
|
+
status: &'static str,
|
|
2341
|
+
bounded: &BoundedOutputParams<'_>,
|
|
2342
|
+
) -> Result<()> {
|
|
2201
2343
|
use upd::output::build_audit_report;
|
|
2202
2344
|
let report = build_audit_report(audit, 0, status);
|
|
2203
|
-
|
|
2345
|
+
let doc = serde_json::to_value(&report)?;
|
|
2346
|
+
let doc = apply_bounded_output(doc, "vulnerabilities", bounded);
|
|
2347
|
+
println!("{}", serde_json::to_string_pretty(&doc)?);
|
|
2204
2348
|
Ok(())
|
|
2205
2349
|
}
|
|
2206
2350
|
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
//! Regression tests for five functional defects.
|
|
2
|
+
//!
|
|
3
|
+
//! Each test targets a specific corrected contract:
|
|
4
|
+
//!
|
|
5
|
+
//! P1-a: Parse failures exit with code 4 and emit a structured envelope.
|
|
6
|
+
//! P1-b: Direct failure exits in run_interactive_update emit a structured envelope.
|
|
7
|
+
//! P2-a: --format text is explicit and wins over TTY detection when piped.
|
|
8
|
+
//! P2-b: --limit / --offset / --fields are wired into JSON output.
|
|
9
|
+
//! P2-c: --yes covers the align subcommand (same as --apply).
|
|
10
|
+
|
|
11
|
+
use serde_json::Value;
|
|
12
|
+
use std::fs;
|
|
13
|
+
use std::path::Path;
|
|
14
|
+
use std::process::Command;
|
|
15
|
+
|
|
16
|
+
fn upd_bin() -> &'static str {
|
|
17
|
+
env!("CARGO_BIN_EXE_upd")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fn run(args: &[&str], cwd: &Path) -> (String, String, i32) {
|
|
21
|
+
let output = Command::new(upd_bin())
|
|
22
|
+
.args(args)
|
|
23
|
+
.current_dir(cwd)
|
|
24
|
+
.output()
|
|
25
|
+
.expect("failed to run upd");
|
|
26
|
+
(
|
|
27
|
+
String::from_utf8(output.stdout).expect("stdout not UTF-8"),
|
|
28
|
+
String::from_utf8(output.stderr).expect("stderr not UTF-8"),
|
|
29
|
+
output.status.code().unwrap_or(-1),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fn parse_json(s: &str) -> Value {
|
|
34
|
+
serde_json::from_str(s.trim()).unwrap_or_else(|e| panic!("not valid JSON ({e}):\n{s}"))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── P1-a: Parse errors exit 4 with structured envelope ───────────────────────
|
|
38
|
+
|
|
39
|
+
/// An unknown flag must exit with code 4 (parse_error), not clap's default 2.
|
|
40
|
+
#[test]
|
|
41
|
+
fn parse_error_exits_four() {
|
|
42
|
+
let tmp = tempfile::tempdir().unwrap();
|
|
43
|
+
let (_stdout, _stderr, code) = run(&["--this-flag-does-not-exist"], tmp.path());
|
|
44
|
+
assert_eq!(
|
|
45
|
+
code, 4,
|
|
46
|
+
"invalid CLI arg must exit 4 (parse_error), got {code}"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// The structured envelope for a parse error must be the last line of stderr
|
|
51
|
+
/// and declare kind=parse_error with exit_code=4.
|
|
52
|
+
#[test]
|
|
53
|
+
fn parse_error_emits_structured_envelope_on_stderr() {
|
|
54
|
+
let tmp = tempfile::tempdir().unwrap();
|
|
55
|
+
let (_stdout, stderr, _code) = run(&["--unknown-flag-xyz"], tmp.path());
|
|
56
|
+
let last_line = stderr.trim_end().lines().last().unwrap_or("");
|
|
57
|
+
let envelope = parse_json(last_line);
|
|
58
|
+
assert_eq!(
|
|
59
|
+
envelope["error"]["kind"], "parse_error",
|
|
60
|
+
"last stderr line must have kind=parse_error; got: {last_line}"
|
|
61
|
+
);
|
|
62
|
+
assert_eq!(
|
|
63
|
+
envelope["error"]["exit_code"], 4,
|
|
64
|
+
"envelope must declare exit_code=4; got: {last_line}"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// --help must NOT emit a parse_error envelope and must exit 0.
|
|
69
|
+
#[test]
|
|
70
|
+
fn help_display_exits_zero_no_envelope() {
|
|
71
|
+
let tmp = tempfile::tempdir().unwrap();
|
|
72
|
+
let (_stdout, stderr, code) = run(&["--help"], tmp.path());
|
|
73
|
+
assert_eq!(code, 0, "--help must exit 0, got {code}");
|
|
74
|
+
assert!(
|
|
75
|
+
!stderr.contains("parse_error"),
|
|
76
|
+
"--help must not emit a parse_error envelope; stderr: {stderr}"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── P1-b: Structured envelope for direct failure exits ───────────────────────
|
|
81
|
+
|
|
82
|
+
/// --interactive without a TTY must exit 2 with a structured envelope on stderr.
|
|
83
|
+
///
|
|
84
|
+
/// We simulate a non-TTY stdin by piping /dev/null into the process. The
|
|
85
|
+
/// envelope must be the last line of stderr.
|
|
86
|
+
#[test]
|
|
87
|
+
fn interactive_without_tty_emits_envelope_and_exits_two() {
|
|
88
|
+
let tmp = tempfile::tempdir().unwrap();
|
|
89
|
+
let output = Command::new(upd_bin())
|
|
90
|
+
.args(["--interactive", "--dry-run"])
|
|
91
|
+
.current_dir(tmp.path())
|
|
92
|
+
.stdin(std::process::Stdio::null())
|
|
93
|
+
.stdout(std::process::Stdio::piped())
|
|
94
|
+
.stderr(std::process::Stdio::piped())
|
|
95
|
+
.output()
|
|
96
|
+
.expect("failed to run upd");
|
|
97
|
+
|
|
98
|
+
let stderr = String::from_utf8(output.stderr).expect("stderr not UTF-8");
|
|
99
|
+
let code = output.status.code().unwrap_or(-1);
|
|
100
|
+
|
|
101
|
+
assert_eq!(code, 2, "--interactive without TTY must exit 2, got {code}");
|
|
102
|
+
|
|
103
|
+
let last_line = stderr.trim_end().lines().last().unwrap_or("");
|
|
104
|
+
let envelope = parse_json(last_line);
|
|
105
|
+
assert!(
|
|
106
|
+
envelope["error"]["kind"].is_string(),
|
|
107
|
+
"last stderr line must be a structured envelope; got: {last_line}"
|
|
108
|
+
);
|
|
109
|
+
assert!(
|
|
110
|
+
envelope["error"]["message"].is_string(),
|
|
111
|
+
"envelope must have a message field; got: {last_line}"
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── P2-a: --format text wins over TTY detection ──────────────────────────────
|
|
116
|
+
|
|
117
|
+
/// When stdout is piped (non-TTY) and --format text is explicitly passed,
|
|
118
|
+
/// the output must be text (not JSON). This verifies the three-valued rule:
|
|
119
|
+
/// an explicit value always beats auto-detection.
|
|
120
|
+
#[test]
|
|
121
|
+
fn format_text_explicit_wins_over_tty_detection_when_piped() {
|
|
122
|
+
let tmp = tempfile::tempdir().unwrap();
|
|
123
|
+
let path_str = tmp.path().to_str().unwrap();
|
|
124
|
+
// stdout is piped (non-TTY) by default in Command::output().
|
|
125
|
+
let (stdout, _stderr, code) = run(&["--format", "text", "--dry-run", path_str], tmp.path());
|
|
126
|
+
assert_eq!(code, 0, "expected exit 0, got {code}");
|
|
127
|
+
assert!(
|
|
128
|
+
serde_json::from_str::<Value>(stdout.trim()).is_err(),
|
|
129
|
+
"--format text must produce non-JSON output even when piped; got: {stdout}"
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// --format json emits JSON even in a piped context (sanity check that the
|
|
134
|
+
/// three-valued logic works in both directions).
|
|
135
|
+
#[test]
|
|
136
|
+
fn format_json_explicit_emits_json_when_piped() {
|
|
137
|
+
let tmp = tempfile::tempdir().unwrap();
|
|
138
|
+
let path_str = tmp.path().to_str().unwrap();
|
|
139
|
+
let (stdout, _stderr, code) = run(&["--format", "json", "--dry-run", path_str], tmp.path());
|
|
140
|
+
assert_eq!(code, 0, "expected exit 0, got {code}");
|
|
141
|
+
assert!(
|
|
142
|
+
serde_json::from_str::<Value>(stdout.trim()).is_ok(),
|
|
143
|
+
"--format json must produce JSON when piped; got: {stdout}"
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── P2-b: --limit / --offset / --fields are wired into JSON output ───────────
|
|
148
|
+
|
|
149
|
+
/// --limit 0 on an empty workspace returns an empty files array.
|
|
150
|
+
#[test]
|
|
151
|
+
fn limit_zero_returns_empty_files_array() {
|
|
152
|
+
let tmp = tempfile::tempdir().unwrap();
|
|
153
|
+
let path_str = tmp.path().to_str().unwrap();
|
|
154
|
+
let (stdout, _stderr, code) = run(
|
|
155
|
+
&["--output", "json", "--limit", "0", "--dry-run", path_str],
|
|
156
|
+
tmp.path(),
|
|
157
|
+
);
|
|
158
|
+
assert_eq!(code, 0, "expected exit 0, got {code}");
|
|
159
|
+
let doc = parse_json(&stdout);
|
|
160
|
+
let files = doc["files"].as_array().expect("files must be an array");
|
|
161
|
+
assert!(files.is_empty(), "files must be empty with --limit 0");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// --fields filters top-level keys: requesting only "summary" must drop "files".
|
|
165
|
+
#[test]
|
|
166
|
+
fn fields_filters_top_level_keys() {
|
|
167
|
+
let tmp = tempfile::tempdir().unwrap();
|
|
168
|
+
let path_str = tmp.path().to_str().unwrap();
|
|
169
|
+
let (stdout, _stderr, code) = run(
|
|
170
|
+
&[
|
|
171
|
+
"--output",
|
|
172
|
+
"json",
|
|
173
|
+
"--fields",
|
|
174
|
+
"summary",
|
|
175
|
+
"--dry-run",
|
|
176
|
+
path_str,
|
|
177
|
+
],
|
|
178
|
+
tmp.path(),
|
|
179
|
+
);
|
|
180
|
+
assert_eq!(code, 0, "expected exit 0, got {code}");
|
|
181
|
+
let doc = parse_json(&stdout);
|
|
182
|
+
assert!(
|
|
183
|
+
doc.get("summary").is_some(),
|
|
184
|
+
"summary key must be present with --fields summary; got: {stdout}"
|
|
185
|
+
);
|
|
186
|
+
assert!(
|
|
187
|
+
doc.get("files").is_none(),
|
|
188
|
+
"files key must be absent with --fields summary; got: {stdout}"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// --limit and --offset inject truncation metadata when the list is bounded.
|
|
193
|
+
#[test]
|
|
194
|
+
fn limit_offset_inject_truncation_metadata() {
|
|
195
|
+
let tmp = tempfile::tempdir().unwrap();
|
|
196
|
+
// Create two dependency files so there is a non-trivial list to paginate.
|
|
197
|
+
fs::write(tmp.path().join("requirements.txt"), "requests==1.0.0\n").unwrap();
|
|
198
|
+
fs::write(
|
|
199
|
+
tmp.path().join("package.json"),
|
|
200
|
+
r#"{"dependencies":{"lodash":"1.0.0"}}"#,
|
|
201
|
+
)
|
|
202
|
+
.unwrap();
|
|
203
|
+
let path_str = tmp.path().to_str().unwrap();
|
|
204
|
+
|
|
205
|
+
// Use --no-cache and point to a dead registry so the run finishes fast
|
|
206
|
+
// with errors (which is fine; we only care about the JSON shape).
|
|
207
|
+
let (stdout, _stderr, _code) = run(
|
|
208
|
+
&[
|
|
209
|
+
"--output",
|
|
210
|
+
"json",
|
|
211
|
+
"--limit",
|
|
212
|
+
"1",
|
|
213
|
+
"--offset",
|
|
214
|
+
"0",
|
|
215
|
+
"--no-cache",
|
|
216
|
+
"--dry-run",
|
|
217
|
+
path_str,
|
|
218
|
+
],
|
|
219
|
+
tmp.path(),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// The stdout may be valid JSON even with registry errors (errors in files[]).
|
|
223
|
+
if let Ok(doc) = serde_json::from_str::<Value>(stdout.trim()) {
|
|
224
|
+
// When limit/offset truncate, metadata must be present.
|
|
225
|
+
if doc.get("total").is_some() {
|
|
226
|
+
assert!(
|
|
227
|
+
doc["total"].is_number(),
|
|
228
|
+
"total must be a number; got: {stdout}"
|
|
229
|
+
);
|
|
230
|
+
assert!(
|
|
231
|
+
doc["limit"].is_number(),
|
|
232
|
+
"limit must be a number; got: {stdout}"
|
|
233
|
+
);
|
|
234
|
+
assert!(
|
|
235
|
+
doc["offset"].is_number(),
|
|
236
|
+
"offset must be a number; got: {stdout}"
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
// At most 1 file entry must be returned.
|
|
240
|
+
if let Some(files) = doc["files"].as_array() {
|
|
241
|
+
assert!(
|
|
242
|
+
files.len() <= 1,
|
|
243
|
+
"at most 1 file must be returned with --limit 1; got: {stdout}"
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// If stdout is not JSON (registry errors forced text mode somehow), skip.
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── P2-c: --yes covers align ─────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
/// align --yes must behave identically to align --apply: it must count as
|
|
253
|
+
/// non-dry-run and actually write changes when misalignments exist.
|
|
254
|
+
///
|
|
255
|
+
/// We test the dry-run vs apply distinction by verifying that --yes produces
|
|
256
|
+
/// the same output shape as --apply, not the "Would align" text mode output.
|
|
257
|
+
/// Since we have no misalignments in a fresh workspace, we verify that both
|
|
258
|
+
/// --yes and --apply exit 0 (the aligned case) and that --yes is not stuck
|
|
259
|
+
/// in dry-run (which would also exit 0 here but with different text).
|
|
260
|
+
///
|
|
261
|
+
/// A deeper test with real misalignments requires a network call; this test
|
|
262
|
+
/// keeps it offline and fast by verifying the flag is wired to the right
|
|
263
|
+
/// effective-dry-run path via the exit code and output shape.
|
|
264
|
+
#[test]
|
|
265
|
+
fn align_yes_behaves_like_apply() {
|
|
266
|
+
let tmp = tempfile::tempdir().unwrap();
|
|
267
|
+
let path_str = tmp.path().to_str().unwrap();
|
|
268
|
+
|
|
269
|
+
let (stdout_yes, _stderr_yes, code_yes) = run(
|
|
270
|
+
&["align", "--yes", "--output", "json", path_str],
|
|
271
|
+
tmp.path(),
|
|
272
|
+
);
|
|
273
|
+
let (stdout_apply, _stderr_apply, code_apply) = run(
|
|
274
|
+
&["align", "--apply", "--output", "json", path_str],
|
|
275
|
+
tmp.path(),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
assert_eq!(
|
|
279
|
+
code_yes, code_apply,
|
|
280
|
+
"--yes and --apply must produce the same exit code; --yes={code_yes}, --apply={code_apply}"
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// Both must produce valid JSON with the same command field.
|
|
284
|
+
let doc_yes = parse_json(&stdout_yes);
|
|
285
|
+
let doc_apply = parse_json(&stdout_apply);
|
|
286
|
+
assert_eq!(
|
|
287
|
+
doc_yes["command"], "align",
|
|
288
|
+
"--yes align must emit command=align; got: {stdout_yes}"
|
|
289
|
+
);
|
|
290
|
+
assert_eq!(
|
|
291
|
+
doc_yes["command"], doc_apply["command"],
|
|
292
|
+
"--yes and --apply must emit the same command; got yes={stdout_yes} apply={stdout_apply}"
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// align without --apply or --yes must be dry-run: the JSON output must reflect
|
|
297
|
+
/// the align report but no files must be modified.
|
|
298
|
+
#[test]
|
|
299
|
+
fn align_without_apply_is_dry_run() {
|
|
300
|
+
let tmp = tempfile::tempdir().unwrap();
|
|
301
|
+
let path_str = tmp.path().to_str().unwrap();
|
|
302
|
+
|
|
303
|
+
// Create a file to ensure scan works, but no misalignments.
|
|
304
|
+
fs::write(tmp.path().join("requirements.txt"), "requests==1.0.0\n").unwrap();
|
|
305
|
+
|
|
306
|
+
let (stdout, _stderr, code) = run(&["align", "--output", "json", path_str], tmp.path());
|
|
307
|
+
|
|
308
|
+
// Should exit 0 (no misalignments) even in dry-run mode.
|
|
309
|
+
assert_eq!(
|
|
310
|
+
code, 0,
|
|
311
|
+
"align dry-run on aligned workspace must exit 0, got {code}"
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
// Output must be valid JSON with command=align.
|
|
315
|
+
let doc = parse_json(&stdout);
|
|
316
|
+
assert_eq!(
|
|
317
|
+
doc["command"], "align",
|
|
318
|
+
"align must emit command=align in JSON output; got: {stdout}"
|
|
319
|
+
);
|
|
320
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|