use anyhow::Result;
use apm_core::config::{Config, TicketConfig, WorkerProfileConfig, WorkflowConfig};
use apm_core::help_schema::{schema_entries, FieldEntry};
static TOPICS: &[(&str, &str)] = &[
("commands", "All apm subcommands and their usage"),
("config", "Fields available in .apm/config.toml"),
("workflow", "Fields available in .apm/workflow.toml"),
("ticket", "Fields available in .apm/ticket.toml"),
];
pub fn run(topic: Option<&str>, cli_cmd: clap::Command) -> Result<()> {
match topic {
None => {
print!("{}", render_overview());
Ok(())
}
Some(t) => {
let content = match t {
"commands" => render_commands(&cli_cmd),
"config" => render_config(),
"workflow" => render_workflow(),
"ticket" => render_ticket(),
unknown => {
let valid: Vec<&str> = TOPICS.iter().map(|(name, _)| *name).collect();
anyhow::bail!(
"unknown help topic {:?}; valid topics are: {}",
unknown,
valid.join(", ")
);
}
};
print!("{}", content);
Ok(())
}
}
}
fn render_overview() -> String {
let mut out = String::new();
out.push_str("apm help — topic reference for Agent Project Manager\n\n");
out.push_str("Run `apm help <topic>` for details on a specific topic.\n");
out.push_str("Run `apm <subcommand> --help` for flags on a specific command.\n\n");
out.push_str("Topics:\n");
for (name, summary) in TOPICS {
out.push_str(&format!(" {:<10} {}\n", name, summary));
}
out
}
fn render_commands(root: &clap::Command) -> String {
let mut cmds: Vec<&clap::Command> = root
.get_subcommands()
.filter(|c| !c.is_hide_set())
.collect();
cmds.sort_by_key(|c| c.get_name());
let mut out = String::from("Commands\n========\n\n");
let blocks: Vec<String> = cmds.iter().map(|c| render_one(c, "", 100)).collect();
out.push_str(&blocks.join("\n\n"));
out.push('\n');
out
}
fn render_one(cmd: &clap::Command, prefix: &str, max_width: usize) -> String {
let name = cmd.get_name();
let mut out = String::new();
let positionals: Vec<String> = cmd
.get_arguments()
.filter(|a| {
a.is_positional()
&& !a.is_hide_set()
&& a.get_id().as_str() != "help"
&& a.get_id().as_str() != "version"
})
.map(|a| {
let vname = a
.get_value_names()
.and_then(|names| names.first())
.map(|s| s.to_string())
.unwrap_or_else(|| a.get_id().to_string().to_uppercase());
if a.is_required_set() {
format!("<{}>", vname)
} else {
format!("[{}]", vname)
}
})
.collect();
let usage = if positionals.is_empty() {
format!("{}{}", prefix, name)
} else {
format!("{}{} {}", prefix, name, positionals.join(" "))
};
out.push_str(&usage);
out.push('\n');
if let Some(about) = cmd.get_about() {
let about_str = about.to_string();
if !about_str.is_empty() {
let wrapped = wrap_with_indent(" ", &about_str, max_width);
out.push_str(&wrapped);
out.push('\n');
}
}
for arg in cmd.get_arguments() {
if arg.is_hide_set() {
continue;
}
let id = arg.get_id().as_str();
if id == "help" || id == "version" {
continue;
}
if arg.is_positional() {
continue;
}
let long = match arg.get_long() {
Some(l) => l,
None => continue,
};
let short_part = arg
.get_short()
.map(|s| format!("-{}, ", s))
.unwrap_or_default();
let takes_value = !matches!(
arg.get_action(),
clap::ArgAction::SetTrue | clap::ArgAction::SetFalse | clap::ArgAction::Count
);
let val_part = if takes_value {
arg.get_value_names()
.and_then(|names| names.first())
.map(|v| format!(" <{}>", v))
.unwrap_or_default()
} else {
String::new()
};
let flag_head = format!(" {}--{}{}", short_part, long, val_part);
let help_str = arg
.get_help()
.map(|h| h.to_string())
.unwrap_or_default();
let defaults: Vec<String> = arg
.get_default_values()
.iter()
.map(|d| d.to_string_lossy().into_owned())
.collect();
let full_help = if !defaults.is_empty() && !help_str.contains("(default:") {
let def = defaults.join(", ");
if help_str.is_empty() {
format!("(default: {})", def)
} else {
format!("{} (default: {})", help_str, def)
}
} else {
help_str
};
let line = if full_help.is_empty() {
flag_head
} else {
let first_prefix = format!("{} ", flag_head);
wrap_with_indent(&first_prefix, &full_help, max_width)
};
out.push_str(&line);
out.push('\n');
}
let subcmds: Vec<&clap::Command> = cmd
.get_subcommands()
.filter(|c| !c.is_hide_set())
.collect();
if !subcmds.is_empty() {
out.push('\n');
let sub_prefix = format!("{}{} ", prefix, name);
let sub_max = max_width.saturating_sub(2);
for sub in &subcmds {
let block = render_one(sub, &sub_prefix, sub_max);
for line in block.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
out.push('\n');
}
while out.ends_with("\n\n") {
out.pop();
}
}
out.trim_end().to_string()
}
fn wrap_with_indent(first_prefix: &str, text: &str, max_width: usize) -> String {
if text.trim().is_empty() {
return first_prefix.trim_end().to_string();
}
let cont_indent: String = " ".repeat(first_prefix.len());
let mut result: Vec<String> = Vec::new();
let mut current = first_prefix.to_string();
for word in text.split_whitespace() {
if current.trim().is_empty() {
current.push_str(word);
} else if current.len() + 1 + word.len() <= max_width {
current.push(' ');
current.push_str(word);
} else {
result.push(current);
current = format!("{}{}", cont_indent, word);
}
}
result.push(current);
result.join("\n")
}
fn render_config() -> String {
let all_entries = schema_entries::<Config>();
let mut sections: Vec<(String, Vec<FieldEntry>)> = Vec::new();
for e in all_entries {
let seg = e.toml_path
.split(|c: char| c == '.' || c == '[')
.next()
.unwrap_or(e.toml_path.as_str())
.to_string();
match sections.iter_mut().find(|(k, _)| *k == seg) {
Some(group) => group.1.push(e),
None => sections.push((seg, vec![e])),
}
}
let mut out = String::from("config.toml — project and tool configuration\n\n");
for (section, group) in §ions {
if section == "workflow" || section == "ticket" {
continue;
}
if section == "worker_profiles" {
out.push_str("[worker_profiles.<name>]\n");
out.push_str("# Each key is a user-defined named profile whose fields mirror [workers].\n");
let profile_entries: Vec<FieldEntry> = schema_entries::<WorkerProfileConfig>()
.into_iter()
.map(|e| FieldEntry {
toml_path: format!("worker_profiles.<name>.{}", e.toml_path),
..e
})
.collect();
if !profile_entries.is_empty() {
let path_w = profile_entries.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
let type_w = profile_entries.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
for e in &profile_entries {
out.push_str(&fmt_field_entry(e, path_w, type_w));
out.push('\n');
}
}
} else {
out.push_str(&format!("[{}]\n", section));
let path_w = group.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
let type_w = group.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
for e in group {
out.push_str(&fmt_field_entry(e, path_w, type_w));
out.push('\n');
}
}
out.push('\n');
}
out
}
fn fmt_field_entry(e: &FieldEntry, path_w: usize, type_w: usize) -> String {
let mut line = format!("{:<path_w$} {:<type_w$}", e.toml_path, e.type_name);
if let Some(ref d) = e.default {
line.push_str(&format!(" [default: {}]", d));
}
if let Some(ref desc) = e.description {
line.push_str(&format!(" # {}", desc));
}
if let Some(ref variants) = e.enum_variants {
line.push_str(&format!(" ({})", variants.join(" | ")));
}
line
}
fn render_workflow() -> String {
let mut out = String::new();
out.push_str("workflow.toml — state-machine and prioritization configuration\n");
out.push_str("workflow.states is an array of user-defined state objects; each element defines one node in the ticket state machine.\n");
out.push('\n');
let entries: Vec<FieldEntry> = schema_entries::<WorkflowConfig>()
.into_iter()
.map(|e| FieldEntry {
toml_path: format!("workflow.{}", e.toml_path),
..e
})
.collect();
if entries.is_empty() {
return out;
}
let path_w = entries.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
let type_w = entries.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
for e in &entries {
let mut line = format!("{:<path_w$} {:<type_w$}", e.toml_path, e.type_name);
if let Some(ref d) = e.default {
line.push_str(&format!(" [default: {}]", d));
}
if let Some(ref desc) = e.description {
line.push_str(&format!(" # {}", desc));
}
if let Some(ref variants) = e.enum_variants {
line.push_str(&format!(" ({})", variants.join(" | ")));
}
out.push_str(&line);
out.push('\n');
}
out
}
fn render_ticket() -> String {
let mut out = String::new();
out.push_str("ticket.toml — ticket section configuration\n");
out.push_str("Defines the [[ticket.sections]] array: an ordered list of sections\n");
out.push_str("that appear on every ticket created in this project.\n");
out.push('\n');
let entries: Vec<FieldEntry> = schema_entries::<TicketConfig>()
.into_iter()
.map(|e| FieldEntry {
toml_path: format!("ticket.{}", e.toml_path),
..e
})
.collect();
if entries.is_empty() {
return out;
}
let path_w = entries.iter().map(|e| e.toml_path.len()).max().unwrap_or(0);
let type_w = entries.iter().map(|e| e.type_name.len()).max().unwrap_or(0);
for e in &entries {
let mut line = format!("{:<path_w$} {:<type_w$}", e.toml_path, e.type_name);
if let Some(ref d) = e.default {
line.push_str(&format!(" [default: {}]", d));
}
if let Some(ref desc) = e.description {
line.push_str(&format!(" # {}", desc));
}
if let Some(ref variants) = e.enum_variants {
line.push_str(&format!(" ({})", variants.join(" | ")));
}
out.push_str(&line);
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_cmd() -> clap::Command {
clap::Command::new("testapp")
.subcommand(
clap::Command::new("foo")
.about("Do foo things")
.arg(clap::Arg::new("id").value_name("ID").required(true))
.arg(
clap::Arg::new("verbose")
.long("verbose")
.short('v')
.action(clap::ArgAction::SetTrue)
.help("Enable verbose output"),
),
)
.subcommand(
clap::Command::new("bar")
.about("Do bar things")
.arg(
clap::Arg::new("count")
.long("count")
.value_name("N")
.default_value("1")
.help("Number of repetitions"),
),
)
.subcommand(
clap::Command::new("hidden")
.about("Should not appear")
.hide(true),
)
.subcommand(
clap::Command::new("parent")
.about("Has subcommands")
.subcommand(
clap::Command::new("child")
.about("Child command"),
),
)
}
#[test]
fn render_commands_includes_visible_cmds() {
let root = make_test_cmd();
let out = render_commands(&root);
assert!(out.contains("foo"), "missing 'foo' in:\n{out}");
assert!(out.contains("bar"), "missing 'bar' in:\n{out}");
assert!(out.contains("parent"), "missing 'parent' in:\n{out}");
}
#[test]
fn render_commands_excludes_hidden() {
let root = make_test_cmd();
let out = render_commands(&root);
assert!(!out.contains("hidden"), "hidden cmd appeared in:\n{out}");
}
#[test]
fn render_commands_alphabetical_order() {
let root = make_test_cmd();
let out = render_commands(&root);
let bar_pos = out.find("bar").unwrap();
let foo_pos = out.find("foo").unwrap();
let parent_pos = out.find("parent").unwrap();
assert!(bar_pos < foo_pos, "'bar' should come before 'foo'");
assert!(foo_pos < parent_pos, "'foo' should come before 'parent'");
}
#[test]
fn render_commands_shows_about() {
let root = make_test_cmd();
let out = render_commands(&root);
assert!(out.contains("Do foo things"), "about missing in:\n{out}");
assert!(out.contains("Do bar things"), "about missing in:\n{out}");
}
#[test]
fn render_commands_shows_flags() {
let root = make_test_cmd();
let out = render_commands(&root);
assert!(out.contains("--verbose"), "flag missing in:\n{out}");
assert!(out.contains("-v,"), "short flag missing in:\n{out}");
assert!(out.contains("--count"), "flag missing in:\n{out}");
}
#[test]
fn render_commands_shows_default() {
let root = make_test_cmd();
let out = render_commands(&root);
assert!(out.contains("(default: 1)"), "default annotation missing in:\n{out}");
}
#[test]
fn render_commands_no_auto_flags() {
let root = make_test_cmd();
let out = render_commands(&root);
assert!(!out.contains("--help"), "--help appeared in:\n{out}");
assert!(!out.contains("--version"), "--version appeared in:\n{out}");
}
#[test]
fn render_commands_shows_subcommands() {
let root = make_test_cmd();
let out = render_commands(&root);
assert!(out.contains("parent child"), "subcommand missing in:\n{out}");
assert!(out.contains("Child command"), "subcommand about missing in:\n{out}");
}
#[test]
fn render_commands_shows_positional_in_usage() {
let root = make_test_cmd();
let out = render_commands(&root);
assert!(out.contains("<ID>"), "required positional missing in:\n{out}");
}
#[test]
fn wrap_short_line_unchanged() {
let result = wrap_with_indent(" ", "hello world", 100);
assert_eq!(result, " hello world");
}
#[test]
fn wrap_long_line_breaks_at_word_boundary() {
let result = wrap_with_indent(" ", "alpha beta gamma delta", 20);
let lines: Vec<&str> = result.lines().collect();
for line in &lines {
assert!(
line.len() <= 20,
"line exceeds 20 chars: {:?}",
line
);
}
assert!(result.contains("alpha"));
assert!(result.contains("delta"));
}
#[test]
fn wrap_continuation_lines_aligned() {
let result = wrap_with_indent(" --flag ", "word1 word2 word3 word4 word5 word6 word7 word8", 25);
let lines: Vec<&str> = result.lines().collect();
assert!(lines[0].starts_with(" --flag "), "first line: {:?}", lines[0]);
for line in lines.iter().skip(1) {
assert!(
line.starts_with(" "),
"continuation line not indented: {:?}",
line
);
}
}
#[test]
fn no_ansi_in_output() {
let root = make_test_cmd();
let out = render_commands(&root);
assert!(!out.contains('\x1b'), "ANSI escape code found in output");
}
}