feat(cli): render fatal errors with miette

Wrap root CLI errors at the main boundary so fatal diagnostics use miette's styled renderer while preserving existing telemetry, exit codes, and auth help hints.
This commit is contained in:
Bryan Helmkamp 2026-04-24 15:35:57 -04:00
parent 89bf987457
commit 7e64bf8b69
No known key found for this signature in database
36 changed files with 259 additions and 96 deletions

99
Cargo.lock generated
View file

@ -478,6 +478,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "backtrace-ext"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
dependencies = [
"backtrace",
]
[[package]]
name = "base64"
version = "0.22.1"
@ -1663,6 +1672,7 @@ dependencies = [
"insta",
"jsonwebtoken",
"libc",
"miette",
"object_store",
"openssl",
"paste",
@ -3520,6 +3530,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_ci"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@ -3964,6 +3980,36 @@ dependencies = [
"autocfg",
]
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"backtrace",
"backtrace-ext",
"cfg-if",
"miette-derive",
"owo-colors",
"supports-color",
"supports-hyperlinks",
"supports-unicode",
"terminal_size",
"textwrap",
"unicode-width 0.1.14",
]
[[package]]
name = "miette-derive"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "mime"
version = "0.3.17"
@ -4625,6 +4671,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "owo-colors"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "panic-message"
version = "0.3.0"
@ -6379,6 +6431,27 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "supports-color"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
dependencies = [
"is_ci",
]
[[package]]
name = "supports-hyperlinks"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91"
[[package]]
name = "supports-unicode"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
[[package]]
name = "syn"
version = "1.0.109"
@ -6534,12 +6607,32 @@ dependencies = [
"unicode-width 0.1.14",
]
[[package]]
name = "terminal_size"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"unicode-linebreak",
"unicode-width 0.2.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@ -7109,6 +7202,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"

View file

@ -78,6 +78,7 @@ object_store = { version = "0.12.5", features = ["aws"] }
rust-embed = "8"
percent-encoding = "2"
minijinja = "2"
miette = { version = "7.6", features = ["fancy"] }
fabro-http = { path = "lib/crates/fabro-http" }
fabro-redact = { path = "lib/crates/fabro-redact" }
fabro-static = { path = "lib/crates/fabro-static" }

View file

@ -510,7 +510,7 @@ Solid arrows are real dependencies. Dashed lines show phases that are independen
### Phase 4 — miette CLI diagnostics wrapper
- [ ] **Unit 4.1: Wrap `fabro-cli` root error with `miette::Diagnostic`**
- [x] **Unit 4.1: Wrap `fabro-cli` root error with `miette::Diagnostic`**
**Goal:** Render CLI errors through `miette` at the `main` boundary, preserving existing exit-class hint behavior while gaining styled chained errors and `help:` footer rendering. Source-highlighted output is out of scope until fabro has errors that carry span/source context.

View file

@ -56,6 +56,7 @@ console.workspace = true
indicatif.workspace = true
daytona-sdk.workspace = true
anyhow.workspace = true
miette.workspace = true
dotenvy.workspace = true
tokio.workspace = true
tracing.workspace = true

View file

