diff --git a/crates/crosspack-cli/src/core_flows.rs b/crates/crosspack-cli/src/core_flows.rs index 72acf06..7563e9f 100644 --- a/crates/crosspack-cli/src/core_flows.rs +++ b/crates/crosspack-cli/src/core_flows.rs @@ -1616,9 +1616,15 @@ fn bin_cache_file_name_from_url(artifact_url: &str) -> Result { } fn format_install_outcome_lines(outcome: &InstallOutcome, style: OutputStyle) -> Vec { + let detail_style = if style == OutputStyle::Rich { + OutputStyle::Plain + } else { + style + }; + let mut lines = vec![ render_status_line( - style, + detail_style, "ok", &format!( "resolved {} {} for {}", @@ -1626,17 +1632,17 @@ fn format_install_outcome_lines(outcome: &InstallOutcome, style: OutputStyle) -> ), ), render_status_line( - style, + detail_style, "step", &format!("archive: {}", outcome.archive_type.as_str()), ), render_status_line( - style, + detail_style, "step", &format!("artifact: {}", outcome.artifact_url), ), render_status_line( - style, + detail_style, "step", &format!( "cache: {} ({})", @@ -1645,7 +1651,7 @@ fn format_install_outcome_lines(outcome: &InstallOutcome, style: OutputStyle) -> ), ), render_status_line( - style, + detail_style, "step", &format!("install_root: {}", outcome.install_root.display()), ), @@ -1653,14 +1659,14 @@ fn format_install_outcome_lines(outcome: &InstallOutcome, style: OutputStyle) -> if !outcome.exposed_bins.is_empty() { lines.push(render_status_line( - style, + detail_style, "step", &format!("exposed_bins: {}", outcome.exposed_bins.join(", ")), )); } if !outcome.exposed_completions.is_empty() { lines.push(render_status_line( - style, + detail_style, "step", &format!( "exposed_completions: {}", @@ -1670,7 +1676,7 @@ fn format_install_outcome_lines(outcome: &InstallOutcome, style: OutputStyle) -> } if !outcome.exposed_gui_assets.is_empty() { lines.push(render_status_line( - style, + detail_style, "step", &format!( "exposed_gui_assets: {}", @@ -1680,7 +1686,7 @@ fn format_install_outcome_lines(outcome: &InstallOutcome, style: OutputStyle) -> } if !outcome.native_gui_records.is_empty() { lines.push(render_status_line( - style, + detail_style, "step", &format!( "native_gui_records: {}", @@ -1690,13 +1696,13 @@ fn format_install_outcome_lines(outcome: &InstallOutcome, style: OutputStyle) -> } for warning in &outcome.warnings { lines.push(render_status_line( - style, + detail_style, "warn", &format!("warning: {warning}"), )); } lines.push(render_status_line( - style, + detail_style, "step", &format!("receipt: {}", outcome.receipt_path.display()), )); @@ -1704,10 +1710,82 @@ fn format_install_outcome_lines(outcome: &InstallOutcome, style: OutputStyle) -> lines } +fn format_rich_install_outcome_lines(outcome: &InstallOutcome) -> Vec { + let mut lines = vec![ + render_rich_install_detail_row( + "ok", + "resolved", + &format!( + "{} {} for {}", + outcome.name, outcome.version, outcome.resolved_target + ), + ), + render_rich_install_detail_row("step", "archive", outcome.archive_type.as_str()), + render_rich_install_detail_row("step", "artifact", &outcome.artifact_url), + render_rich_install_detail_row( + "step", + "cache", + &format!( + "{} ({})", + outcome.cache_path.display(), + outcome.download_status + ), + ), + render_rich_install_detail_row( + "step", + "install_root", + &outcome.install_root.display().to_string(), + ), + ]; + + if !outcome.exposed_bins.is_empty() { + lines.push(render_rich_install_detail_row( + "step", + "exposed_bins", + &outcome.exposed_bins.join(", "), + )); + } + if !outcome.exposed_completions.is_empty() { + lines.push(render_rich_install_detail_row( + "step", + "exposed_completions", + &outcome.exposed_completions.join(", "), + )); + } + if !outcome.exposed_gui_assets.is_empty() { + lines.push(render_rich_install_detail_row( + "step", + "exposed_gui_assets", + &outcome.exposed_gui_assets.join(", "), + )); + } + if !outcome.native_gui_records.is_empty() { + lines.push(render_rich_install_detail_row( + "step", + "native_gui_records", + &outcome.native_gui_records.join(", "), + )); + } + for warning in &outcome.warnings { + lines.push(render_rich_install_detail_row("warn", "warning", warning)); + } + lines.push(render_rich_install_detail_row( + "step", + "receipt", + &outcome.receipt_path.display().to_string(), + )); + + lines +} + fn print_install_outcome(outcome: &InstallOutcome, style: OutputStyle) { let renderer = TerminalRenderer::from_style(style); renderer.print_section(&format!("Installed {} {}", outcome.name, outcome.version)); - renderer.print_lines(&format_install_outcome_lines(outcome, style)); + let lines = match style { + OutputStyle::Plain => format_install_outcome_lines(outcome, OutputStyle::Plain), + OutputStyle::Rich => format_rich_install_outcome_lines(outcome), + }; + renderer.print_lines(&lines); } fn collect_declared_binaries(artifact: &Artifact) -> Result> { diff --git a/crates/crosspack-cli/src/render.rs b/crates/crosspack-cli/src/render.rs index e8b1fa3..844265a 100644 --- a/crates/crosspack-cli/src/render.rs +++ b/crates/crosspack-cli/src/render.rs @@ -212,3 +212,23 @@ fn render_progress_line( suffix )) } + +fn install_detail_status_label(status: &str) -> &'static str { + match status { + "ok" => "OK", + "warn" => "WARN", + "error" => "ERR", + "step" => "STEP", + _ => "INFO", + } +} + +fn render_rich_install_detail_row(status: &str, key: &str, value: &str) -> String { + let key_label = format!("{key}:"); + format!( + "{:<4} | {:<18} | {}", + install_detail_status_label(status), + key_label, + value + ) +} diff --git a/crates/crosspack-cli/src/tests.rs b/crates/crosspack-cli/src/tests.rs index c1f6247..b1f547a 100644 --- a/crates/crosspack-cli/src/tests.rs +++ b/crates/crosspack-cli/src/tests.rs @@ -7059,6 +7059,21 @@ sha256 = "abc" ); } + #[test] + fn render_rich_install_detail_row_is_structured_and_badge_free() { + let line = render_rich_install_detail_row("step", "archive", "tar.zst"); + let columns = line.split('|').map(str::trim).collect::>(); + + assert_eq!(columns, vec!["STEP", "archive:", "tar.zst"]); + assert!( + !line.contains("[OK]") + && !line.contains("[..]") + && !line.contains("[ERR]") + && !line.contains("[WARN]"), + "rich install detail row must avoid plain status badges: {line}" + ); + } + #[test] fn format_update_output_lines_plain_preserves_contract_lines() { let report = sample_update_report(); @@ -7082,20 +7097,70 @@ sha256 = "abc" let outcome = sample_install_outcome(); let lines = format_install_outcome_lines(&outcome, OutputStyle::Plain); assert_eq!( - lines[0], - "resolved ripgrep 14.1.0 for x86_64-unknown-linux-gnu" + lines, + vec![ + "resolved ripgrep 14.1.0 for x86_64-unknown-linux-gnu".to_string(), + "archive: tar.zst".to_string(), + "artifact: https://example.test/ripgrep-14.1.0.tar.zst".to_string(), + "cache: /tmp/crosspack/cache/ripgrep/14.1.0/artifact.tar.zst (downloaded)" + .to_string(), + "install_root: /tmp/crosspack/pkgs/ripgrep/14.1.0".to_string(), + "exposed_bins: rg".to_string(), + "exposed_completions: bash:rg".to_string(), + "exposed_gui_assets: app:dev.ripgrep.viewer".to_string(), + "native_gui_records: app:dev.ripgrep.viewer".to_string(), + "receipt: /tmp/crosspack/state/installed/ripgrep.receipt".to_string(), + ] + ); + } + + #[test] + fn format_rich_install_outcome_lines_are_structured_and_badge_free() { + let mut outcome = sample_install_outcome(); + outcome + .warnings + .push("native registration skipped".to_string()); + + let lines = format_rich_install_outcome_lines(&outcome); + + assert!( + lines.iter().all(|line| line.contains('|')), + "rich install detail rows must use status/key/value columns: {lines:?}" + ); + assert!( + lines.iter().all(|line| { + !line.contains("[OK]") + && !line.contains("[..]") + && !line.contains("[ERR]") + && !line.contains("[WARN]") + }), + "rich install detail rows must avoid plain status badges: {lines:?}" + ); + assert!( + lines.iter().any(|line| line.starts_with("WARN")), + "rich install detail rows must preserve warning severity: {lines:?}" ); - assert_eq!(lines[1], "archive: tar.zst"); } #[test] fn format_install_outcome_lines_rich_adds_step_indicators() { let outcome = sample_install_outcome(); let lines = format_install_outcome_lines(&outcome, OutputStyle::Rich); - assert!(lines[0].starts_with("[OK] ")); assert!(lines.iter().any(|line| line.contains("receipt: "))); } + #[test] + fn format_install_outcome_lines_rich_does_not_include_plain_status_badges() { + let outcome = sample_install_outcome(); + let lines = format_install_outcome_lines(&outcome, OutputStyle::Rich); + assert!( + lines + .iter() + .all(|line| !line.contains("[OK]") && !line.contains("[..]")), + "rich install outcome details must avoid plain status badges: {lines:?}" + ); + } + #[test] fn install_resolved_emits_warning_when_native_gui_registration_fails() { let mut outcome = sample_install_outcome(); diff --git a/docs/install-flow.md b/docs/install-flow.md index 848383c..e426297 100644 --- a/docs/install-flow.md +++ b/docs/install-flow.md @@ -69,13 +69,17 @@ - no transaction metadata, receipts, package files, or binaries are mutated. For non-dry-run lifecycle output, Crosspack auto-selects output mode: -- interactive terminal: enhanced terminal UX (section hierarchy, semantic color, and progress indicators) for human readability, +- interactive terminal: enhanced terminal UX (section hierarchy, semantic color, progress indicators, and rich install detail rows), - non-interactive/piped output: plain deterministic lines. Interactive rich mode also renders live download telemetry during the download phase: - when HTTP `Content-Length` is available, progress includes downloaded bytes and percent, - when total size is unknown, progress includes downloaded bytes only. +In interactive mode, install detail rows are rendered in a dedicated rich shape: +- `STATUS | key: | value` +- no plain status badge tokens (`[OK]`, `[..]`, `[ERR]`, `[WARN]`) are used in install outcome detail rows. + Plain mode keeps existing deterministic line contracts unchanged (no live byte/percent progress frames). Machine-oriented dry-run preview lines remain unchanged regardless of output mode.