Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 145 additions & 144 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions cot-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ workspace = true

[dependencies]
anyhow.workspace = true
assert_cmd = { workspace = true, optional = true }
cargo_toml.workspace = true
cargo_metadata.workspace = true
chrono.workspace = true
Expand All @@ -43,15 +44,16 @@ syn.workspace = true
tracing.workspace = true
tracing-subscriber = { workspace = true, features = ["env-filter"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
serde_json = { workspace = true}
wait-timeout = { workspace = true }
tempfile = { workspace = true, optional = true }
wait-timeout.workspace = true

[dev-dependencies]
cot-cli = { path = ".", features = ["test_utils"] }
assert_cmd.workspace = true
insta.workspace = true
insta-cmd.workspace = true

trybuild.workspace = true

[features]
Expand Down
2 changes: 2 additions & 0 deletions cot-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use clap_verbosity_flag::Verbosity;
pub const PACKAGE_LONG_FLAG: &str = "--package";
pub const PACKAGE_SHORT_FLAG: &str = "-p";
pub const RELEASE_FLAG: &str = "--release";
pub const HELP_LONG_FLAG: &str = "--help";
pub const HELP_SHORT_FLAG: &str = "-h";
pub const BINARY_FLAG: &str = "--bin";
pub const BUILD_FLAG: &str = "--build";

Expand Down
341 changes: 340 additions & 1 deletion cot-cli/src/handlers.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
use std::collections::HashSet;
use std::ffi::OsString;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::PathBuf;

use anyhow::Context;
use clap::CommandFactory;
use cot::metadata::CommandMeta;
use cot::metadata::{ArgMeta, CommandMeta};
use cot::utils::cli::{StatusType, print_status_msg};

use crate::args::{
Expand Down Expand Up @@ -183,6 +184,112 @@ fn exec(proj: &ProjectBinary, args: &[OsString]) -> anyhow::Result<()> {
}
}

/// Build a fresh [`clap::Command`] and inject the project's subcommands into
/// it before printing.
pub fn handle_combined_help(
project: Option<&ProjectBinary>,
path: &[String],
) -> anyhow::Result<()> {
let cmd = combined_help_command(project);
let mut target = navigate_to(cmd, path);
target.print_help()?;
println!();
Ok(())
}

fn navigate_to(mut cmd: clap::Command, path: &[String]) -> clap::Command {
let mut bin_name = cmd.get_name().to_string();

for segment in path {
match cmd.find_subcommand(segment) {
Some(sub) => {
bin_name = format!("{bin_name} {segment}");
cmd = sub.clone();
}
None => break,
}
}

cmd.bin_name(bin_name)
}

fn combined_help_command(project: Option<&ProjectBinary>) -> clap::Command {
let mut cmd = Cli::command();

if let Some(proj) = project
&& let Some(meta) = &proj.metadata
{
let mut cmd_set: HashSet<String> = cmd
.get_subcommands()
.map(|sc| sc.get_name().to_string())
.collect();

for meta_cmd in &meta.commands {
if cmd_set.insert(meta_cmd.name.clone()) {
cmd = cmd.subcommand(build_clap_subcommand(meta_cmd));
} else {
// there's an existing command, let's merge them into one. For command
// collisions, metadata(such as name and about) of the command
// present in `cot-cli` will take precedence.
cmd = cmd.mut_subcommand(&meta_cmd.name, |mut sc| {
for sub in &meta_cmd.subcommands {
sc = sc.subcommand(build_clap_subcommand(sub));
}
sc
});
}
}
}

cmd
}

fn build_clap_subcommand(meta: &CommandMeta) -> clap::Command {
let mut cmd = clap::Command::new(&meta.name);

if let Some(about) = &meta.about {
cmd = cmd.about(about.clone());
}

for alias in &meta.aliases {
cmd = cmd.visible_alias(alias.clone());
}

for arg_meta in &meta.args {
cmd = cmd.arg(build_clap_arg(arg_meta));
}

for sub in &meta.subcommands {
cmd = cmd.subcommand(build_clap_subcommand(sub));
}

cmd
}

fn build_clap_arg(meta: &ArgMeta) -> clap::Arg {
let mut arg = clap::Arg::new(&meta.name).required(meta.required);

if meta.is_positional
&& let Some(vn) = &meta.value_name
{
arg = arg.value_name(vn.clone());
} else {
if let Some(long) = &meta.long {
arg = arg.long(long.clone());
}
if let Some(short) = meta.short {
arg = arg.short(short);
}
if !meta.takes_value {
arg = arg.action(clap::ArgAction::SetTrue);
}
}
if let Some(help) = &meta.help {
arg = arg.help(help.clone());
}
arg
}

fn generate_completions(shell: clap_complete::Shell, writer: &mut impl std::io::Write) {
clap_complete::generate(shell, &mut Cli::command(), "cot", writer);
}
Expand Down Expand Up @@ -457,4 +564,236 @@ mod tests {
fn command_path_exists_empty_path_is_true() {
assert!(command_path_exists(&[], &[]));
}

#[test]
fn build_clap_subcommand_preserves_about_aliases_and_nested_subcommands() {
let meta = CommandMeta {
name: "migration".to_string(),
about: Some("Migration commands".to_string()),
aliases: vec!["database".to_string()],
subcommands: vec![CommandMeta {
name: "rollback".to_string(),
about: Some("Rollback migrations".to_string()),
aliases: vec!["rbk".to_string()],
subcommands: vec![],
args: vec![],
}],
args: vec![],
};

let cmd = build_clap_subcommand(&meta);

assert_eq!(cmd.get_name(), "migration");
assert_eq!(cmd.get_about().unwrap().to_string(), "Migration commands");
assert!(cmd.get_all_aliases().any(|alias| alias == "database"));
let nested = cmd
.get_subcommands()
.find(|subcommand| subcommand.get_name() == "rollback")
.unwrap();
assert_eq!(
nested.get_about().unwrap().to_string(),
"Rollback migrations"
);
assert!(nested.get_all_aliases().any(|alias| alias == "rbk"));
}

#[test]
fn build_clap_arg_positional_required() {
let meta = ArgMeta {
name: "migration_name".to_string(),
long: None,
short: None,
help: Some("Migration to roll back to".to_string()),
required: true,
is_positional: true,
takes_value: true,
value_name: Some("MIGRATION_NAME".to_string()),
};

let arg = build_clap_arg(&meta);

assert!(arg.is_required_set());
assert!(arg.is_positional());
assert_eq!(arg.get_value_names().unwrap()[0].as_str(), "MIGRATION_NAME");
}

#[test]
fn build_clap_arg_boolean_flag_sets_true_action() {
let meta = ArgMeta {
name: "dry-run".to_string(),
long: Some("dry-run".to_string()),
short: None,
help: None,
required: false,
is_positional: false,
takes_value: false,
value_name: None,
};

let arg = build_clap_arg(&meta);

assert_eq!(arg.get_long(), Some("dry-run"));
}

#[test]
fn build_clap_arg_valued_flag_with_short_and_long() {
let meta = ArgMeta {
name: "app".to_string(),
long: Some("app".to_string()),
short: Some('a'),
help: Some("App name".to_string()),
required: false,
is_positional: false,
takes_value: true,
value_name: None,
};

let arg = build_clap_arg(&meta);

assert_eq!(arg.get_long(), Some("app"));
assert_eq!(arg.get_short(), Some('a'));
}

#[test]
fn combined_help_command_includes_project_commands_and_builtin_commands() {
let project = ProjectBinary {
path: PathBuf::from("target/debug/example"),
metadata: Some(cot::metadata::ProjectMetadata {
version: cot::metadata::METADATA_SCHEMA_VERSION,
binary_name: "example".to_string(),
commands: vec![CommandMeta {
name: "health".to_string(),
about: Some("Check the server health".to_string()),
aliases: vec![],
subcommands: vec![],
args: vec![],
}],
}),
};

let cmd = combined_help_command(Some(&project));

assert!(
cmd.get_subcommands()
.any(|subcommand| subcommand.get_name() == "new")
);
let health = cmd
.get_subcommands()
.find(|subcommand| subcommand.get_name() == "health")
.unwrap();
assert_eq!(
health.get_about().unwrap().to_string(),
"Check the server health"
);
}

#[test]
fn combined_help_command_merges_duplicate_subcommand_preserving_builtin_about() {
let project = ProjectBinary {
path: PathBuf::from("target/debug/example"),
metadata: Some(cot::metadata::ProjectMetadata {
version: cot::metadata::METADATA_SCHEMA_VERSION,
binary_name: "example".to_string(),
commands: vec![CommandMeta {
name: "migration".to_string(),
about: Some("Should not override cot-cli's about".to_string()),
aliases: vec![],
subcommands: vec![CommandMeta {
name: "rollback".to_string(),
about: Some("Rollback migrations".to_string()),
aliases: vec![],
subcommands: vec![],
args: vec![],
}],
args: vec![],
}],
}),
};

let cmd = combined_help_command(Some(&project));

let matches: Vec<_> = cmd
.get_subcommands()
.filter(|sc| sc.get_name() == "migration")
.collect();
assert_eq!(matches.len(), 1, "migration should not be duplicated");

let migration = matches[0];
assert_eq!(
migration.get_about().unwrap().to_string(),
"Manage migrations for a Cot project"
);
assert!(
migration
.get_subcommands()
.any(|sc| sc.get_name() == "rollback")
);
assert!(
migration
.get_subcommands()
.any(|sc| sc.get_name() == "list")
);
}

#[test]
fn navigate_to_returns_root_for_empty_path() {
let cmd = combined_help_command(None);
let target = navigate_to(cmd, &[]);
assert_eq!(target.get_name(), "cot");
}

#[test]
fn navigate_to_descends_into_known_subcommand() {
let cmd = combined_help_command(None);
let target = navigate_to(cmd, &["migration".to_string()]);
assert_eq!(target.get_name(), "migration");
assert_eq!(target.get_bin_name(), Some("cot migration"));
}

#[test]
fn navigate_to_stops_at_first_unknown_segment() {
let cmd = combined_help_command(None);
let target = navigate_to(cmd, &["migration".to_string(), "nonexistent".to_string()]);
assert_eq!(target.get_name(), "migration");
}

#[test]
fn navigate_to_descends_into_merged_binary_subcommand_with_args() {
let project = ProjectBinary {
path: PathBuf::from("target/debug/example"),
metadata: Some(cot::metadata::ProjectMetadata {
version: cot::metadata::METADATA_SCHEMA_VERSION,
binary_name: "example".to_string(),
commands: vec![CommandMeta {
name: "migration".to_string(),
about: None,
aliases: vec![],
subcommands: vec![CommandMeta {
name: "rollback".to_string(),
about: Some("Rollback migrations".to_string()),
aliases: vec![],
subcommands: vec![],
args: vec![ArgMeta {
name: "dry-run".to_string(),
long: Some("dry-run".to_string()),
short: None,
help: Some("Print the rollback plan".to_string()),
required: false,
is_positional: false,
takes_value: false,
value_name: None,
}],
}],
args: vec![],
}],
}),
};

let cmd = combined_help_command(Some(&project));
let target = navigate_to(cmd, &["migration".to_string(), "rollback".to_string()]);

assert_eq!(target.get_name(), "rollback");
assert_eq!(target.get_bin_name(), Some("cot migration rollback"));
assert!(target.get_arguments().any(|a| a.get_id() == "dry-run"));
}
}
Loading
Loading