1use anyhow::Result;
2use apm_core::config::{Config, TicketConfig, WorkflowConfig};
3use apm_core::help_schema::{schema_entries, FieldEntry};
4
5static TOPICS: &[(&str, &str)] = &[
6 ("commands", "All apm subcommands and their usage"),
7 ("config", "Fields available in .apm/config.toml"),
8 ("workflow", "Fields available in .apm/workflow.toml"),
9 ("ticket", "Fields available in .apm/ticket.toml"),
10];
11
12pub fn run(topic: Option<&str>, cli_cmd: clap::Command) -> Result<()> {
18 match topic {
19 None => {
20 print!("{}", render_overview());
21 Ok(())
22 }
23 Some(t) => {
24 let content = match t {
25 "commands" => render_commands(&cli_cmd),
26 "config" => render_config(),
27 "workflow" => render_workflow(),
28 "ticket" => render_ticket(),
29 unknown => {
30 let valid: Vec<&str> = TOPICS.iter().map(|(name, _)| *name).collect();
31 anyhow::bail!(
32 "unknown help topic {:?}; valid topics are: {}",
33 unknown,
34 valid.join(", ")
35 );
36 }
37 };
38 print!("{}", content);
39 Ok(())
40 }
41 }
42}
43
44fn render_overview() -> String {
45 let mut out = String::new();
46 out.push_str("apm help — topic reference for Agent Project Manager\n\n");
47 out.push_str("Run `apm help <topic>` for details on a specific topic.\n");
48 out.push_str("Run `apm <subcommand> --help` for flags on a specific command.\n\n");
49 out.push_str("Topics:\n");
50 for (name, summary) in TOPICS {
51 out.push_str(&format!(" {:<10} {}\n", name, summary));
52 }
53 out
54}
55
56pub fn render_commands(root: &clap::Command) -> String {
57 let mut cmds: Vec<&clap::Command> = root
58 .get_subcommands()
59 .filter(|c| !c.is_hide_set())
60 .collect();
61 cmds.sort_by_key(|c| c.get_name());
62
63 let mut out = String::from("Commands\n========\n\n");
64 let blocks: Vec<String> = cmds.iter().map(|c| render_one(c, "", 100)).collect();
65 out.push_str(&blocks.join("\n\n"));
66 out.push('\n');
67 out
68}
69
70fn render_one(cmd: &clap::Command, prefix: &str, max_width: usize) -> String {
76 let name = cmd.get_name();
77 let mut out = String::new();
78
79 let positionals: Vec<String> = cmd
81 .get_arguments()
82 .filter(|a| {
83 a.is_positional()
84 && !a.is_hide_set()
85 && a.get_id().as_str() != "help"
86 && a.get_id().as_str() != "version"
87 })
88 .map(|a| {
89 let vname = a
90 .get_value_names()
91 .and_then(|names| names.first())
92 .map(|s| s.to_string())
93 .unwrap_or_else(|| a.get_id().to_string().to_uppercase());
94 if a.is_required_set() {
95 format!("<{}>", vname)
96 } else {
97 format!("[{}]", vname)
98 }
99 })
100 .collect();
101
102 let usage = if positionals.is_empty() {
103 format!("{}{}", prefix, name)
104 } else {
105 format!("{}{} {}", prefix, name, positionals.join(" "))
106 };
107 out.push_str(&usage);
108 out.push('\n');
109
110 if let Some(about) = cmd.get_about() {
112 let about_str = about.to_string();
113 if !about_str.is_empty() {
114 let wrapped = wrap_with_indent(" ", &about_str, max_width);
115 out.push_str(&wrapped);
116 out.push('\n');
117 }
118 }
119
120 for arg in cmd.get_arguments() {
122 if arg.is_hide_set() {
123 continue;
124 }
125 let id = arg.get_id().as_str();
126 if id == "help" || id == "version" {
127 continue;
128 }
129 if arg.is_positional() {
130 continue;
131 }
132 let long = match arg.get_long() {
133 Some(l) => l,
134 None => continue,
135 };
136
137 let short_part = arg
139 .get_short()
140 .map(|s| format!("-{}, ", s))
141 .unwrap_or_default();
142 let takes_value = !matches!(
145 arg.get_action(),
146 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse | clap::ArgAction::Count
147 );
148 let val_part = if takes_value {
149 arg.get_value_names()
150 .and_then(|names| names.first())
151 .map(|v| format!(" <{}>", v))
152 .unwrap_or_default()
153 } else {
154 String::new()
155 };
156 let flag_head = format!(" {}--{}{}", short_part, long, val_part);
157
158 let help_str = arg
160 .get_help()
161 .map(|h| h.to_string())
162 .unwrap_or_default();
163 let defaults: Vec<String> = arg
164 .get_default_values()
165 .iter()
166 .map(|d| d.to_string_lossy().into_owned())
167 .collect();
168 let full_help = if !defaults.is_empty() && !help_str.contains("(default:") {
171 let def = defaults.join(", ");
172 if help_str.is_empty() {
173 format!("(default: {})", def)
174 } else {
175 format!("{} (default: {})", help_str, def)
176 }
177 } else {
178 help_str
179 };
180
181 let line = if full_help.is_empty() {
182 flag_head
183 } else {
184 let first_prefix = format!("{} ", flag_head);
186 wrap_with_indent(&first_prefix, &full_help, max_width)
187 };
188 out.push_str(&line);
189 out.push('\n');
190 }
191
192 let subcmds: Vec<&clap::Command> = cmd
194 .get_subcommands()
195 .filter(|c| !c.is_hide_set())
196 .collect();
197 if !subcmds.is_empty() {
198 out.push('\n');
199 let sub_prefix = format!("{}{} ", prefix, name);
200 let sub_max = max_width.saturating_sub(2);
203 for sub in &subcmds {
204 let block = render_one(sub, &sub_prefix, sub_max);
205 for line in block.lines() {
206 out.push_str(" ");
207 out.push_str(line);
208 out.push('\n');
209 }
210 out.push('\n');
211 }
212 while out.ends_with("\n\n") {
214 out.pop();
215 }
216 }
217
218 out.trim_end().to_string()
219}
220
221fn wrap_with_indent(first_prefix: &str, text: &str, max_width: usize) -> String {
227 if text.trim().is_empty() {
228 return first_prefix.trim_end().to_string();
229 }
230
231 let cont_indent: String = " ".repeat(first_prefix.len());
232 let mut result: Vec<String> = Vec::new();
233 let mut current = first_prefix.to_string();
234
235 for word in text.split_whitespace() {
236 if current.trim().is_empty() {
239 current.push_str(word);
240 } else if current.len() + 1 + word.len() <= max_width {
241 current.push(' ');
242 current.push_str(word);
243 } else {
244 result.push(current);
245 current = format!("{}{}", cont_indent, word);
246 }
247 }
248 result.push(current);
249 result.join("\n")
250}
251
252fn render_config() -> String {
253 let all_entries = schema_entries::<Config>();
254
255 let mut sections: Vec<(String, Vec<FieldEntry>)> = Vec::new();
258 for e in all_entries {
259 let seg = e.toml_path
260 .split(|c: char| c == '.' || c == '[')
261 .next()
262 .unwrap_or(e.toml_path.as_str())
263 .to_string();
264 match sections.iter_mut().find(|(k, _)| *k == seg) {
265 Some(group) => group.1.push(e),
266 None => sections.push((seg, vec![e])),
267 }
268 }
269
270 let mut out = String::from("config.toml — project and tool configuration\n\n");
271
272 for (section, group) in §ions {
273 if section == "workflow" || section == "ticket" {
275 continue;
276 }
277
278 {
279 out.push_str(&format!("[{}]\n", section));
280
281 let path_w = group.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
282 let type_w = group.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
283 for e in group {
284 out.push_str(&fmt_field_entry(e, path_w, type_w));
285 out.push('\n');
286 }
287 }
288
289 out.push('\n');
290 }
291
292 out
293}
294
295fn fmt_field_entry(e: &FieldEntry, path_w: usize, type_w: usize) -> String {
296 let mut line = format!("{:<path_w$} {:<type_w$}", e.toml_path, e.type_name);
297 if let Some(ref d) = e.default {
298 line.push_str(&format!(" [default: {}]", d));
299 }
300 if let Some(ref desc) = e.description {
301 line.push_str(&format!(" # {}", desc));
302 }
303 if let Some(ref variants) = e.enum_variants {
304 line.push_str(&format!(" ({})", variants.join(" | ")));
305 }
306 line
307}
308
309fn render_workflow() -> String {
310 let mut out = String::new();
311 out.push_str("workflow.toml — state-machine and prioritization configuration\n");
312 out.push_str("workflow.states is an array of user-defined state objects; each element defines one node in the ticket state machine.\n");
313 out.push('\n');
314
315 let entries: Vec<FieldEntry> = schema_entries::<WorkflowConfig>()
317 .into_iter()
318 .map(|e| FieldEntry {
319 toml_path: format!("workflow.{}", e.toml_path),
320 ..e
321 })
322 .collect();
323
324 if entries.is_empty() {
325 return out;
326 }
327
328 let path_w = entries.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
329 let type_w = entries.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
330
331 for e in &entries {
332 let mut line = format!("{:<path_w$} {:<type_w$}", e.toml_path, e.type_name);
333 if let Some(ref d) = e.default {
334 line.push_str(&format!(" [default: {}]", d));
335 }
336 if let Some(ref desc) = e.description {
337 line.push_str(&format!(" # {}", desc));
338 }
339 if let Some(ref variants) = e.enum_variants {
340 line.push_str(&format!(" ({})", variants.join(" | ")));
341 }
342 out.push_str(&line);
343 out.push('\n');
344 }
345
346 out
347}
348
349fn render_ticket() -> String {
350 let mut out = String::new();
351 out.push_str("ticket.toml — ticket section configuration\n");
352 out.push_str("Defines the [[ticket.sections]] array: an ordered list of sections\n");
353 out.push_str("that appear on every ticket created in this project.\n");
354 out.push('\n');
355
356 let entries: Vec<FieldEntry> = schema_entries::<TicketConfig>()
358 .into_iter()
359 .map(|e| FieldEntry {
360 toml_path: format!("ticket.{}", e.toml_path),
361 ..e
362 })
363 .collect();
364
365 if entries.is_empty() {
366 return out;
367 }
368
369 let path_w = entries.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
370 let type_w = entries.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
371
372 for e in &entries {
373 let mut line = format!("{:<path_w$} {:<type_w$}", e.toml_path, e.type_name);
374 if let Some(ref d) = e.default {
375 line.push_str(&format!(" [default: {}]", d));
376 }
377 if let Some(ref desc) = e.description {
378 line.push_str(&format!(" # {}", desc));
379 }
380 if let Some(ref variants) = e.enum_variants {
381 line.push_str(&format!(" ({})", variants.join(" | ")));
382 }
383 out.push_str(&line);
384 out.push('\n');
385 }
386
387 out
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 fn make_test_cmd() -> clap::Command {
395 clap::Command::new("testapp")
396 .subcommand(
397 clap::Command::new("foo")
398 .about("Do foo things")
399 .arg(clap::Arg::new("id").value_name("ID").required(true))
400 .arg(
401 clap::Arg::new("verbose")
402 .long("verbose")
403 .short('v')
404 .action(clap::ArgAction::SetTrue)
405 .help("Enable verbose output"),
406 ),
407 )
408 .subcommand(
409 clap::Command::new("bar")
410 .about("Do bar things")
411 .arg(
412 clap::Arg::new("count")
413 .long("count")
414 .value_name("N")
415 .default_value("1")
416 .help("Number of repetitions"),
417 ),
418 )
419 .subcommand(
420 clap::Command::new("hidden")
421 .about("Should not appear")
422 .hide(true),
423 )
424 .subcommand(
425 clap::Command::new("parent")
426 .about("Has subcommands")
427 .subcommand(
428 clap::Command::new("child")
429 .about("Child command"),
430 ),
431 )
432 }
433
434 #[test]
435 fn render_commands_includes_visible_cmds() {
436 let root = make_test_cmd();
437 let out = render_commands(&root);
438 assert!(out.contains("foo"), "missing 'foo' in:\n{out}");
439 assert!(out.contains("bar"), "missing 'bar' in:\n{out}");
440 assert!(out.contains("parent"), "missing 'parent' in:\n{out}");
441 }
442
443 #[test]
444 fn render_commands_excludes_hidden() {
445 let root = make_test_cmd();
446 let out = render_commands(&root);
447 assert!(!out.contains("hidden"), "hidden cmd appeared in:\n{out}");
448 }
449
450 #[test]
451 fn render_commands_alphabetical_order() {
452 let root = make_test_cmd();
453 let out = render_commands(&root);
454 let bar_pos = out.find("bar").unwrap();
455 let foo_pos = out.find("foo").unwrap();
456 let parent_pos = out.find("parent").unwrap();
457 assert!(bar_pos < foo_pos, "'bar' should come before 'foo'");
458 assert!(foo_pos < parent_pos, "'foo' should come before 'parent'");
459 }
460
461 #[test]
462 fn render_commands_shows_about() {
463 let root = make_test_cmd();
464 let out = render_commands(&root);
465 assert!(out.contains("Do foo things"), "about missing in:\n{out}");
466 assert!(out.contains("Do bar things"), "about missing in:\n{out}");
467 }
468
469 #[test]
470 fn render_commands_shows_flags() {
471 let root = make_test_cmd();
472 let out = render_commands(&root);
473 assert!(out.contains("--verbose"), "flag missing in:\n{out}");
474 assert!(out.contains("-v,"), "short flag missing in:\n{out}");
475 assert!(out.contains("--count"), "flag missing in:\n{out}");
476 }
477
478 #[test]
479 fn render_commands_shows_default() {
480 let root = make_test_cmd();
481 let out = render_commands(&root);
482 assert!(out.contains("(default: 1)"), "default annotation missing in:\n{out}");
483 }
484
485 #[test]
486 fn render_commands_no_auto_flags() {
487 let root = make_test_cmd();
488 let out = render_commands(&root);
489 assert!(!out.contains("--help"), "--help appeared in:\n{out}");
490 assert!(!out.contains("--version"), "--version appeared in:\n{out}");
491 }
492
493 #[test]
494 fn render_commands_shows_subcommands() {
495 let root = make_test_cmd();
496 let out = render_commands(&root);
497 assert!(out.contains("parent child"), "subcommand missing in:\n{out}");
498 assert!(out.contains("Child command"), "subcommand about missing in:\n{out}");
499 }
500
501 #[test]
502 fn render_commands_shows_positional_in_usage() {
503 let root = make_test_cmd();
504 let out = render_commands(&root);
505 assert!(out.contains("<ID>"), "required positional missing in:\n{out}");
506 }
507
508 #[test]
509 fn wrap_short_line_unchanged() {
510 let result = wrap_with_indent(" ", "hello world", 100);
511 assert_eq!(result, " hello world");
512 }
513
514 #[test]
515 fn wrap_long_line_breaks_at_word_boundary() {
516 let result = wrap_with_indent(" ", "alpha beta gamma delta", 20);
519 let lines: Vec<&str> = result.lines().collect();
520 for line in &lines {
521 assert!(
522 line.len() <= 20,
523 "line exceeds 20 chars: {:?}",
524 line
525 );
526 }
527 assert!(result.contains("alpha"));
529 assert!(result.contains("delta"));
530 }
531
532 #[test]
533 fn wrap_continuation_lines_aligned() {
534 let result = wrap_with_indent(" --flag ", "word1 word2 word3 word4 word5 word6 word7 word8", 25);
537 let lines: Vec<&str> = result.lines().collect();
538 assert!(lines[0].starts_with(" --flag "), "first line: {:?}", lines[0]);
540 for line in lines.iter().skip(1) {
542 assert!(
543 line.starts_with(" "),
544 "continuation line not indented: {:?}",
545 line
546 );
547 }
548 }
549
550 #[test]
551 fn no_ansi_in_output() {
552 let root = make_test_cmd();
553 let out = render_commands(&root);
554 assert!(!out.contains('\x1b'), "ANSI escape code found in output");
555 }
556}