Skip to content

Commit 761b909

Browse files
authored
feat(cargo-mono): improve runtime errors with multiline structured context (#297)
## Summary - switch cargo-mono runtime errors to a fixed 3-line format: `Summary`, `Context`, `Hint` - enrich error context with safe operational data (command/status/base ref/package/path/attempt) - add context normalization and truncation to keep output safe and readable - render publish failures in human output as multiline blocks for consistency - update cargo-mono docs/contracts and crates AGENTS rule to reflect the new error UX contract ## Testing - cargo fmt --all - cargo test ## Notes - keeps error kind labels and exit-code mapping unchanged - does not change command behavior or JSON output schema keys
1 parent 9506419 commit 761b909

10 files changed

Lines changed: 428 additions & 130 deletions

File tree

crates/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
- Keep command identifiers stable and documented in `docs/project-cargo-mono.md` and `docs/crates-cargo-mono-foundation.md`.
3333
- Preserve `cargo mono` subcommand compatibility (`cargo-mono` binary naming contract).
3434
- Ensure release automation (`bump`, `publish`) logs include structured operational context.
35+
- Keep runtime error output on the fixed `Summary/Context/Hint` three-line contract and include only safe debugging context values.
3536

3637
### serde-feather-Specific Rules
3738

crates/cargo-mono/src/commands/publish.rs

Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use tracing::{info, warn};
1717
use crate::{
1818
cli::PublishArgs,
1919
commands::{print_output, targeting},
20-
errors::{message_with_hint, CargoMonoError, ErrorKind, Result},
20+
errors::{message_with_details, CargoMonoError, ErrorKind, Result},
2121
types::{OutputFormat, PublishSkipReason},
2222
workspace::Workspace,
2323
CargoMonoApp,
@@ -324,8 +324,11 @@ pub fn execute(args: &PublishArgs, output: OutputFormat, app: &CargoMonoApp) ->
324324
attempts,
325325
error: format_publish_failure(
326326
&package_name,
327+
attempts,
327328
&publish_output.status.to_string(),
328329
&details,
330+
args.dry_run,
331+
args.registry.as_deref(),
329332
),
330333
});
331334
published_or_skipped = true;
@@ -338,7 +341,12 @@ pub fn execute(args: &PublishArgs, output: OutputFormat, app: &CargoMonoApp) ->
338341
failed.push(FailedPackage {
339342
name: package_name.clone(),
340343
attempts,
341-
error: format_publish_retry_limit_failure(&package_name, attempts),
344+
error: format_publish_retry_limit_failure(
345+
&package_name,
346+
attempts,
347+
args.dry_run,
348+
args.registry.as_deref(),
349+
),
342350
});
343351
}
344352
}
@@ -390,9 +398,10 @@ pub fn execute(args: &PublishArgs, output: OutputFormat, app: &CargoMonoApp) ->
390398

391399
for item in &result.failed {
392400
human_lines.push(format!(
393-
"- failed {} (attempts={}): {}",
394-
item.name, item.attempts, item.error
401+
"- failed {} (attempts={}):",
402+
item.name, item.attempts
395403
));
404+
human_lines.push(indent_multiline(&item.error, " "));
396405
}
397406

398407
print_output(output, &human_lines.join("\n"), &result)?;
@@ -852,33 +861,63 @@ fn run_publish_command(package: &str, dry_run: bool, registry: Option<&str>) ->
852861
}
853862

854863
command.output().map_err(|error| {
855-
CargoMonoError::with_hint(
864+
CargoMonoError::with_details(
856865
ErrorKind::Cargo,
857-
format!("Failed to start `cargo publish` for package `{package}`: {error}"),
866+
"Failed to start `cargo publish` command.",
867+
vec![
868+
("package", package.to_string()),
869+
("dry_run", dry_run.to_string()),
870+
("registry", registry.unwrap_or("default").to_string()),
871+
("error", error.to_string()),
872+
],
858873
"Ensure Cargo is installed, the package exists, and registry credentials are \
859874
configured before retrying.",
860875
)
861876
})
862877
}
863878

