use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
mod config;
mod ground;
mod hook;
mod index;
mod init_repo;
pub use config::{
ConfigDownloadArgs, ConfigInitArgs, ConfigShowArgs, ConfigValidateArgs, cmd_config_download,
cmd_config_init, cmd_config_show, cmd_config_validate,
};
pub use ground::{GroundArgs, cmd_ground, run_ground};
pub use hook::{HookArgs, cmd_hook_install, cmd_hook_uninstall};
pub use index::{
AD_HOC_CORPUS_NAME, CorpusReport, IndexArgs, IndexReport, cmd_index, run_index, select_corpora,
};
pub use init_repo::{InitRepoArgs, cmd_init_repo};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
pub enum FormatArg {
#[default]
Outline,
Json,
JsonPretty,
}
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Index(IndexCli),
Ground(GroundCli),
Hook {
#[command(subcommand)]
action: HookAction,
},
InitRepo(InitRepoCli),
Config {
#[command(subcommand)]
action: ConfigAction,
},
Serve,
Daemon(DaemonCli),
}
#[derive(Debug, Args)]
pub struct InitRepoCli {
pub name: String,
#[arg(long, value_name = "PATH")]
pub path: Option<PathBuf>,
#[arg(long)]
pub force: bool,
}
impl From<InitRepoCli> for InitRepoArgs {
fn from(cli: InitRepoCli) -> Self {
Self {
name: cli.name,
path: cli.path,
force: cli.force,
}
}
}
#[derive(Debug, Args)]
pub struct DaemonCli {
#[command(subcommand)]
pub action: Option<DaemonAction>,
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
}
#[derive(Debug, Subcommand, Clone, PartialEq, Eq)]
pub enum DaemonAction {
Run,
Stop,
Restart,
Status,
}
impl From<DaemonCli> for crate::app::daemon::DaemonArgs {
fn from(cli: DaemonCli) -> Self {
Self { config: cli.config }
}
}
#[derive(Debug, Args)]
pub struct IndexCli {
#[arg(long)]
pub corpus: Option<String>,
#[arg(long, value_name = "FILE", hide = true)]
pub paths_from: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
#[arg(long)]
pub strict: bool,
}
impl From<IndexCli> for IndexArgs {
fn from(cli: IndexCli) -> Self {
Self {
corpus: cli.corpus,
paths_from: cli.paths_from,
config: cli.config,
socket: cli.socket,
strict: cli.strict,
}
}
}
#[derive(Debug, Args)]
pub struct GroundCli {
pub query: String,
#[arg(long)]
pub corpus: Option<String>,
#[arg(long, value_enum, default_value_t = FormatArg::Outline, conflicts_with = "full")]
pub format: FormatArg,
#[arg(long, conflicts_with = "format")]
pub full: bool,
#[arg(long, value_name = "N")]
pub snippet_chars: Option<usize>,
#[arg(long, value_name = "N")]
pub top_files: Option<usize>,
#[arg(long, value_name = "N")]
pub chunks_per_file: Option<usize>,
#[arg(long, value_name = "N")]
pub limit: Option<usize>,
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl From<GroundCli> for GroundArgs {
fn from(cli: GroundCli) -> Self {
use crate::domain::ground::Format;
let format = if cli.full {
Format::JsonPretty
} else {
match cli.format {
FormatArg::Outline => Format::Outline,
FormatArg::Json => Format::Json,
FormatArg::JsonPretty => Format::JsonPretty,
}
};
Self {
query: cli.query,
corpus: cli.corpus,
format,
snippet_chars: cli.snippet_chars,
top_files: cli.top_files,
chunks_per_file: cli.chunks_per_file,
limit: cli.limit,
config: cli.config,
socket: cli.socket,
}
}
}
#[derive(Debug, Subcommand)]
pub enum HookAction {
Install {
#[arg(long, value_name = "PATH")]
repo: Option<PathBuf>,
},
Uninstall {
#[arg(long, value_name = "PATH")]
repo: Option<PathBuf>,
},
}
#[derive(Debug, Subcommand)]
pub enum ConfigAction {
Init {
#[arg(long)]
force: bool,
#[arg(long, value_name = "PATH")]
path: Option<PathBuf>,
},
Show {
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
cwd: Option<PathBuf>,
},
Download {
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
},
Validate {
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
cwd: Option<PathBuf>,
},
}
pub async fn dispatch(cli: Cli) -> anyhow::Result<()> {
match cli.command {
Command::Index(args) => cmd_index(args.into()).await,
Command::Ground(args) => cmd_ground(args.into()).await,
Command::Hook { action } => match action {
HookAction::Install { repo } => cmd_hook_install(HookArgs { repo }),
HookAction::Uninstall { repo } => cmd_hook_uninstall(HookArgs { repo }),
},
Command::InitRepo(args) => cmd_init_repo(args.into()),
Command::Config { action } => match action {
ConfigAction::Init { force, path } => cmd_config_init(ConfigInitArgs { force, path }),
ConfigAction::Show { config, cwd } => cmd_config_show(ConfigShowArgs { config, cwd }),
ConfigAction::Download { config } => cmd_config_download(ConfigDownloadArgs { config }),
ConfigAction::Validate { config, cwd } => {
cmd_config_validate(ConfigValidateArgs { config, cwd })
}
},
Command::Serve => {
crate::app::daemon::ensure_daemon_running().await?;
crate::adapters::mcp::serve_stdio().await
}
Command::Daemon(args) => {
let action = args.action.clone().unwrap_or(DaemonAction::Run);
match action {
DaemonAction::Run => crate::app::daemon::run_daemon(args.into()).await,
DaemonAction::Stop => {
crate::app::daemon::stop().await?;
println!("daemon stopped");
Ok(())
}
DaemonAction::Restart => {
crate::app::daemon::restart().await?;
println!("daemon restarted");
Ok(())
}
DaemonAction::Status => {
match crate::app::daemon::status().await? {
crate::app::daemon::DaemonStatus::Running => println!("running"),
crate::app::daemon::DaemonStatus::NotRunning => println!("not running"),
}
Ok(())
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_index_subcommand() {
let cli = Cli::try_parse_from(["hallouminate", "index"]).expect("parse index");
assert!(matches!(cli.command, Command::Index(_)));
}
#[test]
fn parses_index_with_corpus_and_paths_from() {
let cli = Cli::try_parse_from([
"hallouminate",
"index",
"--corpus",
"docs",
"--paths-from",
"/tmp/p.txt",
])
.expect("parse");
match cli.command {
Command::Index(args) => {
assert_eq!(args.corpus.as_deref(), Some("docs"));
assert_eq!(args.paths_from, Some(PathBuf::from("/tmp/p.txt")));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn parses_ground_subcommand_with_query_and_outline_default() {
let cli =
Cli::try_parse_from(["hallouminate", "ground", "spice melange"]).expect("parse ground");
match cli.command {
Command::Ground(args) => {
assert_eq!(args.query, "spice melange");
assert!(!args.full);
assert_eq!(args.format, FormatArg::Outline);
assert_eq!(args.corpus, None);
assert_eq!(args.snippet_chars, None);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn parses_ground_with_format_json_flag() {
let cli = Cli::try_parse_from(["hallouminate", "ground", "q", "--format", "json"])
.expect("parse");
match cli.command {
Command::Ground(args) => {
assert_eq!(args.format, FormatArg::Json);
assert!(!args.full);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn parses_ground_with_full_flag() {
let cli = Cli::try_parse_from(["hallouminate", "ground", "q", "--full"]).expect("parse");
match cli.command {
Command::Ground(args) => {
assert!(args.full);
let ga: GroundArgs = args.into();
assert_eq!(ga.format, crate::domain::ground::Format::JsonPretty);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn parses_ground_with_snippet_chars() {
let cli = Cli::try_parse_from(["hallouminate", "ground", "q", "--snippet-chars", "80"])
.expect("parse");
match cli.command {
Command::Ground(args) => assert_eq!(args.snippet_chars, Some(80)),
_ => panic!("wrong variant"),
}
}
#[test]
fn parses_ground_with_overrides() {
let cli = Cli::try_parse_from([
"hallouminate",
"ground",
"tokio",
"--corpus",
"docs",
"--format",
"json-pretty",
"--top-files",
"5",
"--chunks-per-file",
"2",
"--limit",
"20",
])
.expect("parse ground with flags");
match cli.command {
Command::Ground(args) => {
assert_eq!(args.query, "tokio");
assert_eq!(args.corpus.as_deref(), Some("docs"));
assert_eq!(args.format, FormatArg::JsonPretty);
assert_eq!(args.top_files, Some(5));
assert_eq!(args.chunks_per_file, Some(2));
assert_eq!(args.limit, Some(20));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn rejects_ground_without_query() {
let err = Cli::try_parse_from(["hallouminate", "ground"]).expect_err("query required");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn rejects_full_and_format_together() {
let err =
Cli::try_parse_from(["hallouminate", "ground", "q", "--full", "--format", "json"])
.expect_err("conflicting flags must be rejected");
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn rejects_removed_pretty_flag() {
let err = Cli::try_parse_from(["hallouminate", "ground", "q", "--pretty"])
.expect_err("--pretty must be rejected as unknown");
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn parses_daemon_subcommand_without_args() {
let cli = Cli::try_parse_from(["hallouminate", "daemon"]).expect("parse daemon");
match cli.command {
Command::Daemon(args) => {
assert!(args.config.is_none(), "--config defaults to None");
let inner: crate::app::daemon::DaemonArgs = args.into();
assert!(inner.config.is_none());
}
_ => panic!("wrong variant"),
}
}
#[test]
fn parses_daemon_subcommand_with_config_flag() {
let cli = Cli::try_parse_from(["hallouminate", "daemon", "--config", "/tmp/cfg.toml"])
.expect("parse daemon --config");
match cli.command {
Command::Daemon(args) => {
assert_eq!(args.config, Some(PathBuf::from("/tmp/cfg.toml")));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn parses_hook_install_and_uninstall() {
let install =
Cli::try_parse_from(["hallouminate", "hook", "install"]).expect("parse hook install");
match install.command {
Command::Hook {
action: HookAction::Install { repo },
} => assert_eq!(repo, None),
other => panic!("wrong variant: {other:?}"),
}
let uninstall = Cli::try_parse_from(["hallouminate", "hook", "uninstall"])
.expect("parse hook uninstall");
match uninstall.command {
Command::Hook {
action: HookAction::Uninstall { repo },
} => assert_eq!(repo, None),
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn parses_hook_install_with_repo_path() {
let cli = Cli::try_parse_from(["hallouminate", "hook", "install", "--repo", "/tmp/r"])
.expect("parse hook install --repo");
match cli.command {
Command::Hook {
action: HookAction::Install { repo },
} => assert_eq!(repo, Some(PathBuf::from("/tmp/r"))),
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn parses_config_init_and_show() {
let init =
Cli::try_parse_from(["hallouminate", "config", "init"]).expect("parse config init");
match init.command {
Command::Config {
action: ConfigAction::Init { force, path },
} => {
assert!(!force);
assert_eq!(path, None);
}
other => panic!("wrong variant: {other:?}"),
}
let show =
Cli::try_parse_from(["hallouminate", "config", "show"]).expect("parse config show");
match show.command {
Command::Config {
action: ConfigAction::Show { config, cwd: _ },
} => assert_eq!(config, None),
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn parses_config_init_with_force_and_path() {
let cli = Cli::try_parse_from([
"hallouminate",
"config",
"init",
"--force",
"--path",
"/tmp/cfg.toml",
])
.expect("parse");
match cli.command {
Command::Config {
action: ConfigAction::Init { force, path },
} => {
assert!(force);
assert_eq!(path, Some(PathBuf::from("/tmp/cfg.toml")));
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn parses_config_show_with_explicit_config_path() {
let cli =
Cli::try_parse_from(["hallouminate", "config", "show", "--config", "/tmp/c.toml"])
.expect("parse");
match cli.command {
Command::Config {
action: ConfigAction::Show { config, cwd: _ },
} => assert_eq!(config, Some(PathBuf::from("/tmp/c.toml"))),
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn parses_config_download_with_explicit_config_path() {
let cli = Cli::try_parse_from([
"hallouminate",
"config",
"download",
"--config",
"/tmp/c.toml",
])
.expect("parse");
match cli.command {
Command::Config {
action: ConfigAction::Download { config },
} => assert_eq!(config, Some(PathBuf::from("/tmp/c.toml"))),
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn rejects_missing_subcommand() {
let err = Cli::try_parse_from(["hallouminate"]).expect_err("requires subcommand");
assert_eq!(
err.kind(),
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
);
}
#[test]
fn rejects_unknown_hook_action() {
let err = Cli::try_parse_from(["hallouminate", "hook", "frobnicate"])
.expect_err("unknown hook action");
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
}
#[test]
fn parses_init_repo_with_name_only() {
let cli = Cli::try_parse_from(["hallouminate", "init-repo", "demo"]).expect("parse");
match cli.command {
Command::InitRepo(args) => {
assert_eq!(args.name, "demo");
assert_eq!(args.path, None);
assert!(!args.force);
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn parses_init_repo_with_path_and_force() {
let cli = Cli::try_parse_from([
"hallouminate",
"init-repo",
"demo",
"--path",
"/tmp/repo",
"--force",
])
.expect("parse");
match cli.command {
Command::InitRepo(args) => {
assert_eq!(args.name, "demo");
assert_eq!(args.path, Some(PathBuf::from("/tmp/repo")));
assert!(args.force);
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn rejects_init_repo_without_name() {
let err = Cli::try_parse_from(["hallouminate", "init-repo"]).expect_err("name required");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
}