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.
Files changed (81) hide show
  1. {upd_cli-0.2.0 → upd_cli-0.2.1}/CHANGELOG.md +7 -0
  2. {upd_cli-0.2.0 → upd_cli-0.2.1}/Cargo.lock +1 -1
  3. {upd_cli-0.2.0 → upd_cli-0.2.1}/Cargo.toml +1 -1
  4. {upd_cli-0.2.0 → upd_cli-0.2.1}/PKG-INFO +1 -1
  5. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/cli.rs +10 -9
  6. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/main.rs +205 -61
  7. upd_cli-0.2.1/tests/defect_fixes.rs +320 -0
  8. {upd_cli-0.2.0 → upd_cli-0.2.1}/.mise.toml +0 -0
  9. {upd_cli-0.2.0 → upd_cli-0.2.1}/.pre-commit-config.yaml +0 -0
  10. {upd_cli-0.2.0 → upd_cli-0.2.1}/.pre-commit-hooks.yaml +0 -0
  11. {upd_cli-0.2.0 → upd_cli-0.2.1}/.rumdl.toml +0 -0
  12. {upd_cli-0.2.0 → upd_cli-0.2.1}/LICENSE +0 -0
  13. {upd_cli-0.2.0 → upd_cli-0.2.1}/Makefile +0 -0
  14. {upd_cli-0.2.0 → upd_cli-0.2.1}/README.md +0 -0
  15. {upd_cli-0.2.0 → upd_cli-0.2.1}/assets/logo-wide.svg +0 -0
  16. {upd_cli-0.2.0 → upd_cli-0.2.1}/assets/logo.svg +0 -0
  17. {upd_cli-0.2.0 → upd_cli-0.2.1}/fixtures/clispec-v0.2.json +0 -0
  18. {upd_cli-0.2.0 → upd_cli-0.2.1}/pyproject.toml +0 -0
  19. {upd_cli-0.2.0 → upd_cli-0.2.1}/python/upd_cli/__init__.py +0 -0
  20. {upd_cli-0.2.0 → upd_cli-0.2.1}/python/upd_cli/__main__.py +0 -0
  21. {upd_cli-0.2.0 → upd_cli-0.2.1}/python/upd_cli/py.typed +0 -0
  22. {upd_cli-0.2.0 → upd_cli-0.2.1}/rust-toolchain.toml +0 -0
  23. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/align.rs +0 -0
  24. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/audit/cache.rs +0 -0
  25. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/audit/cvss.rs +0 -0
  26. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/audit/mod.rs +0 -0
  27. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/bin/upd-cli.rs +0 -0
  28. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/cache.rs +0 -0
  29. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/config.rs +0 -0
  30. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/cooldown.rs +0 -0
  31. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/http.rs +0 -0
  32. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/interactive.rs +0 -0
  33. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/lib.rs +0 -0
  34. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/lockfile.rs +0 -0
  35. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/output.rs +0 -0
  36. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/crates_io.rs +0 -0
  37. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/github_releases.rs +0 -0
  38. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/go_proxy.rs +0 -0
  39. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/mock.rs +0 -0
  40. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/mod.rs +0 -0
  41. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/npm.rs +0 -0
  42. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/nuget.rs +0 -0
  43. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/pypi.rs +0 -0
  44. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/rubygems.rs +0 -0
  45. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/terraform.rs +0 -0
  46. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/registry/utils.rs +0 -0
  47. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/schema.rs +0 -0
  48. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/cargo_toml.rs +0 -0
  49. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/csproj.rs +0 -0
  50. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/gemfile.rs +0 -0
  51. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/github_actions.rs +0 -0
  52. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/go_mod.rs +0 -0
  53. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/mise.rs +0 -0
  54. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/mod.rs +0 -0
  55. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/npm_range.rs +0 -0
  56. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/package_json.rs +0 -0
  57. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/pre_commit.rs +0 -0
  58. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/pyproject.rs +0 -0
  59. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/requirements.rs +0 -0
  60. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/updater/terraform.rs +0 -0
  61. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/version/compare.rs +0 -0
  62. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/version/mod.rs +0 -0
  63. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/version/pep440.rs +0 -0
  64. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/version/semver_util.rs +0 -0
  65. {upd_cli-0.2.0 → upd_cli-0.2.1}/src/version/tag.rs +0 -0
  66. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/audit_offline.rs +0 -0
  67. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/audit_sarif.rs +0 -0
  68. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/audit_severity.rs +0 -0
  69. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/bump_filter.rs +0 -0
  70. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/cooldown_e2e.rs +0 -0
  71. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/discovery_no_ignore.rs +0 -0
  72. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/exit_codes.rs +0 -0
  73. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/fix_audit.rs +0 -0
  74. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/format_json.rs +0 -0
  75. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/help_text.rs +0 -0
  76. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/interactive_tty.rs +0 -0
  77. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/invalid_positional.rs +0 -0
  78. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/no_args_scope.rs +0 -0
  79. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/output_streams.rs +0 -0
  80. {upd_cli-0.2.0 → upd_cli-0.2.1}/tests/package_filter.rs +0 -0
  81. {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
@@ -2015,7 +2015,7 @@ checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47"
2015
2015
 
2016
2016
  [[package]]
2017
2017
  name = "upd"
2018
- version = "0.2.0"
2018
+ version = "0.2.1"
2019
2019
  dependencies = [
2020
2020
  "anyhow",
2021
2021
  "async-trait",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "upd"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  edition = "2024"
5
5
  rust-version = "1.95.0"
6
6
  description = "A fast dependency updater for Python, Node.js, Rust, Go, Ruby, Terraform, GitHub Actions, pre-commit, and Mise projects"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upd-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -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, default_value_t = OutputFormat::Text, value_name = "FORMAT")]
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 test_cli_format_defaults_to_text() {
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, OutputFormat::Text);
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
- if e.kind() != clap::error::ErrorKind::DisplayHelp
528
- && e.kind() != clap::error::ErrorKind::DisplayVersion
529
- && e.kind() != clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
530
- {
531
- eprintln!(
532
- "{}",
533
- serde_json::json!({"error": {"kind": "parse_error", "message": e.to_string(), "exit_code": 4}})
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
- e.exit()
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 == upd::cli::OutputFormat::Sarif {
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: use --format if set to json, otherwise TTY-detect.
624
- OutputMode::Auto => {
625
- if cli.format == upd::cli::OutputFormat::Json {
626
- true
627
- } else {
628
- cli.is_json_output()
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
- &UpdateResult::default(),
683
- 0,
684
- effective_dry_run,
685
- UpdateFilter::from_cli(&cli.only_bump, cli.max_bump),
686
- &HashMap::new(),
687
- Vec::new(),
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
- &scanned,
1150
- &total_result,
1151
- file_count,
1152
- dry_run,
1153
- filter,
1154
- &file_cooldowns,
1155
- notes_vec,
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
- fn emit_update_json(
1170
- scanned: &[ScannedFileResult],
1171
- total_result: &UpdateResult,
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
- ) -> Result<()> {
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
- println!("{}", serde_json::to_string_pretty(&report)?);
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
- "{} --interactive requires a terminal on stdin",
1269
- "error:".red()
1270
- );
1271
- eprintln!(
1272
- "{} use --check to preview updates, or --dry-run to print proposed changes",
1273
- "hint:".dimmed()
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 {} skipping (nothing to regenerate)",
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.check || cli.dry_run || (!cli.apply && !cli.interactive);
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(packages: &[PackageAlignment], file_count: usize) -> Result<()> {
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
- println!("{}", serde_json::to_string_pretty(&report)?);
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(&AuditResult::default(), "complete")?;
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(&AuditResult::default(), "complete")?;
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(&audit_result, status_str)?;
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 != 0 {
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(audit: &AuditResult, status: &'static str) -> Result<()> {
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
- println!("{}", serde_json::to_string_pretty(&report)?);
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