Skip to main content

apm/cmd/
help.rs

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
12// `apm help commands` uses flat word-wrapped lines rather than the
13// column-aligned table format used by the config/workflow/ticket topics.
14// The divergence is intentional: commands form a hierarchy
15// (command → positionals → flags → subcommands) that does not fit a
16// key-value table layout; schema topics describe flat key-value fields.
17pub 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
70/// Render a single command (and recursively its subcommands) into a text block.
71///
72/// `prefix` is prepended to the usage line (e.g. "epic " for subcommands).
73/// `max_width` is the line-length limit for wrapping; callers reduce it by 2
74/// for each level of indentation that will be applied to the output.
75fn 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    // Usage line: {prefix}{name} [<POS1> [POS2] ...]
80    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    // About text (one-liner from get_about())
111    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    // Flags and options (non-positional, non-hidden, not auto-generated)
121    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        // "  -s, --flag <VALUE>" or "  --flag <VALUE>" or "  --flag"
138        let short_part = arg
139            .get_short()
140            .map(|s| format!("-{}, ", s))
141            .unwrap_or_default();
142        // Boolean flags (SetTrue / SetFalse / Count) take no value — omit the
143        // <VALUE> placeholder. Other actions (Set, Append, …) display it.
144        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        // Help text, optionally followed by "(default: X)"
159        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        // Append a "(default: X)" annotation only when the help text does not
169        // already contain one (some commands embed the default in their doc comment).
170        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            // Two-space separator between flag definition and help text
185            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    // Subcommands (recursive, not re-sorted — declaration order preserved)
193    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        // Reduce the wrap limit by 2 to compensate for the 2-space indent
201        // applied to each subcommand block below.
202        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        // Drop the trailing blank line added after the last subcommand
213        while out.ends_with("\n\n") {
214            out.pop();
215        }
216    }
217
218    out.trim_end().to_string()
219}
220
221/// Word-wrap `text` into lines of at most `max_width` characters.
222///
223/// The first line is prefixed with `first_prefix`. Continuation lines are
224/// indented with the same number of spaces as `first_prefix` has characters,
225/// so the text column stays aligned across wrapped lines.
226fn 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        // current.trim().is_empty() is true when the line contains only spaces
237        // (the initial prefix or a continuation indent) — no real text yet.
238        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    // Group entries by their first path segment (before first '.' or '[').
256    // Preserve encounter order (schemars respects struct declaration order).
257    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 &sections {
273        // workflow and ticket have dedicated `apm help workflow` / `apm help ticket` topics.
274        if section == "workflow" || section == "ticket" {
275            continue;
276        }
277
278        if section == "worker_profiles" {
279            // worker_profiles is a HashMap<String, WorkerProfileConfig>.
280            // Render using map notation: worker_profiles.<name>.<field>.
281            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    // Get entries and prefix all paths with "workflow." to match the TOML key hierarchy.
338    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    // Get entries and prefix all paths with "ticket." to match the TOML key hierarchy.
379    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        // Each word is 5 chars; prefix is 2 chars; max is 20.
539        // "  alpha beta gamma delta" = 24 chars → should wrap.
540        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        // All words must appear somewhere in the output
550        assert!(result.contains("alpha"));
551        assert!(result.contains("delta"));
552    }
553
554    #[test]
555    fn wrap_continuation_lines_aligned() {
556        // prefix = "  --flag  " (10 chars); text wraps; continuation should
557        // also be indented 10 chars.
558        let result = wrap_with_indent("  --flag  ", "word1 word2 word3 word4 word5 word6 word7 word8", 25);
559        let lines: Vec<&str> = result.lines().collect();
560        // First line starts with "  --flag  "
561        assert!(lines[0].starts_with("  --flag  "), "first line: {:?}", lines[0]);
562        // Continuation lines start with 10 spaces
563        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}