agent_first_data/cli_spec/
help.rs1use super::*;
2use serde::Serialize;
3use std::collections::BTreeMap;
4
5#[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 #[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 #[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#[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#[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 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#[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#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct ResolvedDocs {
148 pub(super) output: OutputPlan,
149}
150
151impl ResolvedDocs {
152 pub fn output_plan(&self) -> &OutputPlan {
153 &self.output
154 }
155}
156
157pub(super) fn combination_usage(
158 command: &CommandSpec,
159 combination: &Combination,
160 command_path: &str,
161 detail: bool,
162) -> (String, BTreeMap<String, String>, BTreeMap<String, CliValue>) {
163 let mut parts = vec![command_path.to_string()];
164 let mut notes = BTreeMap::new();
165 let mut defaults = BTreeMap::new();
166 for argument in &command.arguments {
167 let id = argument.argument_id.as_str();
168 let fixed = combination.fixed.get(id);
169 let required = combination.required.iter().any(|candidate| candidate == id);
170 let optional = combination.optional.iter().any(|candidate| candidate == id);
171 if fixed.is_none() && !required && !optional {
172 continue;
173 }
174 if optional && !detail {
175 continue;
176 }
177 let default_satisfies_fixed = fixed.is_some_and(|fixed| {
182 argument
183 .default
184 .as_ref()
185 .and_then(CliValue::as_str)
186 .is_some_and(|value| fixed.values().iter().any(|fixed| fixed == value))
187 });
188 let omittable = optional || default_satisfies_fixed;
189 let token = render_argument(argument, fixed, omittable);
190 parts.push(token);
191 if detail {
192 let key = argument_key(argument);
193 if let Some(about) = &argument.about {
194 notes.insert(key.clone(), about.clone());
195 }
196 if omittable
200 && !argument.sensitive
201 && let Some(default) = &argument.default
202 {
203 defaults.insert(key, default.clone());
204 }
205 }
206 }
207 if detail {
208 match &combination.output {
209 OutputSpec::Raw { file_sinks } => {
210 render_file_sinks(&mut parts, file_sinks);
211 }
212 OutputSpec::Protocol {
213 formats,
214 destinations,
215 default_format,
216 default_destination,
217 file_sinks,
218 ..
219 } => {
220 parts.push(format!("[--output <{}>]", formats.join("|")));
221 parts.push(format!("[--output-to <{}>]", destinations.join("|")));
222 render_file_sinks(&mut parts, file_sinks);
223 defaults.insert(
224 "--output".to_string(),
225 CliValue::String(default_format.clone()),
226 );
227 defaults.insert(
228 "--output-to".to_string(),
229 CliValue::String(default_destination.clone()),
230 );
231 }
232 }
233 }
234 (parts.join(" "), notes, defaults)
235}
236
237fn plain_value(value: &CliValue) -> String {
239 match value {
240 CliValue::Bool(value) => value.to_string(),
241 CliValue::String(value) | CliValue::Json(value) => value.clone(),
242 CliValue::I64(value) => value.to_string(),
243 CliValue::FiniteF64(value) => value.to_string(),
244 CliValue::List(values) => values.iter().map(plain_value).collect::<Vec<_>>().join(","),
245 }
246}
247
248pub(crate) fn argument_key(argument: &ArgSpec) -> String {
256 match &argument.syntax {
257 ArgSyntax::Long { name } => name.clone(),
258 ArgSyntax::Positional { .. } => argument
259 .value_name
260 .clone()
261 .unwrap_or_else(|| argument.argument_id.clone()),
262 }
263}
264
265fn render_argument(argument: &ArgSpec, fixed: Option<&FixedValue>, optional: bool) -> String {
266 let value = if let Some(fixed) = fixed {
267 match fixed {
268 FixedValue::Value(value) => value.clone(),
269 FixedValue::OneOf { one_of } => format!("<{}>", one_of.join("|")),
270 }
271 } else if argument.value_type == ArgValueType::Flag {
272 String::new()
273 } else if !argument.enum_values.is_empty() {
274 format!("<{}>", argument.enum_values.join("|"))
278 } else {
279 format!(
280 "<{}>",
281 argument
282 .value_name
283 .as_deref()
284 .unwrap_or(argument.argument_id.as_str())
285 )
286 };
287 let mut token = match &argument.syntax {
288 ArgSyntax::Long { name } => {
289 if value.is_empty() {
290 name.clone()
291 } else {
292 format!("{name} {value}")
293 }
294 }
295 ArgSyntax::Positional { .. } => value,
296 };
297 if argument.repeatable {
298 token.push_str("...");
299 }
300 if optional {
301 token = format!("[{token}]");
302 }
303 token
304}
305
306fn render_file_sinks(parts: &mut Vec<String>, file_sinks: &[String]) {
307 if file_sinks.iter().any(|sink| sink == "stdout") {
308 parts.push("[--stdout-file <PATH>]".to_string());
309 }
310 if file_sinks.iter().any(|sink| sink == "stderr") {
311 parts.push("[--stderr-file <PATH>]".to_string());
312 }
313}