diff --git a/cli-engine/docs/concepts.md b/cli-engine/docs/concepts.md index 276a242..b2dcad6 100644 --- a/cli-engine/docs/concepts.md +++ b/cli-engine/docs/concepts.md @@ -517,6 +517,15 @@ Ok(CommandResult::new(json!({ "id": "app-1", "name": "my-app" })) `description` fields. This is the primary mechanism for agent-first CLIs to tell callers what command to run next. +### warnings + +A handler that succeeds but wants the caller to know something (a partial result, a deprecated flag, a skipped item) chains `CommandResult::with_warning` once per message. The messages land in the envelope's `warnings` array in JSON and TOON output (absent when there are none) and render as a "Warnings:" block ahead of the "Next steps:" footer in human output. + +```rust +Ok(CommandResult::new(json!({ "id": "app-1" })) + .with_warning("2 of 5 regions were unreachable; results may be incomplete")) +``` + ### pagination A command that opted into `--limit`/`--offset` pagination via `CommandSpec::with_pagination` gets diff --git a/cli-engine/src/command/mod.rs b/cli-engine/src/command/mod.rs index a652b92..1993a65 100644 --- a/cli-engine/src/command/mod.rs +++ b/cli-engine/src/command/mod.rs @@ -79,6 +79,18 @@ impl CommandResult { self } + /// Appends a non-fatal warning to this result. + /// + /// Use for conditions the caller should know about even though the command + /// succeeded (a partial result, a deprecated flag, a skipped item). Chain + /// it once per warning. The envelope carries them under `warnings` in JSON + /// and TOON output, and human output renders a "Warnings:" block. + #[must_use] + pub fn with_warning(mut self, message: impl Into) -> Self { + self.metadata.warnings.push(message.into()); + self + } + /// Marks this result as a dry-run preview outcome. /// /// Call only when the handler actually skipped its mutating step because @@ -121,6 +133,9 @@ impl From for CommandResult { pub struct CommandResultMetadata { /// Suggested follow-up actions for the caller. pub next_actions: Vec, + /// Set by [`CommandResult::with_warning`]; rendered in the envelope's + /// `warnings` field. + pub warnings: Vec, /// Set by [`CommandResult::with_dry_run`] when a /// [`handles_dry_run`](CommandSpec::handles_dry_run) handler skipped its /// mutating step. Middleware tags the audit/activity outcome and envelope diff --git a/cli-engine/src/middleware/run.rs b/cli-engine/src/middleware/run.rs index 9fbfad1..46fab40 100644 --- a/cli-engine/src/middleware/run.rs +++ b/cli-engine/src/middleware/run.rs @@ -271,6 +271,7 @@ impl Middleware { let mut envelope = Envelope::success(data, command_system).with_next_actions(metadata.next_actions); + envelope.warnings = metadata.warnings; if is_dry_run { envelope = envelope.with_dry_run(); } diff --git a/cli-engine/src/output/human/footer.rs b/cli-engine/src/output/human/footer.rs index 3938393..e7bfbff 100644 --- a/cli-engine/src/output/human/footer.rs +++ b/cli-engine/src/output/human/footer.rs @@ -216,6 +216,20 @@ pub(super) fn append_cursor_summary( } } +/// Appends a "Warnings:" block listing non-fatal warnings to `out` (a no-op +/// when there are none). +pub(super) fn append_warnings(out: &mut String, warnings: &[String]) { + if warnings.is_empty() { + return; + } + out.push_str("\nWarnings:\n"); + for warning in warnings { + out.push_str(" "); + out.push_str(warning); + out.push('\n'); + } +} + /// Append a "Next steps:" footer listing suggested follow-up commands to `out` /// (a no-op when there are none). Each action shows its command template with /// any known param values substituted into their `` (params diff --git a/cli-engine/src/output/human/mod.rs b/cli-engine/src/output/human/mod.rs index a843b14..bab18ce 100644 --- a/cli-engine/src/output/human/mod.rs +++ b/cli-engine/src/output/human/mod.rs @@ -18,6 +18,7 @@ mod value_format; use body::render_data_body; use footer::{ append_cursor_summary, append_next_actions, append_pagination_summary, append_render_notes, + append_warnings, }; pub(crate) use columns::terminal_width; @@ -373,6 +374,7 @@ pub(crate) fn render_human_with_registry_selected( && let Some(custom) = registry.custom(schema_id) { let mut output = custom.render(data); + append_warnings(&mut output, &envelope.warnings); append_next_actions(&mut output, &envelope.next_actions); return output; } @@ -469,6 +471,7 @@ pub(crate) fn render_human_with_view( append_pagination_summary(&mut body, envelope.pagination.as_ref(), shown); append_cursor_summary(&mut body, envelope.cursor.as_ref(), shown); } + append_warnings(&mut body, &envelope.warnings); append_next_actions(&mut body, &envelope.next_actions); body } diff --git a/cli-engine/src/output/human/tests/footer.rs b/cli-engine/src/output/human/tests/footer.rs index 7a83c12..1c4967c 100644 --- a/cli-engine/src/output/human/tests/footer.rs +++ b/cli-engine/src/output/human/tests/footer.rs @@ -90,3 +90,20 @@ fn error_output_appends_fix_line() { let out = render_human(&envelope); assert_eq!(out, "Error: not logged in\nFix: Run auth login\n"); } + +#[test] +fn human_output_lists_warnings_before_next_steps() { + let mut envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") + .with_next_actions(vec![NextAction::new("domain info", "Show details")]); + envelope.add_warning("quote expires soon"); + let out = render_human(&envelope); + let warnings = out.find("\nWarnings:\n quote expires soon\n").expect(&out); + let next_steps = out.find("\nNext steps:\n").expect(&out); + assert!(warnings < next_steps, "{out}"); +} + +#[test] +fn human_output_has_no_warnings_block_without_warnings() { + let out = render_human(&Envelope::success(json!({ "id": 1 }), "x")); + assert!(!out.contains("Warnings:"), "{out}"); +} diff --git a/cli-engine/tests/warnings.rs b/cli-engine/tests/warnings.rs new file mode 100644 index 0000000..e206f01 --- /dev/null +++ b/cli-engine/tests/warnings.rs @@ -0,0 +1,52 @@ +//! End-to-end coverage for `CommandResult::with_warning`, driven through +//! `Cli::run` the way a real consumer binary would. + +use cli_engine::{Cli, CliConfig, CommandResult, CommandSpec, RuntimeCommandSpec}; +use serde_json::{Value, json}; + +fn cli() -> Cli { + let mut cli = Cli::new(CliConfig::new("my-cli", "Dev tooling", "my-cli")); + cli.add_command(RuntimeCommandSpec::new( + CommandSpec::new("warn", "Succeeds with warnings").no_auth(true), + async |_credential, _args| { + Ok(CommandResult::new(json!({"id": 1})) + .with_warning("first") + .with_warning("second")) + }, + )); + cli.add_command(RuntimeCommandSpec::new( + CommandSpec::new("clean", "Succeeds cleanly").no_auth(true), + async |_credential, _args| Ok(CommandResult::new(json!({"id": 1}))), + )); + cli +} + +#[tokio::test] +async fn warnings_appear_in_json_envelope_in_order() { + let output = cli().run(["my-cli", "warn", "--output", "json"]).await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let envelope: Value = serde_json::from_str(&output.rendered).expect("json envelope"); + assert_eq!(envelope["warnings"], json!(["first", "second"])); + assert_eq!(envelope["data"], json!({"id": 1})); +} + +#[tokio::test] +async fn warnings_appear_in_toon_and_human_output() { + let toon = cli().run(["my-cli", "warn", "--output", "toon"]).await; + assert!(toon.rendered.contains("warnings"), "{}", toon.rendered); + assert!(toon.rendered.contains("second"), "{}", toon.rendered); + + let human = cli().run(["my-cli", "warn", "--output", "human"]).await; + assert!( + human.rendered.contains("Warnings:\n first\n second\n"), + "{}", + human.rendered + ); +} + +#[tokio::test] +async fn warnings_field_is_absent_without_warnings() { + let output = cli().run(["my-cli", "clean", "--output", "json"]).await; + let envelope: Value = serde_json::from_str(&output.rendered).expect("json envelope"); + assert!(envelope.get("warnings").is_none(), "{}", output.rendered); +}