Skip to content
Merged
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
11 changes: 7 additions & 4 deletions src/output/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>"`.
/// A param's placeholder is its key wrapped in angle brackets (`<key>`);
/// 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,
Expand Down Expand Up @@ -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 <id>", "Get project details"),
]);

let serialized = serde_json::to_string(&envelope).expect("envelope serializes to JSON");
Expand All @@ -553,7 +556,7 @@ mod tests {

assert_eq!(
parsed["next_actions"][0]["command"],
"project get --id {{id}}"
"project get --id <id>"
);
assert_eq!(
parsed["next_actions"][0]["description"],
Expand All @@ -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 <app>", "Deploy the app").with_param(
"app",
NextActionParam {
description: Some("Application name".to_owned()),
Expand Down
67 changes: 61 additions & 6 deletions src/output/human.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -338,23 +339,48 @@ 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 `<domain>` 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 `<placeholder>` (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;
}
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 ");
Comment thread
jpage-godaddy marked this conversation as resolved.
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 <domain>"` with
/// `params["domain"].value == Some("example.com")` becomes
/// `"domain quote example.com"`. A param's placeholder is its key wrapped in
/// angle brackets (`<domain>`); 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<String, NextActionParam>,
) -> Cow<'cmd, str> {
let mut command = Cow::Borrowed(command);
for (key, param) in params {
if let Some(value) = &param.value {
let placeholder = format!("<{key}>");
if command.contains(&placeholder) {
command = Cow::Owned(command.replace(&placeholder, value));
}
}
Comment thread
jpage-godaddy marked this conversation as resolved.
}
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
Expand Down Expand Up @@ -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 <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("<quote-token>"), "{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 <domain>", "Price a registration")
.with_param("domain", NextActionParam::required()),
]);
let out = render_human(&envelope);
assert!(out.contains("domain quote <domain>"), "{out}");
}

#[test]
fn human_output_has_no_footer_without_next_actions() {
let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain");
Expand Down