864-
fn format_publish_failure(package: &str, status: &str, raw_details: &str) -> String {
879+
fn format_publish_failure(
880+
package: &str,
881+
attempts: usize,
882+
status: &str,
883+
raw_details: &str,
884+
dry_run: bool,
885+
registry: Option<&str>,
886+
) -> String {
865887
let details = compact_error_details(raw_details);
866-
let summary = if details.is_empty() {
867-
format!("`cargo publish` failed for package `{package}` with status {status}.")
868-
} else {
869-
format!("`cargo publish` failed for package `{package}`: {details}")
870-
};
871-
message_with_hint(
872-
summary,
888+
let mut context = vec![
889+
("package", package.to_string()),
890+
("attempt", attempts.to_string()),
891+
("status", status.to_string()),
892+
("dry_run", dry_run.to_string()),
893+
("registry", registry.unwrap_or("default").to_string()),
894+
];
895+
if !details.is_empty() {
896+
context.push(("details_excerpt", details));
897+
}
898+
899+
message_with_details(
900+
"`cargo publish` failed for package.",
901+
&context,
873902
"Verify package metadata, registry access, and network connectivity, then retry.",
874903
)
875904
}
876905

877-
fn format_publish_retry_limit_failure(package: &str, attempts: usize) -> String {
878-
message_with_hint(
879-
format!(
880-
"`cargo publish` did not complete for package `{package}` within {attempts} attempts."
881-
),
906+
fn format_publish_retry_limit_failure(
907+
package: &str,
908+
attempts: usize,
909+
dry_run: bool,
910+
registry: Option<&str>,
911+
) -> String {
912+
message_with_details(
913+
"`cargo publish` did not complete within retry attempts.",
914+
&[
915+
("package", package.to_string()),
916+
("attempts", attempts.to_string()),
917+
("max_attempts", MAX_PUBLISH_ATTEMPTS.to_string()),
918+
("dry_run", dry_run.to_string()),
919+
("registry", registry.unwrap_or("default").to_string()),
920+
],
882921
"Wait for index propagation or rate limits to clear, then rerun publish.",
883922
)
884923
}
@@ -887,6 +926,13 @@ fn compact_error_details(raw: &str) -> String {
887926
raw.split_whitespace().collect::<Vec<_>>().join(" ")
888927
}
889928

929+
fn indent_multiline(raw: &str, prefix: &str) -> String {
930+
raw.lines()
931+
.map(|line| format!("{prefix}{line}"))
932+
.collect::<Vec<_>>()
933+
.join("\n")
934+
}
935+
890936
fn retry_delay(attempt: usize) -> Duration {
891937
match attempt {
892938
1 => Duration::from_secs(2),
@@ -994,25 +1040,40 @@ mod tests {
9941040

9951041
#[test]
9961042
fn format_publish_failure_uses_status_when_no_details_exist() {
997-
let message = format_publish_failure("alpha", "exit status: 101", "");
998-
assert!(message
999-
.contains("`cargo publish` failed for package `alpha` with status exit status: 101."));
1043+
let message =
1044+
format_publish_failure("alpha", 1, "exit status: 101", "", false, Some("crates-io"));
1045+
assert!(message.contains("Summary: `cargo publish` failed for package."));
1046+
assert!(message.contains("package=alpha"));
1047+
assert!(message.contains("attempt=1"));
1048+
assert!(message.contains("status=exit status: 101"));
10001049
assert!(message.contains("Hint: "));
10011050
}
10021051

10031052
#[test]
10041053
fn format_publish_failure_compacts_multiline_details() {
1005-
let message = format_publish_failure("alpha", "ignored", "error:\nnetwork timeout\n");
1006-
assert!(
1007-
message.contains("`cargo publish` failed for package `alpha`: error: network timeout")
1054+
let message = format_publish_failure(
1055+
"alpha",
1056+
2,
1057+
"exit status: 101",
1058+
"error:\nnetwork timeout\n",
1059+
true,
1060+
None,
10081061
);
1062+
assert!(message.contains("details_excerpt=error: network timeout"));
1063+
assert!(message.contains("dry_run=true"));
1064+
assert!(message.contains("registry=default"));
10091065
assert!(message.contains("Hint: "));
10101066
}
10111067

10121068
#[test]
10131069
fn format_publish_retry_limit_failure_includes_hint() {
1014-
let message = format_publish_retry_limit_failure("alpha", 3);
1015-
assert!(message.contains("within 3 attempts."));
1070+
let message = format_publish_retry_limit_failure("alpha", 3, false, Some("internal"));
1071+
assert!(
1072+
message.contains("Summary: `cargo publish` did not complete within retry attempts.")
1073+
);
1074+
assert!(message.contains("attempts=3"));
1075+
assert!(message.contains("max_attempts=3"));
1076+
assert!(message.contains("registry=internal"));
10161077
assert!(message.contains("Hint: "));
10171078
}
10181079

crates/cargo-mono/src/commands/targeting.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,19 @@ pub fn resolve_targets(
4848
.collect::<Vec<_>>();
4949

5050
if !missing.is_empty() {
51-
return Err(CargoMonoError::with_hint(
51+
let requested = names.iter().cloned().collect::<Vec<_>>();
52+
return Err(CargoMonoError::with_details(
5253
ErrorKind::InvalidInput,
53-
format!("Unknown package selector(s): {}", missing.join(", ")),
54+
"Unknown package selector(s).",
55+
vec![
56+
("requested_packages", requested.join(",")),
57+
("missing_packages", missing.join(",")),
58+
("selected_count", requested.len().to_string()),
59+
(
60+
"workspace_package_count",
61+
workspace.all_package_names().len().to_string(),
62+
),
63+
],
5464
"Run `cargo mono list` to view valid workspace package names.",
5565
));
5666
}

0 commit comments

Comments
 (0)