pub mod auth;
pub mod graph;
pub mod submit;
use std::path::PathBuf;
use clap::Args;
use clap::Command;
use clap::Parser;
use clap::Subcommand;
use clap_complete::Shell;
use crate::cli::auth::AuthArgs;
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"))]
#[command(args_conflicts_with_subcommands = true)]
pub struct Cli {
#[arg(long, global = true, env = "STAKK_CONFIG", verbatim_doc_comment)]
pub config: Option<PathBuf>,
#[command(subcommand)]
pub command: Option<Commands>,
#[command(flatten)]
pub submit_args: SubmitArgs,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
Submit(Box<SubmitArgs>),
Auth(AuthArgs),
Show(ShowArgs),
Completions {
shell: Shell,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum ShowFormat {
#[default]
Pretty,
Json,
}
#[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_submit_and_graph_defaults(&config, cmd);
let config2 = config.clone();
let cmd = cmd.mut_subcommand("submit", |sub| {
apply_submit_and_graph_defaults(&config, sub)
});
cmd.mut_subcommand("show", |sub| apply_graph_defaults(&config2, sub))
}
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_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) = config.template {
cmd = set_default(cmd, "template", template);
}
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
}
fn apply_submit_and_graph_defaults(config: &Config, cmd: Command) -> Command {
let cmd = apply_submit_defaults(config, cmd);
apply_graph_defaults(config, cmd)
}
#[cfg(test)]
mod tests {
use clap::CommandFactory;
use clap::FromArgMatches;
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,
_ => &cli.submit_args,
}
}
use crate::cli::submit::PrMode;
#[test]
fn pr_mode_default_no_config() {
let cli = parse_with_config(Config::default(), &["stakk", "submit", "bm"]);
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", "bm"]);
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", "bm"]);
assert_eq!(submit_args(&cli).pr_mode(), PrMode::Regular);
}
#[test]
fn pr_mode_config_regular_cli_draft() {
let config = Config {
pr_mode: Some(PrMode::Regular),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "submit", "--draft", "bm"]);
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", "bm"]);
assert_eq!(submit_args(&cli).pr_mode(), PrMode::Regular);
}
#[test]
fn pr_mode_no_config_cli_draft_flag() {
let cli = parse_with_config(Config::default(), &["stakk", "submit", "--draft", "bm"]);
assert_eq!(submit_args(&cli).pr_mode(), PrMode::Draft);
}
#[test]
fn pr_mode_draft_flag_overrides_pr_mode_regular() {
let cli = parse_with_config(
Config::default(),
&["stakk", "submit", "--pr-mode", "regular", "--draft", "bm"],
);
assert_eq!(submit_args(&cli).pr_mode(), PrMode::Draft);
}
#[test]
fn pr_mode_toplevel_config_draft() {
let config = Config {
pr_mode: Some(PrMode::Draft),
..Default::default()
};
let cli = parse_with_config(config, &["stakk", "bm"]);
assert_eq!(submit_args(&cli).pr_mode(), PrMode::Draft);
}
#[test]
fn pr_mode_toplevel_cli_draft_flag() {
let cli = parse_with_config(Config::default(), &["stakk", "--draft", "bm"]);
assert_eq!(submit_args(&cli).pr_mode(), PrMode::Draft);
}
#[test]
fn remote_default_no_config() {
let cli = parse_with_config(Config::default(), &["stakk", "submit", "bm"]);
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", "bm"]);
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", "bm"]);
assert_eq!(submit_args(&cli).remote, "other");
}
#[test]
fn stack_placement_default_no_config() {
let cli = parse_with_config(Config::default(), &["stakk", "submit", "bm"]);
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", "bm"]);
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", "bm"],
);
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", "bm"]);
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", "bm"],
);
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", "bm"]);
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", "bm"]);
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", "bm"],
);
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", "bm"]);
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", "bm"]);
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", "bm"]);
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", "bm"]);
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-", "bm"]);
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", "bm"]);
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", "bm"]);
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()", "bm"],
);
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 selection_flags_parse_and_accumulate() {
let cli = parse_with_config(
Config::default(),
&[
"stakk",
"submit",
"--keep",
"a",
"--keep",
"b",
"--keep-all",
"--new",
"r1=name1",
"--new",
"r2",
"--new-auto",
"r3",
"--new-command",
"r4",
],
);
let args = submit_args(&cli);
assert_eq!(args.keep, vec!["a", "b"]);
assert!(args.keep_all);
assert_eq!(args.new, vec!["r1=name1", "r2"]);
assert_eq!(args.new_auto, vec!["r3"]);
assert_eq!(args.new_command, vec!["r4"]);
}
#[test]
fn selection_flags_conflict_with_positional_bookmark() {
use clap::error::ErrorKind;
for flag_args in [
vec!["--keep", "a"],
vec!["--keep-all"],
vec!["--new", "r"],
vec!["--new-auto", "r"],
vec!["--new-command", "r"],
] {
let mut argv = vec!["stakk", "submit", "my-bookmark"];
argv.extend(&flag_args);
let cmd = apply_config_defaults(Config::default(), Cli::command());
let err = cmd.try_get_matches_from(argv).unwrap_err();
assert_eq!(
err.kind(),
ErrorKind::ArgumentConflict,
"flags {flag_args:?} must conflict with the positional bookmark",
);
}
}
#[test]
fn selection_flags_parse_at_top_level() {
let cli = parse_with_config(
Config::default(),
&["stakk", "--keep", "a", "--new", "r=n", "--keep-all"],
);
assert!(cli.command.is_none());
assert_eq!(cli.submit_args.keep, vec!["a"]);
assert_eq!(cli.submit_args.new, vec!["r=n"]);
assert!(cli.submit_args.keep_all);
}
#[test]
fn selection_flags_conflict_at_top_level() {
use clap::error::ErrorKind;
let cmd = apply_config_defaults(Config::default(), Cli::command());
let err = cmd
.try_get_matches_from(["stakk", "my-bookmark", "--keep", "a"])
.unwrap_err();
assert_eq!(err.kind(), ErrorKind::ArgumentConflict);
}
#[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", "bm"]);
assert_eq!(submit_args(&cli).remote, "from-cli");
}
#[test]
fn toml_deserialize_full() {
let toml_str = r#"
remote = "upstream"
pr_mode = "draft"
template = "/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.pr_mode, Some(PrMode::Draft));
assert_eq!(config.template.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());
}
}