use clap::{Arg, ArgMatches, Command};
use super::commands;
use super::context::Context;
use super::errors::{die, die1, CliError};
use super::output::{extract_output_from_args, OutputFormat};
use crate::infra::driven::fs::config::{try_load_config, CartularyConfig, CURRENT_SCHEMA_VERSION};
fn root_dir_arg() -> Arg {
Arg::new("root-dir")
.long("root-dir")
.value_name("PATH")
.help("Root directory of the workspace (defaults to current directory)")
.global(true)
}
fn output_format_arg() -> Arg {
Arg::new("output")
.long("output")
.short('o')
.value_name("FORMAT")
.help("Output format: human (default), json, yaml")
.global(true)
}
fn build_cli(config: &CartularyConfig) -> Command {
let mut app = Command::new("cartu")
.about("The knowledge layer of your project")
.version(env!("CARGO_PKG_VERSION"))
.subcommand_required(true)
.arg_required_else_help(true)
.arg(root_dir_arg())
.arg(output_format_arg());
for kind_cfg in &config.decision_kinds {
let kind: &'static str = Box::leak(kind_cfg.kind.clone().into_boxed_str());
let label: &'static str =
Box::leak(format!("Manage {}s", kind_cfg.kind.to_uppercase()).into_boxed_str());
let sub = Command::new(kind)
.about(label)
.subcommand_required(true)
.arg_required_else_help(true);
let sub = commands::decision_record::decision_record_subcommands()
.into_iter()
.fold(sub, |s, cmd| s.subcommand(cmd));
app = app.subcommand(sub);
}
app = app.subcommand(commands::issue::issue_subcommand());
app = app.subcommand(commands::backlog::subcommand());
app = app.subcommand(commands::decisions::subcommand());
app = app.subcommand(
Command::new("init")
.about("Create a default cartulary.toml in the workspace root")
.long_about(
"Create a default `cartulary.toml` in the workspace root \
(defaults to the current directory). The file declares one \
ADR kind under `docs/adr/` and an issues directory under \
`docs/issues/`; both directories are created lazily when \
the first record is added. Fails if `cartulary.toml` \
already exists.",
),
);
app = app.subcommand(commands::site::site_subcommand());
app = app.subcommand(commands::query::subcommand());
if !config.sources.is_empty() {
let mut source_cmd = Command::new("source")
.about("Interact with external issue sources")
.subcommand_required(true)
.arg_required_else_help(true);
for src_cfg in &config.sources {
let name: &'static str = Box::leak(src_cfg.name.clone().into_boxed_str());
let label: &'static str =
Box::leak(format!("Manage issues from source '{}'", src_cfg.name).into_boxed_str());
source_cmd = source_cmd.subcommand(
Command::new(name)
.about(label)
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand(Command::new("list").about("List issues from this source"))
.subcommand(
Command::new("sync")
.about("Sync issues from this source into local workspace")
.arg(
Arg::new("dry-run")
.long("dry-run")
.action(clap::ArgAction::SetTrue)
.help("Print what would change without writing"),
),
),
);
}
app = app.subcommand(source_cmd);
}
app = app.subcommand(
Command::new("search")
.about("Search across all issues and decision records")
.arg(
Arg::new("query")
.help("Search query (fuzzy)")
.value_name("QUERY")
.required(true),
)
.arg(
Arg::new("kind")
.long("kind")
.value_name("KIND")
.help("Restrict to a kind: issue, adr, ddr, …"),
)
.arg(
Arg::new("limit")
.long("limit")
.value_name("N")
.help("Maximum number of results (default: all)"),
),
);
app = app.subcommand(
Command::new("check")
.about("Validate all decision records and issues")
.long_about(
"Validate every entry in the workspace. Reports invalid \
frontmatter, broken links, unknown statuses, cycles in \
hierarchical relationships (e.g. `parent-of`), and \
multi-parent violations. Exits non-zero on any error-level \
violation; warnings are reported but do not fail the run. \
With `--fix`, mechanically repair the violations rules \
know how to repair (currently: missing inverse pointers); \
unrepairable errors still fail the run.",
)
.arg(
Arg::new("verbose")
.long("verbose")
.short('v')
.help("Also list files with no violations")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("fix")
.long("fix")
.help("Repair every fixable violation in place before reporting")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("dry-run")
.long("dry-run")
.help("Preview the repairs --fix would apply, without writing")
.requires("fix")
.action(clap::ArgAction::SetTrue),
),
);
app = app.subcommand(
Command::new("fmt")
.about("Canonicalize hand-edited frontmatter across all entries")
.arg(
Arg::new("dry-run")
.long("dry-run")
.action(clap::ArgAction::SetTrue)
.help("Print what would change without writing"),
)
.arg(
Arg::new("check")
.long("check")
.action(clap::ArgAction::SetTrue)
.help("Exit non-zero if any file would change (no writes)"),
),
);
app = app.subcommand(commands::migrate::subcommand());
app = app.subcommand(commands::relates::subcommand());
app = app.subcommand(
Command::new("man")
.about("Render a concept page to the terminal")
.arg(
Arg::new("name")
.value_name("NAME")
.help("Concept to render; without an argument, list available topics"),
),
);
app = app.subcommand(
Command::new("completions")
.about("Generate shell completion scripts")
.after_help(
"Installation examples:\n \
bash: cartu completions bash >> ~/.bash_completion.d/cartu\n \
zsh: cartu completions zsh >> ~/.zfunc/_cartu\n \
fish: cartu completions fish >> ~/.config/fish/completions/cartu.fish",
)
.arg(
Arg::new("shell")
.required(true)
.value_name("SHELL")
.help("Shell to generate completions for: bash, zsh, fish"),
),
);
app
}
pub fn reference_command() -> Command {
use std::path::PathBuf;
let canonical = CartularyConfig {
schema_version: CURRENT_SCHEMA_VERSION,
decision_kinds: vec![crate::infra::driven::fs::config::KindConfig {
kind: "adr".to_string(),
dir: PathBuf::from("docs/adr"),
union: vec![],
id_prefix: Some("ADR-".to_string()),
}],
issues_dir: PathBuf::from("docs/issues"),
issues_union: vec![],
issues_id_prefix: Some("ISSUE-".to_string()),
issues_statuses: crate::domain::model::status::StatusesConfig::default_issue(),
tag_descriptors: crate::domain::model::tag_descriptor::TagDescriptors::default(),
sources: vec![],
docs: vec![],
site_title: None,
site_nav: vec![],
site_theme: None,
site_out: None,
query_dir: PathBuf::from("docs/queries"),
};
build_cli(&canonical)
}
pub fn execute() {
let pre = Command::new("cartu")
.arg(root_dir_arg())
.allow_external_subcommands(true)
.disable_help_flag(true);
let root_dir: std::path::PathBuf = pre
.try_get_matches()
.ok()
.as_ref()
.and_then(|m| m.get_one::<String>("root-dir"))
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("."));
let output_fmt = extract_output_from_args();
let config_path = root_dir.join("cartulary.toml");
let raw_args: Vec<String> = std::env::args().collect();
let config = match try_load_config(&config_path, &root_dir) {
Ok(c) => c,
Err(err) => {
if is_bypass_invocation(&raw_args) {
CartularyConfig::default_for_root(&root_dir)
} else {
eprintln!("error: {err}");
std::process::exit(1);
}
}
};
let matches = build_cli(&config).try_get_matches().unwrap_or_else(|err| {
if err.kind() == clap::error::ErrorKind::InvalidSubcommand {
let token = unknown_subcommand_token(&err);
emit_unknown_subcommand_hint(&token, &config, output_fmt);
}
err.exit();
});
let ctx = Context::new(&config, root_dir.to_path_buf(), output_fmt);
dispatch(&matches, &ctx);
}
fn unknown_subcommand_token(err: &clap::Error) -> String {
let text = err.to_string();
let after = text
.find("unrecognized subcommand '")
.map(|i| &text[i + "unrecognized subcommand '".len()..]);
match after.and_then(|s| s.find('\'').map(|j| &s[..j])) {
Some(t) => t.to_string(),
None => "<unknown>".to_string(),
}
}
fn emit_unknown_subcommand_hint(
token: &str,
config: &CartularyConfig,
output_fmt: OutputFormat,
) -> ! {
let mut hint_lines: Vec<String> = Vec::new();
if token == "source" {
hint_lines.push(
"no external source is declared. Add `[sources.<name>]` to cartulary.toml \
to enable `cartu source <name>`."
.to_string(),
);
} else {
let configured_kinds: Vec<&str> = config
.decision_kinds
.iter()
.map(|k| k.kind.as_str())
.collect();
if !configured_kinds.contains(&token) {
hint_lines.push(format!(
"if '{token}' is a decision-record kind, add it to `[decisions].types` in cartulary.toml \
(currently configured: {})",
if configured_kinds.is_empty() {
"none".to_string()
} else {
configured_kinds.join(", ")
}
));
}
let configured_sources: Vec<&str> =
config.sources.iter().map(|s| s.name.as_str()).collect();
if !configured_sources.is_empty() {
hint_lines.push(format!(
"configured sources: {}",
configured_sources.join(", ")
));
}
}
let mut err = CliError::new(format!("unrecognized subcommand '{token}'")).kind("validation");
if !hint_lines.is_empty() {
err = err.hint(hint_lines.join(" "));
}
die(err, output_fmt, 2)
}
fn is_outdated_bypass(name: &str) -> bool {
matches!(name, "migrate" | "init" | "completions" | "man")
}
fn is_bypass_invocation(args: &[String]) -> bool {
args.iter().skip(1).any(|a| {
matches!(
a.as_str(),
"--help" | "-h" | "--version" | "-V" | "migrate" | "init" | "completions" | "man"
)
})
}
fn dispatch(matches: &ArgMatches, ctx: &Context<'_>) {
if let Some((name, _)) = matches.subcommand() {
let v = ctx.config().schema_version;
if v < CURRENT_SCHEMA_VERSION && !is_outdated_bypass(name) {
die1(
CliError::new(format!(
"cartulary.toml is at schema version {v}, this binary requires v{CURRENT_SCHEMA_VERSION}"
))
.kind("config")
.hint("Run `cartu migrate` to upgrade the workspace."),
ctx.output_fmt,
);
}
}
match matches.subcommand() {
Some(("issue", sub)) => commands::issue::execute_issue(sub, ctx),
Some(("backlog", sub)) => commands::backlog::execute(sub, ctx),
Some(("decisions", sub)) => commands::decisions::execute(sub, ctx),
Some(("init", _)) => commands::init::execute_init(&ctx.root_dir, ctx.output_fmt),
Some(("man", sub)) => commands::man::execute_man(sub, ctx.output_fmt),
Some(("check", sub)) => commands::check::execute_global_check(sub, ctx),
Some(("fmt", sub)) => commands::fmt::execute_fmt(sub, ctx),
Some(("migrate", sub)) => commands::migrate::execute(sub, ctx),
Some(("relates", sub)) => commands::relates::execute(sub, ctx),
Some(("search", sub)) => commands::search::execute_search(sub, ctx),
Some(("completions", sub)) => {
let mut app = build_cli(ctx.config());
commands::completions::execute_completions(sub, &mut app, ctx.output_fmt);
}
Some(("site", sub)) => commands::site::execute_site(sub, ctx),
Some(("query", sub)) => commands::query::execute(sub, ctx),
Some(("source", sub)) => {
if let Some((source_name, source_sub)) = sub.subcommand() {
if let Some(src_cfg) = ctx.config().sources.iter().find(|s| s.name == source_name) {
commands::source::execute_source(source_sub, ctx, src_cfg);
} else {
die1(
CliError::new(format!("unknown source '{source_name}'")).kind("not-found"),
ctx.output_fmt,
);
}
}
}
Some((kind, sub)) => {
if let Some(kind_cfg) = ctx.config().decision_kinds.iter().find(|k| k.kind == kind) {
commands::decision_record::execute_decision_records(sub, ctx, kind_cfg);
} else {
die1(
CliError::new(format!("unknown subcommand '{kind}'")).kind("validation"),
ctx.output_fmt,
);
}
}
None => unreachable!("subcommand_required enforces a match"),
}
}