Skip to content
Open
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
9 changes: 9 additions & 0 deletions cli-engine/docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions cli-engine/src/command/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> 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
Expand Down Expand Up @@ -121,6 +133,9 @@ impl From<Value> for CommandResult {
pub struct CommandResultMetadata {
/// Suggested follow-up actions for the caller.
pub next_actions: Vec<NextAction>,
/// Set by [`CommandResult::with_warning`]; rendered in the envelope's
/// `warnings` field.
pub warnings: Vec<String>,
/// 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
Expand Down
1 change: 1 addition & 0 deletions cli-engine/src/middleware/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
14 changes: 14 additions & 0 deletions cli-engine/src/output/human/footer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<placeholder>` (params
Expand Down
3 changes: 3 additions & 0 deletions cli-engine/src/output/human/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
}
Expand Down
17 changes: 17 additions & 0 deletions cli-engine/src/output/human/tests/footer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
52 changes: 52 additions & 0 deletions cli-engine/tests/warnings.rs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading