Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 90 additions & 12 deletions crates/crosspack-cli/src/core_flows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1616,27 +1616,33 @@ fn bin_cache_file_name_from_url(artifact_url: &str) -> Result<String> {
}

fn format_install_outcome_lines(outcome: &InstallOutcome, style: OutputStyle) -> Vec<String> {
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 {}",
outcome.name, outcome.version, outcome.resolved_target
),
),
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: {} ({})",
Expand All @@ -1645,22 +1651,22 @@ fn format_install_outcome_lines(outcome: &InstallOutcome, style: OutputStyle) ->
),
),
render_status_line(
style,
detail_style,
"step",
&format!("install_root: {}", outcome.install_root.display()),
),
];

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: {}",
Expand All @@ -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: {}",
Expand All @@ -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: {}",
Expand All @@ -1690,24 +1696,96 @@ 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()),
));

lines
}

fn format_rich_install_outcome_lines(outcome: &InstallOutcome) -> Vec<String> {
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<Vec<String>> {
Expand Down
20 changes: 20 additions & 0 deletions crates/crosspack-cli/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
73 changes: 69 additions & 4 deletions crates/crosspack-cli/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();

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();
Expand All @@ -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();
Expand Down
6 changes: 5 additions & 1 deletion docs/install-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down