@ -20,6 +20,7 @@ mod user_config;
#[cfg(test)]
use std::ffi::OsString;
use std::fmt::{self, Debug, Display};
use anyhow::Result;
use args::{
@ -94,6 +95,8 @@ async fn main() {
None
};
install_miette_hook();
tel_panic::install_panic_hook();
fabro_telemetry::init_cli();
@ -135,38 +138,69 @@ async fn main() {
fabro_telemetry::shutdown();
if let Err(err) = result {
let style = console::Style::new().red().bold();
for (i, cause) in err.chain().enumerate() {
let text = cause.to_string();
if i == 0 {
for (j, line) in text.lines().enumerate() {
if j == 0 {
eprintln!("{} {line}", style.apply_to("error:"));
} else {
eprintln!(" {line}");
}
}
} else {
for line in text.lines() {
eprintln!(" > {line}");
}
}
}
let json_mode = raw_args.iter().any(|a| a == "--json");
if !json_mode && exit::exit_class_for(&err) == Some(ExitClass::AuthRequired) {
let hint_style = console::Style::new().cyan().bold();
let cmd_style = console::Style::new().bold();
eprintln!();
eprintln!(
"{} Run {} to authenticate.",
hint_style.apply_to("hint:"),
cmd_style.apply_to("`fabro auth login`"),
);
}
eprintln!(
"{:?}",
miette::Report::new(CliDiagnostic::new(err, !json_mode))
);
std::process::exit(exit_code);
}
}
fn install_miette_hook() {
let _ = miette::set_hook(Box::new(|_| {
Box::new(
miette::MietteHandlerOpts::new()
.with_cause_chain()
.wrap_lines(false)
.break_words(false)
.build(),
)
}));
}
struct CliDiagnostic {
err: anyhow::Error,
show_auth_hint: bool,
}
impl CliDiagnostic {
fn new(err: anyhow::Error, show_auth_hint: bool) -> Self {
Self {
err,
show_auth_hint,
}
}
}
impl Display for CliDiagnostic {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
Display::fmt(&self.err, formatter)
}
}
impl Debug for CliDiagnostic {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
Debug::fmt(&self.err, formatter)
}
}
impl std::error::Error for CliDiagnostic {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.err.source()
}
}
impl miette::Diagnostic for CliDiagnostic {
fn help<'a>(&'a self) -> Option<Box<dyn Display + 'a>> {
if self.show_auth_hint && exit::exit_class_for(&self.err) == Some(ExitClass::AuthRequired) {
Some(Box::new("Run `fabro auth login` to authenticate."))
} else {
None
}
}
}
#[expect(
clippy::disallowed_methods,
reason = "CLI main reads documented process-env controls before telemetry and worker dispatch."

View file

@ -15,6 +15,6 @@ fn artifact_cp_empty_run_reports_no_artifacts() {
exit_code: 1
----- stdout -----
----- stderr -----
error: No artifacts found for this run
× No artifacts found for this run
");
}

View file

@ -462,7 +462,7 @@ fn create_invalid_workflow_fails_without_creating_run() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Validation failed
× Validation failed
");
let run_count = run_count_for_test_case(&context);

View file

@ -43,7 +43,7 @@ fn diff_completed_run_without_changes_reports_no_patch() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Run completed but no stored diff exists the run may not have produced any changes
× Run completed but no stored diff exists the run may not have produced any changes
");
}
@ -59,7 +59,7 @@ fn diff_missing_node_diff_reports_helpful_error() {
exit_code: 1
----- stdout -----
----- stderr -----
error: No diff found for node 'missing' check the node ID and try again
× No diff found for node 'missing' check the node ID and try again
");
}

View file

@ -303,7 +303,7 @@ fn dump_rejects_non_empty_output_dir() {
exit_code: 1
----- stdout -----
----- stderr -----
error: output path [TEMP_DIR]/nonempty already exists and is not an empty directory; remove it first or choose a different path
× output path [TEMP_DIR]/nonempty already exists and is not an empty directory; remove it first or choose a different path
");
}

View file

@ -103,7 +103,7 @@ fn exec_missing_api_key_exits_with_error() {
exit_code: 1
----- stdout -----
----- stderr -----
error: LLM credentials not configured for provider 'anthropic'
× LLM credentials not configured for provider 'anthropic'
");
}
@ -129,7 +129,7 @@ fn exec_uses_user_config_defaults() {
exit_code: 1
----- stdout -----
----- stderr -----
error: LLM credentials not configured for provider 'openai'
× LLM credentials not configured for provider 'openai'
");
}
@ -350,6 +350,12 @@ fn exec_server_target_auth_failure_exits_with_4() {
fatal_error_line(&output.stderr),
"LLM error: Authentication error for openai: Authentication required."
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = console::strip_ansi_codes(&stderr);
assert!(
stderr.contains("Run `fabro auth login` to authenticate."),
"auth failures should retain the login help footer:\n{stderr}"
);
}
#[test]

View file

@ -48,7 +48,7 @@ fn fork_outside_git_repo_errors() {
exit_code: 1
----- stdout -----
----- stderr -----
error: No run found matching '[ULID]' (tried run ID prefix and workflow name)
× No run found matching '[ULID]' (tried run ID prefix and workflow name)
");
}

View file

@ -86,6 +86,6 @@ fn graph_invalid_workflow_fails_after_diagnostics() {
----- stderr -----
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
error: Validation failed
× Validation failed
");
}

View file

@ -181,7 +181,7 @@ fn list_invalid_provider_errors() {
exit_code: 1
----- stdout -----
----- stderr -----
error: unknown provider: not-a-provider
× unknown provider: not-a-provider
");
}

View file

