From 882da9efee9269eb582ada4751011a677f1ffcbb Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Tue, 7 Jul 2026 17:46:18 -0700 Subject: [PATCH 1/3] fix: substitute known NextAction params into human-rendered next steps Human output's "Next steps" footer echoed a NextAction's command template verbatim, so any concrete value set via NextActionParam::value (e.g. a freshly issued quote token) never replaced its , leaving users to copy-paste a literal that doesn't exist. --- src/output/human.rs | 51 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/output/human.rs b/src/output/human.rs index 8c4318c..050f0e6 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -1,12 +1,12 @@ use std::{ - collections::{BTreeMap, BTreeSet}, + 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)] @@ -348,13 +348,29 @@ 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. +fn substitute_known_params(command: &str, params: &HashMap) -> String { + let mut command = command.to_owned(); + for (key, param) in params { + if let Some(value) = ¶m.value { + command = command.replace(&format!("<{key}>"), 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 +617,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"); From d0f54efee68054a31a54f82984ce91339315dd31 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Tue, 7 Jul 2026 17:53:22 -0700 Subject: [PATCH 2/3] refactor: address Copilot review on NextAction param substitution - Update the append_next_actions doc comment, which still described the pre-substitution behavior of showing placeholders verbatim. - Return Cow from substitute_known_params instead of always allocating a new String, skipping the allocation when no param has a known value. --- src/output/human.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/output/human.rs b/src/output/human.rs index 050f0e6..2ef7ddd 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Cow, collections::{BTreeMap, BTreeSet, HashMap}, fmt, sync::{Arc, OnceLock, RwLock}, @@ -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; @@ -361,11 +364,18 @@ fn append_next_actions(out: &mut String, actions: &[NextAction]) { /// `"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. -fn substitute_known_params(command: &str, params: &HashMap) -> String { - let mut command = command.to_owned(); +/// 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 { - command = command.replace(&format!("<{key}>"), value); + let placeholder = format!("<{key}>"); + if command.contains(&placeholder) { + command = Cow::Owned(command.replace(&placeholder, value)); + } } } command From 364b7231657d0b31d2f1f5c4d6eb200e3bf6473e Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Tue, 7 Jul 2026 17:58:38 -0700 Subject: [PATCH 3/3] docs: align NextAction placeholder examples with the convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The struct doc comment and two envelope tests still used a {{key}} mustache- style example for NextAction::command, but the only real placeholder convention in use — the human-output substitution added in this branch, and every NextAction call site in the downstream CLI — is (angle brackets). Update the doc comment and test fixtures to match so they don't mislead a future caller into using a syntax substitution never recognizes. --- src/output/envelope.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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()),