1use anyhow::Result;
2use apm_core::config::{Config, TicketConfig, WorkerProfileConfig, 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
56fn 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 if section == "worker_profiles" {
279 out.push_str("[worker_profiles.<name>]\n");
282 out.push_str("# Each key is a user-defined named profile whose fields mirror [workers].\n");
283
284 let profile_entries: Vec<FieldEntry> = schema_entries::<WorkerProfileConfig>()
285 .into_iter()
286 .map(|e| FieldEntry {
287 toml_path: format!("worker_profiles.<name>.{}", e.toml_path),
288 ..e
289 })
290 .collect();
291
292 if !profile_entries.is_empty() {
293 let path_w = profile_entries.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
294 let type_w = profile_entries.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
295 for e in &profile_entries {
296 out.push_str(&fmt_field_entry(e, path_w, type_w));
297 out.push('\n');
298 }
299 }
300 } else {
301 out.push_str(&format!("[{}]\n", section));
302
303 let path_w = group.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
304 let type_w = group.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
305 for e in group {
306 out.push_str(&fmt_field_entry(e, path_w, type_w));
307 out.push('\n');
308 }
309 }
310
311 out.push('\n');
312 }
313
314 out
315}
316
317fn fmt_field_entry(e: &FieldEntry, path_w: usize, type_w: usize) -> String {
318 let mut line = format!("{:<path_w$} {:<type_w$}", e.toml_path, e.type_name);
319 if let Some(ref d) = e.default {
320 line.push_str(&format!(" [default: {}]", d));
321 }
322 if let Some(ref desc) = e.description {
323 line.push_str(&format!(" # {}", desc));
324 }
325 if let Some(ref variants) = e.enum_variants {
326 line.push_str(&format!(" ({})", variants.join(" | ")));
327 }
328 line
329}
330
331fn render_workflow() -> String {
332 let mut out = String::new();
333 out.push_str("workflow.toml — state-machine and prioritization configuration\n");
334 out.push_str("workflow.states is an array of user-defined state objects; each element defines one node in the ticket state machine.\n");
335 out.push('\n');
336
337 let entries: Vec<FieldEntry> = schema_entries::<WorkflowConfig>()
339 .into_iter()
340 .map(|e| FieldEntry {
341 toml_path: format!("workflow.{}", e.toml_path),
342 ..e
343 })
344 .collect();
345
346 if entries.is_empty() {
347 return out;
348 }
349
350 let path_w = entries.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
351 let type_w = entries.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
352
353 for e in &entries {
354 let mut line = format!("{:<path_w$} {:<type_w$}", e.toml_path, e.type_name);
355 if let Some(ref d) = e.default {
356 line.push_str(&format!(" [default: {}]", d));
357 }
358 if let Some(ref desc) = e.description {
359 line.push_str(&format!(" # {}", desc));
360 }
361 if let Some(ref variants) = e.enum_variants {
362 line.push_str(&format!(" ({})", variants.join(" | ")));
363 }
364 out.push_str(&line);
365 out.push('\n');
366 }
367
368 out
369}
370
371fn render_ticket() -> String {
372 let mut out = String::new();
373 out.push_str("ticket.toml — ticket section configuration\n");
374 out.push_str("Defines the [[ticket.sections]] array: an ordered list of sections\n");
375 out.push_str("that appear on every ticket created in this project.\n");
376 out.push('\n');
377
378 let entries: Vec<FieldEntry> = schema_entries::<TicketConfig>()
380 .into_iter()
381 .map(|e| FieldEntry {
382 toml_path: format!("ticket.{}", e.toml_path),
383 ..e
384 })
385 .collect();
386
387 if entries.is_empty() {
388 return out;
389 }
390
391 let path_w = entries.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
392 let type_w = entries.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
393
394 for e in &entries {
395 let mut line = format!("{:<path_w$} {:<type_w$}", e.toml_path, e.type_name);
396 if let Some(ref d) = e.default {
397 line.push_str(&format!(" [default: {}]", d));
398 }
399 if let Some(ref desc) = e.description {
400 line.push_str(&format!(" # {}", desc));
401 }
402 if let Some(ref variants) = e.enum_variants {
403 line.push_str(&format!(" ({})", variants.join(" | ")));
404 }
405 out.push_str(&line);
406 out.push('\n');
407 }
408
409 out
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 fn make_test_cmd() -> clap::Command {
417 clap::Command::new("testapp")
418 .subcommand(
419 clap::Command::new("foo")
420 .about("Do foo things")
421 .arg(clap::Arg::new("id").value_name("ID").required(true))
422 .arg(
423 clap::Arg::new("verbose")
424 .long("verbose")
425 .short('v')
426 .action(clap::ArgAction::SetTrue)
427 .help("Enable verbose output"),
428 ),
429 )
430 .subcommand(
431 clap::Command::new("bar")
432 .about("Do bar things")
433 .arg(
434 clap::Arg::new("count")
435 .long("count")
436 .value_name("N")
437 .default_value("1")
438 .help("Number of repetitions"),
439 ),
440 )
441 .subcommand(
442 clap::Command::new("hidden")
443 .about("Should not appear")
444 .hide(true),
445 )
446 .subcommand(
447 clap::Command::new("parent")
448 .about("Has subcommands")
449 .subcommand(
450 clap::Command::new("child")
451 .about("Child command"),
452 ),
453 )
454 }
455
456 #[test]
457 fn render_commands_includes_visible_cmds() {
458 let root = make_test_cmd();
459 let out = render_commands(&root);
460 assert!(out.contains("foo"), "missing 'foo' in:\n{out}");
461 assert!(out.contains("bar"), "missing 'bar' in:\n{out}");
462 assert!(out.contains("parent"), "missing 'parent' in:\n{out}");
463 }
464
465 #[test]
466 fn render_commands_excludes_hidden() {
467 let root = make_test_cmd();
468 let out = render_commands(&root);
469 assert!(!out.contains("hidden"), "hidden cmd appeared in:\n{out}");
470 }
471
472 #[test]
473 fn render_commands_alphabetical_order() {
474 let root = make_test_cmd();
475 let out = render_commands(&root);
476 let bar_pos = out.find("bar").unwrap();
477 let foo_pos = out.find("foo").unwrap();
478 let parent_pos = out.find("parent").unwrap();
479 assert!(bar_pos < foo_pos, "'bar' should come before 'foo'");
480 assert!(foo_pos < parent_pos, "'foo' should come before 'parent'");
481 }
482
483 #[test]
484 fn render_commands_shows_about() {
485 let root = make_test_cmd();
486 let out = render_commands(&root);
487 assert!(out.contains("Do foo things"), "about missing in:\n{out}");
488 assert!(out.contains("Do bar things"), "about missing in:\n{out}");
489 }
490
491 #[test]
492 fn render_commands_shows_flags() {
493 let root = make_test_cmd();
494 let out = render_commands(&root);
495 assert!(out.contains("--verbose"), "flag missing in:\n{out}");
496 assert!(out.contains("-v,"), "short flag missing in:\n{out}");
497 assert!(out.contains("--count"), "flag missing in:\n{out}");
498 }
499
500 #[test]
501 fn render_commands_shows_default() {
502 let root = make_test_cmd();
503 let out = render_commands(&root);
504 assert!(out.contains("(default: 1)"), "default annotation missing in:\n{out}");
505 }
506
507 #[test]
508 fn render_commands_no_auto_flags() {
509 let root = make_test_cmd();
510 let out = render_commands(&root);
511 assert!(!out.contains("--help"), "--help appeared in:\n{out}");
512 assert!(!out.contains("--version"), "--version appeared in:\n{out}");
513 }
514
515 #[test]
516 fn render_commands_shows_subcommands() {
517 let root = make_test_cmd();
518 let out = render_commands(&root);
519 assert!(out.contains("parent child"), "subcommand missing in:\n{out}");
520 assert!(out.contains("Child command"), "subcommand about missing in:\n{out}");
521 }
522
523 #[test]
524 fn render_commands_shows_positional_in_usage() {
525 let root = make_test_cmd();
526 let out = render_commands(&root);
527 assert!(out.contains("<ID>"), "required positional missing in:\n{out}");
528 }
529
530 #[test]
531 fn wrap_short_line_unchanged() {
532 let result = wrap_with_indent(" ", "hello world", 100);
533 assert_eq!(result, " hello world");
534 }
535
536 #[test]
537 fn wrap_long_line_breaks_at_word_boundary() {
538 let result = wrap_with_indent(" ", "alpha beta gamma delta", 20);
541 let lines: Vec<&str> = result.lines().collect();
542 for line in &lines {
543 assert!(
544 line.len() <= 20,
545 "line exceeds 20 chars: {:?}",
546 line
547 );
548 }
549 assert!(result.contains("alpha"));
551 assert!(result.contains("delta"));
552 }
553
554 #[test]
555 fn wrap_continuation_lines_aligned() {
556 let result = wrap_with_indent(" --flag ", "word1 word2 word3 word4 word5 word6 word7 word8", 25);
559 let lines: Vec<&str> = result.lines().collect();
560 assert!(lines[0].starts_with(" --flag "), "first line: {:?}", lines[0]);
562 for line in lines.iter().skip(1) {
564 assert!(
565 line.starts_with(" "),
566 "continuation line not indented: {:?}",
567 line
568 );
569 }
570 }
571
572 #[test]
573 fn no_ansi_in_output() {
574 let root = make_test_cmd();
575 let out = render_commands(&root);
576 assert!(!out.contains('\x1b'), "ANSI escape code found in output");
577 }
578}