@ -52,7 +52,7 @@ fn model_test_unknown_model_errors() {
----- stdout -----
----- stderr -----
Testing nonexistent-model-xyz... done
error: Unknown model: nonexistent-model-xyz
× Unknown model: nonexistent-model-xyz
");
}
@ -71,7 +71,7 @@ fn single_model_skip_exits_nonzero() {
gemini-3.1-pro-preview gemini gemini-pro 1m $2.0 / $12.0 85 tok/s not configured
----- stderr -----
Testing gemini-3.1-pro-preview... done
error: 1 model(s) failed
× 1 model(s) failed
");
}

View file

@ -125,11 +125,11 @@ fn parse_invalid_dot_fails_cleanly() {
let mut cmd = context.command();
cmd.args(["parse", "bad.fabro"]);
fabro_snapshot!(context.filters(), cmd, @"
fabro_snapshot!(context.filters(), cmd, @r#"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: Parse error: grammar error: Parsing Error: Error { input: \"\", code: Char }
");
× Parse error: grammar error: Parsing Error: Error { input: "", code: Char }
"#);
}

View file

@ -52,7 +52,7 @@ fn pr_create_nongit_run_reports_missing_repo_origin() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Run has no repo origin URL pull request creation requires git metadata.
× Run has no repo origin URL pull request creation requires git metadata.
");
}

View file

@ -51,7 +51,7 @@ fn pr_view_missing_pull_request_json_errors() {
exit_code: 1
----- stdout -----
----- stderr -----
error: No pull request found in store. Create one first with: fabro pr create [ULID]
× No pull request found in store. Create one first with: fabro pr create [ULID]
");
}
@ -107,7 +107,7 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() {
exit_code: 1
----- stdout -----
----- stderr -----
error: GitHub integration unavailable on server.
× GitHub integration unavailable on server.
");
}

View file

@ -52,7 +52,7 @@ fn preflight_invalid_workflow_fails_with_validation_output() {
Graph: [FIXTURES]/invalid.fabro
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
error: Validation failed
× Validation failed
");
}

View file

@ -69,6 +69,6 @@ fn test_repo_deinit_fails_when_not_initialized() {
exit_code: 1
----- stdout -----
----- stderr -----
error: not initialized .fabro/project.toml not found
× not initialized .fabro/project.toml not found
");
}

View file

@ -122,7 +122,7 @@ fn repo_init_rejects_already_initialized_repo() {
exit_code: 1
----- stdout -----
----- stderr -----
error: already initialized .fabro/project.toml exists at [TEMP_DIR]/.fabro/project.toml
× already initialized .fabro/project.toml exists at [TEMP_DIR]/.fabro/project.toml
");
}
@ -137,6 +137,6 @@ fn repo_init_errors_outside_git_repo() {
exit_code: 1
----- stdout -----
----- stderr -----
error: not a git repository run `git init` first
× not a git repository run `git init` first
");
}

View file

@ -48,7 +48,7 @@ fn rewind_outside_git_repo_errors() {
exit_code: 1
----- stdout -----
----- stderr -----
error: No run found matching '[ULID]' (tried run ID prefix and workflow name)
× No run found matching '[ULID]' (tried run ID prefix and workflow name)
");
}

View file

@ -85,7 +85,7 @@ fn rm_rejects_submitted_run_without_force() {
----- stdout -----
----- stderr -----
cannot remove active run [ULID] (status: submitted, use force=true or --force to force)
error: some runs could not be removed
× some runs could not be removed
");
}
@ -286,7 +286,7 @@ fn rm_without_force_uses_resolve_then_surfaces_server_conflict() {
----- stdout -----
----- stderr -----
cannot remove active run [ULID] (status: running, use force=true or --force to force)
error: some runs could not be removed
× some runs could not be removed
");
resolve_mock.assert();
delete_mock.assert();
@ -310,7 +310,7 @@ fn rm_partial_failure_reports_which_identifiers_failed() {
----- stderr -----
[ULID]
error: does-not-exist: No run found matching 'does-not-exist' (tried run ID prefix and workflow name)
error: some runs could not be removed
× some runs could not be removed
");
assert!(
!run.run_dir.exists(),

View file

@ -653,7 +653,7 @@ digraph Test {
exit_code: 1
----- stdout -----
----- stderr -----
error: Precondition failed: run already finished successfully nothing to resume
× Precondition failed: run already finished successfully nothing to resume
");
let inspect_after = context

View file

@ -50,7 +50,7 @@ fn sandbox_cp_run_without_sandbox_json_errors_cleanly() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Run has no active sandbox.
× Run has no active sandbox.
");
}

View file

@ -71,7 +71,7 @@ fn sandbox_preview_rejects_non_daytona_run() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Sandbox provider does not support this capability.
× Sandbox provider does not support this capability.
");
}

View file

@ -44,7 +44,7 @@ fn sandbox_ssh_rejects_non_daytona_run() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Sandbox provider does not support this capability.
× Sandbox provider does not support this capability.
");
}

View file

@ -109,7 +109,7 @@ fn test_secret_rm_missing_key() {
exit_code: 1
----- stdout -----
----- stderr -----
error: secret not found: NOPE
× secret not found: NOPE
");
}

View file

@ -19,6 +19,8 @@ use fabro_test::{
};
use fabro_util::dev_token;
use crate::support::fatal_error_line;
const TEST_DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
const TEST_SESSION_SECRET: &str =
@ -120,13 +122,28 @@ fn run_startup_failure(context: &TestContext, mode: ServerStartMode, case: &Star
mode.name(),
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = console::strip_ansi_codes(&stderr);
let mut expected_lines = case.expected_error.lines();
let expected_fatal_line = expected_lines.next().unwrap_or(case.expected_error);
assert_eq!(
String::from_utf8_lossy(&output.stderr),
format!("error: {}\n", case.expected_error),
"unexpected stderr for {} in {} mode",
fatal_error_line(&output.stderr),
expected_fatal_line,
"unexpected fatal line for {} in {} mode\nstderr:\n{}",
case.name,
mode.name()
mode.name(),
stderr
);
for expected_line in expected_lines {
let expected_line = expected_line.trim_start();
assert!(
stderr.contains(expected_line),
"missing expected stderr detail for {} in {} mode: {expected_line}\nstderr:\n{}",
case.name,
mode.name(),
stderr
);
}
let log_path = storage_dir.join("logs/server.log");
match mode {
@ -348,7 +365,7 @@ fn start_already_running_exits_with_error() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Server already running (pid [PID]) on [SOCKET_PATH]
× Server already running (pid [PID]) on [SOCKET_PATH]
");
context
@ -765,8 +782,8 @@ fn start_with_missing_explicit_flag_config_errors_without_entering_install_mode(
exit_code: 1
----- stdout -----
----- stderr -----
error: reading config file [MISSING_CONFIG]: No such file or directory (os error 2)
> No such file or directory (os error 2)
× reading config file [MISSING_CONFIG]: No such file or directory (os error 2)
No such file or directory (os error 2)
");
}
@ -793,8 +810,8 @@ fn start_with_missing_env_config_errors_without_entering_install_mode() {
exit_code: 1
----- stdout -----
----- stderr -----
error: reading config file [MISSING_CONFIG]: No such file or directory (os error 2)
> No such file or directory (os error 2)
× reading config file [MISSING_CONFIG]: No such file or directory (os error 2)
No such file or directory (os error 2)
");
}
@ -819,18 +836,19 @@ fn start_with_malformed_default_settings_errors_without_entering_install_mode()
exit_code: 1
----- stdout -----
----- stderr -----
error: Failed to parse settings file at [HOME_DIR]/.fabro/settings.toml: settings file is not valid TOML: TOML parse error at line 1, column 15
|
1 | [server.listen
| ^
invalid table header
expected `.`, `]`
> settings file is not valid TOML: TOML parse error at line 1, column 15
> |
> 1 | [server.listen
> | ^
> invalid table header
> expected `.`, `]`
× Failed to parse settings file at [HOME_DIR]/.fabro/settings.toml: settings file is not valid TOML: TOML parse error at line 1, column 15
|
1 | [server.listen
| ^
invalid table header
expected `.`, `]`
settings file is not valid TOML: TOML parse error at line 1, column 15
|
1 | [server.listen
| ^
invalid table header
expected `.`, `]`
");
}

View file

@ -172,7 +172,7 @@ fn start_rejects_already_active_or_completed_run() {
exit_code: 1
----- stdout -----
----- stderr -----
error: an engine process is still running for this run cannot start
× an engine process is still running for this run cannot start
");
gate.release();
@ -185,7 +185,7 @@ fn start_rejects_already_active_or_completed_run() {
exit_code: 1
----- stdout -----
----- stderr -----
error: cannot start run: status is succeeded(completed), expected submitted
× cannot start run: status is succeeded(completed), expected submitted
");
}

View file

@ -97,8 +97,8 @@ fn upgrade_invalid_version_errors() {
exit_code: 1
----- stdout -----
----- stderr -----
error: invalid version: not-a-semver
> unexpected character 'n' while parsing major version number
× invalid version: not-a-semver
unexpected character 'n' while parsing major version number
");
}
@ -209,7 +209,7 @@ fn upgrade_brew_install_refuses_and_prints_brew_command() {
----- stderr -----
fabro was installed via Homebrew.
Run `brew upgrade fabro` to update.
error: refusing to overwrite a Homebrew-managed binary
× refusing to overwrite a Homebrew-managed binary
");
}
@ -245,6 +245,6 @@ fn upgrade_brew_install_rejects_version_flag() {
exit_code: 1
----- stdout -----
----- stderr -----
error: fabro is managed by Homebrew (formula `fabro`); Homebrew selects the version and channel. Use `brew upgrade fabro` (or reinstall with a different formula) instead of `fabro upgrade --version`/`--prerelease`/`--force`.
× fabro is managed by Homebrew (formula `fabro`); Homebrew selects the version and channel. Use `brew upgrade fabro` (or reinstall with a different formula) instead of `fabro upgrade --version`/`--prerelease`/`--force`.
");
}

View file

@ -146,6 +146,6 @@ fn invalid() {
Graph: [FIXTURES]/invalid.fabro
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
error: Validation failed
× Validation failed
");
}

View file

@ -132,7 +132,7 @@ fn wait_submitted_run_times_out() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Timed out after 1s waiting for run '[ULID]'
× Timed out after 1s waiting for run '[ULID]'
");
}
@ -189,7 +189,7 @@ fn wait_blocked_run_times_out_without_treating_it_as_terminal() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Timed out after 1s waiting for run '[ULID]'
× Timed out after 1s waiting for run '[ULID]'
");
resolve_run.assert();
assert!(

View file

@ -140,7 +140,7 @@ fn workflow_create_rejects_existing_workflow() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Workflow 'existing' already exists at [TEMP_DIR]/project/.fabro/workflows/existing
× Workflow 'existing' already exists at [TEMP_DIR]/project/.fabro/workflows/existing
");
}
@ -155,7 +155,7 @@ fn workflow_create_errors_without_project_config() {
exit_code: 1
----- stdout -----
----- stderr -----
error: No .fabro/project.toml found in [TEMP_DIR] or any parent directory
× No .fabro/project.toml found in [TEMP_DIR] or any parent directory
");
}

View file

@ -37,7 +37,7 @@ fn workflow_list_errors_without_project_config() {
exit_code: 1
----- stdout -----
----- stderr -----
error: No .fabro/project.toml found in [TEMP_DIR] or any parent directory
× No .fabro/project.toml found in [TEMP_DIR] or any parent directory
");
}

View file

@ -153,7 +153,7 @@ fn artifact_commands_share_populated_run_fixture() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Path 'assets/retry/report.txt' matches multiple artifacts: create_colliding:retry_1, retry_assets:retry_1, retry_assets:retry_2. Use --node and/or --retry to disambiguate.
× Path 'assets/retry/report.txt' matches multiple artifacts: create_colliding:retry_1, retry_assets:retry_1, retry_assets:retry_2. Use --node and/or --retry to disambiguate.
");
let flat_dest = context.temp_dir.join("artifact-flat");
@ -169,6 +169,6 @@ fn artifact_commands_share_populated_run_fixture() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Filename collision: 'summary.txt' exists in both create_assets:retry_1 and create_colliding:retry_1. Use --tree to preserve directory structure, or --node and/or --retry to filter.
× Filename collision: 'summary.txt' exists in both create_assets:retry_1 and create_colliding:retry_1. Use --tree to preserve directory structure, or --node and/or --retry to filter.
");
}

View file

@ -26,8 +26,12 @@ pub(crate) fn fatal_error_line(stderr: &[u8]) -> String {
console::strip_ansi_codes(&stderr)
.lines()
.rev()
.find_map(|line| line.strip_prefix("error: ").map(ToOwned::to_owned))
.expect("stderr should contain a fatal `error:` line")
.find_map(|line| {
line.strip_prefix("error: ")
.or_else(|| line.trim_start().strip_prefix("× "))
.map(ToOwned::to_owned)
})
.expect("stderr should contain a fatal error line")
}
pub(crate) fn unique_run_id() -> String {