Skip to main content

apm/cmd/
help.rs

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
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
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
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        {
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    // Get entries and prefix all paths with "workflow." to match the TOML key hierarchy.
316    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    // Get entries and prefix all paths with "ticket." to match the TOML key hierarchy.
357    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        // Each word is 5 chars; prefix is 2 chars; max is 20.
517        // "  alpha beta gamma delta" = 24 chars → should wrap.
518        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        // All words must appear somewhere in the output
528        assert!(result.contains("alpha"));
529        assert!(result.contains("delta"));
530    }
531
532    #[test]
533    fn wrap_continuation_lines_aligned() {
534        // prefix = "  --flag  " (10 chars); text wraps; continuation should
535        // also be indented 10 chars.
536        let result = wrap_with_indent("  --flag  ", "word1 word2 word3 word4 word5 word6 word7 word8", 25);
537        let lines: Vec<&str> = result.lines().collect();
538        // First line starts with "  --flag  "
539        assert!(lines[0].starts_with("  --flag  "), "first line: {:?}", lines[0]);
540        // Continuation lines start with 10 spaces
541        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}