Skip to main content

agent_first_data/cli_spec/
help.rs

1use super::*;
2use serde::Serialize;
3use std::collections::BTreeMap;
4
5/// Agent-first help-v2 payload.
6///
7/// `defaults` carries [`CliValue`], which admits finite `f64`, so this model is
8/// `PartialEq` but not `Eq`.
9#[derive(Clone, Debug, PartialEq, Serialize)]
10pub struct CliHelpV2 {
11    pub schema: String,
12    pub command_path: String,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub about: Option<String>,
15    /// Every legal shape of this command, each complete.
16    ///
17    /// Help is answered in one round trip rather than two. A second level would
18    /// only pay off by omitting something, and what it omitted — the optional
19    /// arguments — would be undiscoverable to a caller that stopped at the
20    /// first level.
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub shapes: Vec<CliShape>,
23    #[serde(default, skip_serializing_if = "Vec::is_empty")]
24    pub subcommands: Vec<String>,
25    /// Argument meanings. Arguments belong to the command, not to one shape,
26    /// so these are stated once rather than per shape.
27    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
28    pub notes: BTreeMap<String, String>,
29    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
30    pub defaults: BTreeMap<String, CliValue>,
31}
32
33/// One legal shape of a call.
34#[derive(Clone, Debug, PartialEq, Serialize)]
35pub struct CliShape {
36    pub id: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub about: Option<String>,
39    pub usage: String,
40}
41
42/// Resolved help response plus its lifecycle output plan.
43#[derive(Clone, Debug, PartialEq)]
44pub struct ResolvedHelp {
45    pub(super) model: CliHelpV2,
46    pub(super) output: OutputPlan,
47}
48
49impl ResolvedHelp {
50    pub fn model(&self) -> &CliHelpV2 {
51        &self.model
52    }
53
54    pub fn output_plan(&self) -> &OutputPlan {
55        &self.output
56    }
57
58    pub fn plain(&self) -> String {
59        let mut output = String::new();
60        if let Some(about) = &self.model.about {
61            output.push_str(about);
62            output.push_str("\n\n");
63        }
64        for shape in &self.model.shapes {
65            if self.model.shapes.len() > 1 {
66                let about = shape.about.as_deref().unwrap_or_default();
67                output.push_str(&format!("{}  {about}\n", shape.id));
68            }
69            output.push_str(&shape.usage);
70            output.push('\n');
71        }
72        // The same facts the structured form carries. Without these the plain
73        // rendering was strictly weaker than the JSON it claims to be a human
74        // catalog of — no argument meanings, no defaults — so a reader who
75        // asked for the readable form got told less.
76        if !self.model.notes.is_empty() {
77            output.push('\n');
78            let width = self
79                .model
80                .notes
81                .keys()
82                .map(String::len)
83                .max()
84                .unwrap_or_default();
85            for (name, note) in &self.model.notes {
86                output.push_str(&format!("  {name:<width$}  {note}\n"));
87            }
88        }
89        if !self.model.defaults.is_empty() {
90            output.push_str("\ndefaults:");
91            for (name, value) in &self.model.defaults {
92                output.push_str(&format!(" {name}={}", plain_value(value)));
93            }
94            output.push('\n');
95        }
96        for (index, subcommand) in self.model.subcommands.iter().enumerate() {
97            output.push_str(if index == 0 {
98                "\nmore:    "
99            } else {
100                "         "
101            });
102            output.push_str(subcommand);
103            output.push('\n');
104        }
105        output
106    }
107}
108
109/// Resolved version response plus its lifecycle output plan.
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct ResolvedVersion {
112    pub(super) name: String,
113    pub(super) version: String,
114    pub(super) display_name: Option<String>,
115    pub(super) build: Option<String>,
116    pub(super) output: OutputPlan,
117}
118
119impl ResolvedVersion {
120    pub fn output_plan(&self) -> &OutputPlan {
121        &self.output
122    }
123
124    pub fn name(&self) -> &str {
125        &self.name
126    }
127
128    pub fn version(&self) -> &str {
129        &self.version
130    }
131
132    pub fn display_name(&self) -> Option<&str> {
133        self.display_name.as_deref()
134    }
135
136    pub fn build(&self) -> Option<&str> {
137        self.build.as_deref()
138    }
139}
140
141/// The registry asked to describe itself in full.
142///
143/// Rendering is not the core's business — it carries no Markdown — so this only
144/// carries the legal sink plan and registry so the rendering adapter can
145/// complete the response without a second registry argument.
146#[derive(Clone, Debug, PartialEq)]
147pub struct ResolvedDocs {
148    pub(super) output: OutputPlan,
149    pub(super) registry: BuiltCliSpec,
150}
151
152impl ResolvedDocs {
153    pub fn registry(&self) -> &BuiltCliSpec {
154        &self.registry
155    }
156    pub fn output_plan(&self) -> &OutputPlan {
157        &self.output
158    }
159}
160
161pub(super) fn combination_usage(
162    command: &CommandSpec,
163    combination: &Combination,
164    command_path: &str,
165    detail: bool,
166) -> (String, BTreeMap<String, String>, BTreeMap<String, CliValue>) {
167    let mut parts = vec![command_path.to_string()];
168    let mut notes = BTreeMap::new();
169    let mut defaults = BTreeMap::new();
170    for argument in &command.arguments {
171        let id = argument.argument_id.as_str();
172        let fixed = combination.fixed.get(id);
173        let required = combination.required.iter().any(|candidate| candidate == id);
174        let optional = combination.optional.iter().any(|candidate| candidate == id);
175        if fixed.is_none() && !required && !optional {
176            continue;
177        }
178        if optional && !detail {
179            continue;
180        }
181        // A fixed argument the caller may leave out: `matches_combination`
182        // accepts the argument's own default in place of an explicit value, so
183        // rendering it as required would describe a stricter call than the one
184        // the parser demands.
185        let default_satisfies_fixed = fixed.is_some_and(|fixed| {
186            argument
187                .default
188                .as_ref()
189                .and_then(CliValue::as_str)
190                .is_some_and(|value| fixed.values().iter().any(|fixed| fixed == value))
191        });
192        let omittable = optional || default_satisfies_fixed;
193        let token = render_argument(argument, fixed, omittable);
194        parts.push(token);
195        if detail {
196            let key = argument_key(argument);
197            if let Some(about) = argument.rendered_about() {
198                notes.insert(key.clone(), about);
199            }
200            // A default is only worth stating where leaving the argument out is
201            // legal; on a required argument it describes nothing the caller can
202            // reach.
203            if omittable
204                && !argument.sensitive
205                && let Some(default) = &argument.default
206            {
207                defaults.insert(key, default.clone());
208            }
209        }
210    }
211    if detail {
212        match &combination.output {
213            OutputSpec::Raw { file_sinks } => {
214                render_file_sinks(&mut parts, file_sinks);
215            }
216            OutputSpec::Protocol {
217                formats,
218                destinations,
219                default_format,
220                default_destination,
221                file_sinks,
222                ..
223            } => {
224                parts.push(format!("[--output <{}>]", formats.join("|")));
225                parts.push(format!("[--output-to <{}>]", destinations.join("|")));
226                render_file_sinks(&mut parts, file_sinks);
227                defaults.insert(
228                    "--output".to_string(),
229                    CliValue::String(default_format.clone()),
230                );
231                defaults.insert(
232                    "--output-to".to_string(),
233                    CliValue::String(default_destination.clone()),
234                );
235            }
236        }
237    }
238    (parts.join(" "), notes, defaults)
239}
240
241/// A default rendered for the plain catalog, without JSON's quoting.
242fn plain_value(value: &CliValue) -> String {
243    match value {
244        CliValue::Bool(value) => value.to_string(),
245        CliValue::String(value) | CliValue::Json(value) => value.clone(),
246        CliValue::I64(value) => value.to_string(),
247        CliValue::FiniteF64(value) => value.to_string(),
248        CliValue::List(values) => values.iter().map(plain_value).collect::<Vec<_>>().join(","),
249    }
250}
251
252/// The spelling the caller reads in `usage`, and therefore the key `notes` and
253/// `defaults` use — an agent looks up a token it just read without having to
254/// transform it first.
255///
256/// It also keeps AFDATA's own convention from misfiring on this crate's help:
257/// an argument id like `reveal_secret` names a description, not a secret, and
258/// the `_secret` suffix would redact this command's own help text away.
259pub(crate) fn argument_key(argument: &ArgSpec) -> String {
260    match &argument.syntax {
261        ArgSyntax::Long { name } => name.clone(),
262        ArgSyntax::Positional { .. } => argument
263            .value_name
264            .clone()
265            .unwrap_or_else(|| argument.argument_id.clone()),
266    }
267}
268
269fn render_argument(argument: &ArgSpec, fixed: Option<&FixedValue>, optional: bool) -> String {
270    let value = if let Some(fixed) = fixed {
271        match fixed {
272            FixedValue::Value(value) => value.clone(),
273            FixedValue::OneOf { one_of } => format!("<{}>", one_of.join("|")),
274        }
275    } else if argument.value_type == ArgValueType::Flag {
276        String::new()
277    } else if !argument.enum_values.is_empty() {
278        // A closed value set is the argument's contract. Printing only the
279        // placeholder would leave those values registered but undiscoverable —
280        // the caller would have to provoke an error to be told what they are.
281        format!("<{}>", argument.enum_values.join("|"))
282    } else {
283        format!(
284            "<{}>",
285            argument
286                .value_name
287                .as_deref()
288                .unwrap_or(argument.argument_id.as_str())
289        )
290    };
291    let mut token = match &argument.syntax {
292        ArgSyntax::Long { name } => {
293            if value.is_empty() {
294                name.clone()
295            } else {
296                format!("{name} {value}")
297            }
298        }
299        ArgSyntax::Positional { .. } => value,
300    };
301    if argument.repeatable {
302        token.push_str("...");
303    }
304    if optional {
305        token = format!("[{token}]");
306    }
307    token
308}
309
310fn render_file_sinks(parts: &mut Vec<String>, file_sinks: &[String]) {
311    if file_sinks.iter().any(|sink| sink == "stdout") {
312        parts.push("[--stdout-file <PATH>]".to_string());
313    }
314    if file_sinks.iter().any(|sink| sink == "stderr") {
315        parts.push("[--stderr-file <PATH>]".to_string());
316    }
317}