pub mod graph;
pub mod submit;
use std::path::PathBuf;
use clap::Args;
use clap::Command;
use clap::CommandFactory;
use clap::FromArgMatches;
use clap::Parser;
use clap::Subcommand;
use clap_complete::Shell;
use crate::cli::graph::GraphArgs;
use crate::cli::submit::SubmitArgs;
use crate::config::Config;
#[derive(Debug, Parser)]
#[command(version, about, after_long_help = env!("CARGO_PKG_REPOSITORY"))]
pub struct Cli {
#[arg(long, global = true, env = "STAKK_CONFIG", verbatim_doc_comment)]
pub config: Option<PathBuf>,
#[arg(long, global = true, env = "STAKK_GITHUB_HOST", verbatim_doc_comment)]
pub github_host: Option<String>,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
Submit(Box<SubmitArgs>),
Show(ShowArgs),
Completions {
shell: Shell,
},
Docs {
#[arg(value_enum)]
topic: Option<DocTopic>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum DocTopic {
Agents,
Scripting,
Show,
Config,
Auth,
Template,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum ShowFormat {
#[default]
Pretty,
Json,
JsonFull,
}
#[derive(Debug, Args)]
pub struct ShowArgs {
#[arg(long, default_value = "pretty", value_enum, verbatim_doc_comment)]
pub format: ShowFormat,
#[command(flatten)]
pub graph: GraphArgs,
}
#[expect(
clippy::needless_pass_by_value,
reason = "Config is moved into closures captured by mut_subcommand which requires 'static"
)]
pub fn apply_config_defaults(config: Config, cmd: Command) -> Command {
let cmd = apply_global_defaults(&config, cmd);
let config2 = config.clone();
let cmd = cmd.mut_subcommand("submit", |sub| {
let sub = apply_submit_defaults(&config, sub);
apply_graph_defaults(&config, sub)
});
cmd.mut_subcommand("show", |sub| apply_graph_defaults(&config2, sub))
}
pub fn default_submit_args(config: Config) -> Result<SubmitArgs, clap::Error> {
let cmd = apply_config_defaults(config, Cli::command());
let matches = cmd.try_get_matches_from(["stakk", "submit"])?;
let submit = matches
.subcommand_matches("submit")
.expect("the synthetic argv always names the submit subcommand");
SubmitArgs::from_arg_matches(submit)
}
fn set_default(cmd: Command, arg_id: &str, value: &str) -> Command {
let leaked: &'static str = Box::leak(value.to_string().into_boxed_str());
cmd.mut_arg(arg_id, |a| a.default_value(leaked))
}
fn apply_global_defaults(config: &Config, mut cmd: Command) -> Command {
if let Some(ref host) = config.github_host {
cmd = set_default(cmd, "github_host", host);
}
cmd
}
fn apply_submit_defaults(config: &Config, mut cmd: Command) -> Command {
if let Some(ref remote) = config.remote {
cmd = set_default(cmd, "remote", remote);
}
if let Some(pr_mode) = config.pr_mode {
cmd = set_default(cmd, "pr_mode", &pr_mode.to_string());
}
if let Some(ref template_path) = config.template_path {
cmd = set_default(cmd, "template_path", template_path);
}
if let Some(sp) = config.stack_placement {
cmd = set_default(cmd, "stack_placement", &sp.to_string());
}
if let Some(spc) = config.sync_pr_content {
cmd = set_default(cmd, "sync_pr_content", &spc.to_string());
}
if let Some(tr) = config.trailers {
cmd = set_default(cmd, "trailers", &tr.to_string());
}
if let Some(ref ap) = config.auto_prefix {
cmd = set_default(cmd, "auto_prefix", ap);
}
if let Some(ref bc) = config.bookmark_command {
cmd = set_default(cmd, "bookmark_command", bc);
}
cmd
}
fn apply_graph_defaults(config: &Config, mut cmd: Command) -> Command {
if let Some(ref br) = config.bookmarks_revset {
cmd = set_default(cmd, "bookmarks_revset", br);
}
if let Some(ref hr) = config.heads_revset {
cmd = set_default(cmd, "heads_revset", hr);
}
cmd
}
#[cfg(test)]
mod tests {
use super::*;
use crate::forge::comment::StackPlacement;
fn parse_with_config(config: Config, args: &[&str]) -> Cli {
let cmd = apply_config_defaults(config, Cli::command());
let matches = cmd.get_matches_from(args);
Cli::from_arg_matches(&matches).unwrap()
}
fn submit_args(cli: &Cli) -> &SubmitArgs {
match &cli.command {
Some(Commands::Submit(args)) => args,
other => panic!("expected Submit, got {other:?}"),
}
}
use crate::cli::submit::PrMode;
#[test]
fn pr_mode_default_no_config() {
let cli = parse_with_config(Config::default(), &["stakk", "submit"]);
assert_eq!(submit_args(&cli).pr_mode, PrMode::Regular);
}
#[test]
fn pr_mode_config_draft_no_flag() {
let config = Config {
pr_mode: Some(PrMode::Draft),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(submit_args(&cli).pr_mode, PrMode::Draft);
}
#[test]
fn pr_mode_config_regular_no_flag() {
let config = Config {
pr_mode: Some(PrMode::Regular),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(submit_args(&cli).pr_mode, PrMode::Regular);
}
#[test]
fn pr_mode_cli_draft() {
let config = Config {
pr_mode: Some(PrMode::Regular),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit", "--pr-mode", "draft"]);
assert_eq!(submit_args(&cli).pr_mode, PrMode::Draft);
}
#[test]
fn pr_mode_cli_overrides_config() {
let config = Config {
pr_mode: Some(PrMode::Draft),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit", "--pr-mode", "regular"]);
assert_eq!(submit_args(&cli).pr_mode, PrMode::Regular);
}
#[test]
fn bare_stakk_parses_to_no_subcommand() {
let cli = parse_with_config(Config::default(), &["stakk"]);
assert!(cli.command.is_none());
}
#[test]
fn bare_stakk_submit_args_come_from_a_config_applied_clap_parse() {
let config = Config {
pr_mode: Some(PrMode::Draft),
..Default::default()
};
let args = default_submit_args(config).unwrap();
assert_eq!(args.pr_mode, PrMode::Draft);
}
#[test]
fn bare_stakk_rejects_submit_flags() {
use clap::error::ErrorKind;
let cmd = apply_config_defaults(Config::default(), Cli::command());
let err = cmd
.try_get_matches_from(["stakk", "--dry-run"])
.unwrap_err();
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
}
#[test]
fn remote_default_no_config() {
let cli = parse_with_config(Config::default(), &["stakk", "submit"]);
assert_eq!(submit_args(&cli).remote, "origin");
}
#[test]
fn remote_config_override() {
let config = Config {
remote: Some("upstream".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(submit_args(&cli).remote, "upstream");
}
#[test]
fn remote_cli_overrides_config() {
let config = Config {
remote: Some("upstream".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit", "--remote", "other"]);
assert_eq!(submit_args(&cli).remote, "other");
}
#[test]
fn github_host_default_none() {
let cli = parse_with_config(Config::default(), &["stakk", "submit"]);
assert_eq!(cli.github_host, None);
}
#[test]
fn github_host_from_config() {
let config = Config {
github_host: Some("github.example.com".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(cli.github_host.as_deref(), Some("github.example.com"));
}
#[test]
fn github_host_cli_overrides_config() {
let config = Config {
github_host: Some("github.example.com".into()),
..Default::default()
};
let cli = parse_with_config(
config,
&["stakk", "submit", "--github-host", "ghe.other.com"],
);
assert_eq!(cli.github_host.as_deref(), Some("ghe.other.com"));
}
#[test]
fn github_host_is_global_and_parses_before_the_subcommand() {
let cli = parse_with_config(
Config::default(),
&["stakk", "--github-host", "ghe.example.com", "submit"],
);
assert_eq!(cli.github_host.as_deref(), Some("ghe.example.com"));
}
#[test]
fn github_host_from_config_reaches_show() {
let config = Config {
github_host: Some("github.example.com".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "show"]);
assert_eq!(cli.github_host.as_deref(), Some("github.example.com"));
}
#[test]
fn github_host_from_config_reaches_bare_stakk() {
let config = Config {
github_host: Some("github.example.com".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk"]);
assert_eq!(cli.github_host.as_deref(), Some("github.example.com"));
}
#[test]
fn template_path_default_none() {
let cli = parse_with_config(Config::default(), &["stakk", "submit"]);
assert_eq!(submit_args(&cli).template_path, None);
}
#[test]
fn template_path_config_override() {
let config = Config {
template_path: Some("/from/config.jinja".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(
submit_args(&cli).template_path.as_deref(),
Some("/from/config.jinja"),
);
}
#[test]
fn template_path_cli_overrides_config() {
let config = Config {
template_path: Some("/from/config.jinja".into()),
..Default::default()
};
let cli = parse_with_config(
config,
&["stakk", "submit", "--template-path", "/from/cli.jinja"],
);
assert_eq!(
submit_args(&cli).template_path.as_deref(),
Some("/from/cli.jinja"),
);
}
#[test]
fn stack_placement_default_no_config() {
let cli = parse_with_config(Config::default(), &["stakk", "submit"]);
assert_eq!(submit_args(&cli).stack_placement, StackPlacement::Comment);
}
#[test]
fn stack_placement_config_body() {
let config = Config {
stack_placement: Some(StackPlacement::Body),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(submit_args(&cli).stack_placement, StackPlacement::Body);
}
#[test]
fn stack_placement_cli_overrides_config() {
let config = Config {
stack_placement: Some(StackPlacement::Body),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit", "--stack-placement", "comment"]);
assert_eq!(submit_args(&cli).stack_placement, StackPlacement::Comment);
}
#[test]
fn stack_placement_config_none() {
let config = Config {
stack_placement: Some(StackPlacement::None),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(submit_args(&cli).stack_placement, StackPlacement::None);
}
#[test]
fn stack_placement_cli_none_overrides_config() {
let config = Config {
stack_placement: Some(StackPlacement::Body),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit", "--stack-placement", "none"]);
assert_eq!(submit_args(&cli).stack_placement, StackPlacement::None);
}
#[test]
fn sync_pr_content_default_none() {
let cli = parse_with_config(Config::default(), &["stakk", "submit"]);
assert_eq!(
submit_args(&cli).sync_pr_content,
crate::cli::submit::SyncPrContent::None,
);
}
#[test]
fn sync_pr_content_config_all() {
let config = Config {
sync_pr_content: Some(crate::cli::submit::SyncPrContent::All),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(
submit_args(&cli).sync_pr_content,
crate::cli::submit::SyncPrContent::All,
);
}
#[test]
fn sync_pr_content_cli_overrides_config() {
let config = Config {
sync_pr_content: Some(crate::cli::submit::SyncPrContent::All),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit", "--sync-pr-content=title"]);
assert_eq!(
submit_args(&cli).sync_pr_content,
crate::cli::submit::SyncPrContent::Title,
);
}
#[test]
fn trailers_default_keep() {
let cli = parse_with_config(Config::default(), &["stakk", "submit"]);
assert_eq!(
submit_args(&cli).trailers,
crate::cli::submit::TrailerHandling::Keep,
);
}
#[test]
fn trailers_config_strip() {
let config = Config {
trailers: Some(crate::cli::submit::TrailerHandling::Strip),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(
submit_args(&cli).trailers,
crate::cli::submit::TrailerHandling::Strip,
);
}
#[test]
fn trailers_cli_overrides_config() {
let config = Config {
trailers: Some(crate::cli::submit::TrailerHandling::Strip),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit", "--trailers=keep"]);
assert_eq!(
submit_args(&cli).trailers,
crate::cli::submit::TrailerHandling::Keep,
);
}
#[test]
fn auto_prefix_config_override() {
let config = Config {
auto_prefix: Some("gb-".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(submit_args(&cli).auto_prefix.as_deref(), Some("gb-"));
}
#[test]
fn auto_prefix_cli_overrides_config() {
let config = Config {
auto_prefix: Some("gb-".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit", "--auto-prefix", "xx-"]);
assert_eq!(submit_args(&cli).auto_prefix.as_deref(), Some("xx-"));
}
#[test]
fn bookmarks_revset_config_override() {
let config = Config {
bookmarks_revset: Some("all()".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(submit_args(&cli).graph.bookmarks_revset, "all()");
}
#[test]
fn heads_revset_config_override() {
let config = Config {
heads_revset: Some("heads(all())".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit"]);
assert_eq!(submit_args(&cli).graph.heads_revset, "heads(all())");
}
#[test]
fn revset_cli_overrides_config() {
let config = Config {
bookmarks_revset: Some("all()".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit", "--bookmarks-revset", "mine()"]);
assert_eq!(submit_args(&cli).graph.bookmarks_revset, "mine()");
}
#[test]
fn show_inherits_graph_defaults() {
let config = Config {
bookmarks_revset: Some("custom()".into()),
heads_revset: Some("heads(custom())".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "show"]);
match &cli.command {
Some(Commands::Show(args)) => {
assert_eq!(args.graph.bookmarks_revset, "custom()");
assert_eq!(args.graph.heads_revset, "heads(custom())");
}
other => panic!("expected Show, got {other:?}"),
}
}
#[test]
fn docs_without_topic_is_none() {
let cli = parse_with_config(Config::default(), &["stakk", "docs"]);
match &cli.command {
Some(Commands::Docs { topic }) => assert_eq!(*topic, None),
other => panic!("expected Docs, got {other:?}"),
}
}
#[test]
fn docs_parses_each_topic() {
for (arg, expected) in [
("agents", DocTopic::Agents),
("scripting", DocTopic::Scripting),
("show", DocTopic::Show),
("config", DocTopic::Config),
("template", DocTopic::Template),
] {
let cli = parse_with_config(Config::default(), &["stakk", "docs", arg]);
match &cli.command {
Some(Commands::Docs { topic }) => assert_eq!(*topic, Some(expected)),
other => panic!("expected Docs, got {other:?}"),
}
}
}
#[test]
fn docs_rejects_an_unknown_topic() {
use clap::error::ErrorKind;
let cmd = apply_config_defaults(Config::default(), Cli::command());
let err = cmd
.try_get_matches_from(["stakk", "docs", "nonsense"])
.unwrap_err();
assert_eq!(err.kind(), ErrorKind::InvalidValue);
}
#[test]
fn selection_flags_parse_and_accumulate() {
let cli = parse_with_config(
Config::default(),
&[
"stakk",
"submit",
"--keep",
"a",
"--keep",
"b",
"--new",
"r1=name1",
"--new",
"r2",
"--new-auto",
"r3",
"--new-command",
"r4",
],
);
let args = submit_args(&cli);
assert_eq!(args.keep, vec!["a", "b"]);
assert_eq!(args.new, vec!["r1=name1", "r2"]);
assert_eq!(args.new_auto, vec!["r3"]);
assert_eq!(args.new_command, vec!["r4"]);
}
#[test]
fn env_var_overrides_config() {
let config = Config {
remote: Some("from-config".into()),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit", "--remote", "from-cli"]);
assert_eq!(submit_args(&cli).remote, "from-cli");
}
#[test]
fn toml_deserialize_full() {
let toml_str = r#"
remote = "upstream"
github_host = "github.example.com"
pr_mode = "draft"
template_path = "/path/to/template.jinja"
stack_placement = "body"
sync_pr_content = "all"
trailers = "strip"
auto_prefix = "gb-"
bookmark_command = "my-command"
bookmarks_revset = "all()"
heads_revset = "heads(all())"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.remote.as_deref(), Some("upstream"));
assert_eq!(config.github_host.as_deref(), Some("github.example.com"));
assert_eq!(config.pr_mode, Some(PrMode::Draft));
assert_eq!(
config.template_path.as_deref(),
Some("/path/to/template.jinja"),
);
assert_eq!(config.stack_placement, Some(StackPlacement::Body));
assert_eq!(
config.sync_pr_content,
Some(crate::cli::submit::SyncPrContent::All),
);
assert_eq!(
config.trailers,
Some(crate::cli::submit::TrailerHandling::Strip),
);
assert_eq!(config.auto_prefix.as_deref(), Some("gb-"));
assert_eq!(config.bookmark_command.as_deref(), Some("my-command"));
assert_eq!(config.bookmarks_revset.as_deref(), Some("all()"));
assert_eq!(config.heads_revset.as_deref(), Some("heads(all())"));
}
#[test]
fn toml_deserialize_empty() {
let config: Config = toml::from_str("").unwrap();
assert!(config.remote.is_none());
assert!(config.pr_mode.is_none());
}
#[test]
fn toml_deserialize_partial() {
let config: Config = toml::from_str(r#"pr_mode = "regular""#).unwrap();
assert_eq!(config.pr_mode, Some(PrMode::Regular));
assert!(config.remote.is_none());
}
#[test]
fn toml_rejects_unknown_field() {
let result: Result<Config, _> = toml::from_str("bogus = 42");
assert!(result.is_err());
}
#[test]
fn toml_stack_placement_kebab_case() {
let config: Config = toml::from_str(r#"stack_placement = "comment""#).unwrap();
assert_eq!(config.stack_placement, Some(StackPlacement::Comment));
}
#[test]
fn toml_stack_placement_none() {
let config: Config = toml::from_str(r#"stack_placement = "none""#).unwrap();
assert_eq!(config.stack_placement, Some(StackPlacement::None));
}
#[test]
fn toml_stack_placement_ignore() {
let config: Config = toml::from_str(r#"stack_placement = "ignore""#).unwrap();
assert_eq!(config.stack_placement, Some(StackPlacement::Ignore));
}
#[test]
fn toml_stack_placement_invalid() {
let result: Result<Config, _> = toml::from_str(r#"stack_placement = "invalid""#);
assert!(result.is_err());
}
}