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 hallouminate_daemon::{CorpusReport, IndexReport};
pub use hook::{HookArgs, cmd_hook_install, cmd_hook_uninstall};
pub use index::{AD_HOC_CORPUS_NAME, IndexArgs, 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,
}
impl Cli {
pub(crate) fn logging_config_path(&self) -> Option<&std::path::Path> {
let Command::Daemon(args) = &self.command else {
return None;
};
match &args.action {
None => args.config.as_deref(),
Some(DaemonAction::Run) => args.config.as_deref(),
Some(DaemonAction::Stop) => None,
Some(DaemonAction::Restart) => None,
Some(DaemonAction::Status) => None,
}
}
}
#[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 hallouminate_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 hallouminate_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, startup: hallouminate_config::Config) -> 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 => {
hallouminate_daemon::ensure_daemon_running().await?;
crate::mcp::serve_stdio().await
}
Command::Daemon(args) => {
let action = args.action.clone().unwrap_or(DaemonAction::Run);
match action {
DaemonAction::Run => hallouminate_daemon::run_daemon(startup, args.into()).await,
DaemonAction::Stop => {
hallouminate_daemon::stop().await?;
println!("daemon stopped");
Ok(())
}
DaemonAction::Restart => {
hallouminate_daemon::restart().await?;
println!("daemon restarted");
Ok(())
}
DaemonAction::Status => {
match hallouminate_daemon::status().await? {
hallouminate_daemon::DaemonStatus::Running(report) => {
print!("{}", render_status_report(&report));
}
hallouminate_daemon::DaemonStatus::NotRunning => println!("not running"),
}
Ok(())
}
}
}
}
}
fn render_status_report(report: &hallouminate_daemon::StatusReport) -> String {
use hallouminate_daemon::{DebtLevel, LadderAction, TaskName, TaskState, TripState};
use std::fmt::Write as _;
fn task_name(task: TaskName) -> &'static str {
match task {
TaskName::Maintenance => "maintenance",
TaskName::CatchUp => "catch_up",
TaskName::WatcherPump => "watcher_pump",
TaskName::IdleExit => "idle_exit",
TaskName::Signal => "signal",
}
}
let mut out = String::from("running\n");
if report.per_task.is_empty() {
out.push_str("tasks: none reported\n");
} else {
out.push_str("tasks:\n");
for entry in &report.per_task {
let state = match entry.state {
TaskState::Alive => "alive",
TaskState::Stalled => "stalled",
};
let _ = writeln!(out, " {}: {state}", task_name(entry.task));
}
}
let debt = match report.debt {
DebtLevel::Ok => "ok",
DebtLevel::Soft => "soft",
DebtLevel::Hard => "hard",
};
let _ = writeln!(out, "debt: {debt}");
let _ = writeln!(out, "deferred maintenance passes: {}", report.defer_count);
let _ = writeln!(
out,
"watcher: events={} reindexes={} noop_reindexes={}",
report.watcher.events, report.watcher.reindexes, report.watcher.noop_reindexes,
);
match &report.trips {
TripState::None => out.push_str("last ladder trip: none\n"),
TripState::Tripped { action, at_secs } => {
let action = match action {
LadderAction::ForceMaintenance => "force_maintenance".to_string(),
LadderAction::RestartTask(task) => format!("restart_task({})", task_name(*task)),
LadderAction::WatchdogTrip => "watchdog_trip".to_string(),
};
let _ = writeln!(out, "last ladder trip: {action} at {at_secs}s uptime");
}
}
out
}
#[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, hallouminate_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: hallouminate_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);
}
#[test]
fn logging_config_path_uses_only_foreground_daemon_override() {
let daemon =
Cli::try_parse_from(["hallouminate", "daemon", "--config", "/tmp/logging.toml"])
.expect("parse foreground daemon config");
assert_eq!(
daemon.logging_config_path(),
Some(std::path::Path::new("/tmp/logging.toml"))
);
let index = Cli::try_parse_from(["hallouminate", "index", "--config", "/tmp/ignored.toml"])
.expect("parse index config");
assert_eq!(index.logging_config_path(), None);
}
#[test]
fn logging_config_path_ignores_daemon_lifecycle_subcommands() {
let explicit_run = Cli::try_parse_from([
"hallouminate",
"daemon",
"--config",
"/tmp/logging.toml",
"run",
])
.expect("parse explicit daemon run config");
assert_eq!(
explicit_run.logging_config_path(),
Some(std::path::Path::new("/tmp/logging.toml"))
);
for lifecycle_arg in ["stop", "restart", "status"] {
let cli = Cli::try_parse_from([
"hallouminate",
"daemon",
"--config",
"/tmp/logging.toml",
lifecycle_arg,
])
.unwrap_or_else(|e| panic!("parse daemon {lifecycle_arg} config: {e}"));
assert_eq!(
cli.logging_config_path(),
None,
"daemon {lifecycle_arg} must not consult the foreground --config override"
);
}
}
#[test]
fn daemon_status_render_covers_every_report_field() {
use hallouminate_daemon::{
DebtLevel, LadderAction, StatusReport, TaskName, TaskState, TaskStatus, TripState,
WatcherCounters,
};
let report = StatusReport {
per_task: vec![
TaskStatus {
task: TaskName::Maintenance,
state: TaskState::Alive,
},
TaskStatus {
task: TaskName::WatcherPump,
state: TaskState::Stalled,
},
],
debt: DebtLevel::Soft,
defer_count: 4,
watcher: WatcherCounters {
events: 12,
reindexes: 5,
noop_reindexes: 2,
},
trips: TripState::Tripped {
action: LadderAction::RestartTask(TaskName::WatcherPump),
at_secs: 321,
},
};
let rendered = render_status_report(&report);
let lines: Vec<&str> = rendered.lines().collect();
assert_eq!(
lines,
[
"running",
"tasks:",
" maintenance: alive",
" watcher_pump: stalled",
"debt: soft",
"deferred maintenance passes: 4",
"watcher: events=12 reindexes=5 noop_reindexes=2",
"last ladder trip: restart_task(watcher_pump) at 321s uptime",
],
);
}
#[test]
fn daemon_status_render_handles_empty_tasks_and_no_trip() {
use hallouminate_daemon::{DebtLevel, StatusReport, TripState, WatcherCounters};
let report = StatusReport {
per_task: Vec::new(),
debt: DebtLevel::Ok,
defer_count: 0,
watcher: WatcherCounters::default(),
trips: TripState::None,
};
let rendered = render_status_report(&report);
let lines: Vec<&str> = rendered.lines().collect();
assert_eq!(
lines,
[
"running",
"tasks: none reported",
"debt: ok",
"deferred maintenance passes: 0",
"watcher: events=0 reindexes=0 noop_reindexes=0",
"last ladder trip: none",
],
);
}
}