diff --git a/src/output/envelope.rs b/src/output/envelope.rs index 4d63892..29a2e2d 100644 --- a/src/output/envelope.rs +++ b/src/output/envelope.rs @@ -31,7 +31,10 @@ pub struct Envelope { /// A suggested follow-up command the caller can run next. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct NextAction { - /// Executable command template, e.g. `"application info --name {{name}}"`. + /// Executable command template, e.g. `"application info --name "`. + /// A param's placeholder is its key wrapped in angle brackets (``); + /// human output substitutes it when the param carries a known + /// [`NextActionParam::value`]. pub command: String, /// Human-readable description of what this action does. pub description: String, @@ -544,7 +547,7 @@ mod tests { fn next_actions_appear_in_serialized_envelope() { let envelope = Envelope::success(json!({"id": "p1"}), "projects-api").with_next_actions(vec![ - NextAction::new("project get --id {{id}}", "Get project details"), + NextAction::new("project get --id ", "Get project details"), ]); let serialized = serde_json::to_string(&envelope).expect("envelope serializes to JSON"); @@ -553,7 +556,7 @@ mod tests { assert_eq!( parsed["next_actions"][0]["command"], - "project get --id {{id}}" + "project get --id " ); assert_eq!( parsed["next_actions"][0]["description"], @@ -577,7 +580,7 @@ mod tests { #[test] fn next_action_params_serialize_when_present() { - let action = NextAction::new("deploy run --app {{app}}", "Deploy the app").with_param( + let action = NextAction::new("deploy run --app ", "Deploy the app").with_param( "app", NextActionParam { description: Some("Application name".to_owned()), diff --git a/src/output/human.rs b/src/output/human.rs index 8c4318c..2ef7ddd 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -1,12 +1,13 @@ use std::{ - collections::{BTreeMap, BTreeSet}, + borrow::Cow, + collections::{BTreeMap, BTreeSet, HashMap}, fmt, sync::{Arc, OnceLock, RwLock}, }; use serde_json::Value; -use super::{Envelope, NextAction}; +use super::{Envelope, NextAction, NextActionParam}; /// Column definition for registered human table views. #[derive(Clone, Debug, Eq, PartialEq)] @@ -338,9 +339,11 @@ fn render_data_body(data: &Value, columns: Option<&[TableColumn]>) -> String { } /// 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 -/// (placeholders like `` shown as-is) with the description beneath it. -/// Writes directly into `out` to avoid per-action temporaries. +/// (a no-op when there are none). Each action shows its command template with +/// any known param values substituted into their `` (params +/// without a known value, e.g. required-only hints, are shown as-is), followed +/// by the description beneath it. Writes directly into `out` to avoid +/// per-action temporaries. fn append_next_actions(out: &mut String, actions: &[NextAction]) { if actions.is_empty() { return; @@ -348,13 +351,36 @@ fn append_next_actions(out: &mut String, actions: &[NextAction]) { out.push_str("\nNext steps:\n"); for action in actions { out.push_str(" "); - out.push_str(&action.command); + out.push_str(&substitute_known_params(&action.command, &action.params)); out.push_str("\n "); out.push_str(&action.description); out.push('\n'); } } +/// Fills a `NextAction` command template with any params that carry a known +/// concrete `value` — e.g. `"domain quote "` with +/// `params["domain"].value == Some("example.com")` becomes +/// `"domain quote example.com"`. A param's placeholder is its key wrapped in +/// angle brackets (``); params without a known value (required-only +/// hints) are left as literal placeholder text for the user to fill in. +/// Borrows `command` as-is (no allocation) when nothing has a known value. +fn substitute_known_params<'cmd>( + command: &'cmd str, + params: &HashMap, +) -> Cow<'cmd, str> { + let mut command = Cow::Borrowed(command); + for (key, param) in params { + if let Some(value) = ¶m.value { + let placeholder = format!("<{key}>"); + if command.contains(&placeholder) { + command = Cow::Owned(command.replace(&placeholder, value)); + } + } + } + command +} + /// Upper bound on a `no_truncate` column's width, even though it otherwise /// skips the normal 40-char cap. Prevents a pathologically long field value /// (not expected in practice, but not guaranteed by any schema) from padding @@ -601,6 +627,35 @@ mod tests { assert!(out.contains("Register at the quoted price"), "{out}"); } + #[test] + fn human_output_substitutes_known_next_action_params() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") + .with_next_actions(vec![ + NextAction::new( + "domain purchase --quote-token --agree --confirm", + "Register at the quoted price", + ) + .with_param("quote-token", NextActionParam::value("abc-123")), + ]); + let out = render_human(&envelope); + assert!( + out.contains("domain purchase --quote-token abc-123 --agree --confirm"), + "{out}" + ); + assert!(!out.contains(""), "{out}"); + } + + #[test] + fn human_output_leaves_placeholder_without_a_known_value() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") + .with_next_actions(vec![ + NextAction::new("domain quote ", "Price a registration") + .with_param("domain", NextActionParam::required()), + ]); + let out = render_human(&envelope); + assert!(out.contains("domain quote "), "{out}"); + } + #[test] fn human_output_has_no_footer_without_next_actions() { let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain");