Skip to main content

kindling/
lib.rs

1//! kindling CLI commands.
2//!
3//! Defines the `clap` command tree ([`Cli`]) and handlers for the 12 CLI verbs.
4//! The default execution mode is **in-process** via [`kindling_service`]; the
5//! global `--via-daemon` flag switches the daemon-backed verbs (log, capsule
6//! open/close, search, pin, unpin, forget) to route through [`kindling_client`]
7//! for safe concurrent use alongside other kindling tools.
8//!
9//! `export`/`import`/`status`/`list`/`init` are always in-process (they operate
10//! on the DB file directly or have no daemon endpoint). `serve` starts the UDS
11//! daemon via [`kindling_server::serve`].
12//!
13//! This library backs the single `kindling` binary (`src/main.rs`); the hook
14//! surface is folded in as [`mod@hook`]. The CLI dispatch is exposed via
15//! [`cli_main`] so it stays integration-testable.
16
17mod cli;
18mod commands;
19pub mod hook;
20mod output;
21
22pub use cli::{
23    BrowseArgs, CapsuleCloseArgs, CapsuleCommand, CapsuleOpenArgs, Cli, Command, CommonOpts,
24    DemoArgs, ExportArgs, ForgetArgs, ImportArgs, InitArgs, ListArgs, LogArgs, PinArgs, SearchArgs,
25    ServeArgs, SpoolCommand, SpoolStatusArgs, StatusArgs, UnpinArgs,
26};
27
28use std::path::PathBuf;
29
30use kindling_service::KindlingService;
31
32/// CLI-level errors. Most variants wrap a lower-layer error; the CLI surface
33/// converts these to a printed message + non-zero exit code.
34#[derive(Debug, thiserror::Error)]
35pub enum CliError {
36    #[error(transparent)]
37    Service(#[from] kindling_service::ServiceError),
38
39    #[error(transparent)]
40    Store(#[from] kindling_store::StoreError),
41
42    #[error(transparent)]
43    Client(#[from] kindling_client::ClientError),
44
45    #[error(transparent)]
46    Spool(#[from] kindling_client::SpoolError),
47
48    #[error(transparent)]
49    Server(#[from] kindling_server::ServerError),
50
51    #[error(transparent)]
52    Io(#[from] std::io::Error),
53
54    #[error(transparent)]
55    Json(#[from] serde_json::Error),
56
57    /// A raw SQLite failure (the `list` verb's raw-row reads use rusqlite
58    /// directly to reproduce the TS CLI's raw-column JSON shape).
59    #[error("sqlite error: {0}")]
60    Sqlite(#[from] rusqlite::Error),
61
62    /// A bad argument value (e.g. unknown observation kind / entity / pin type),
63    /// matching the messages the TS commands throw.
64    #[error("{0}")]
65    Invalid(String),
66}
67
68/// A CLI result. The `Ok` payload is the process exit code (0 = success).
69pub type CliResult = Result<(), CliError>;
70
71/// Resolve the database path for an in-process command.
72///
73/// Resolution order (documented in the PORT-012 brief):
74/// 1. an explicit `--db <path>`;
75/// 2. the `KINDLING_DB_PATH` environment override;
76/// 3. the per-project default `project_db_path(default_kindling_home(), cwd)`
77///    via [`kindling_store::resolve_db_path`].
78///
79/// This intentionally prefers the per-project store layout over the TS
80/// single-DB default (`~/.kindling/kindling.db`): the Rust daemon model is
81/// per-project, and matching `resolve_db_path` keeps the CLI and daemon pointed
82/// at the same database for a given project root.
83pub fn resolve_db_path(explicit: Option<&str>) -> Result<PathBuf, CliError> {
84    if let Some(path) = explicit {
85        return Ok(PathBuf::from(path));
86    }
87    let cwd = std::env::current_dir()?;
88    let project_root = cwd.to_string_lossy();
89    kindling_store::resolve_db_path(&project_root).ok_or_else(|| {
90        CliError::Invalid(
91            "could not resolve a database path: no --db, no KINDLING_DB_PATH, \
92             and no home directory (HOME/USERPROFILE) to derive a per-project path"
93                .to_string(),
94        )
95    })
96}
97
98/// Open an in-process service at the resolved DB path, creating the parent
99/// directory if needed.
100pub fn open_service(explicit_db: Option<&str>) -> Result<(KindlingService, PathBuf), CliError> {
101    let path = resolve_db_path(explicit_db)?;
102    if let Some(parent) = path.parent() {
103        if !parent.as_os_str().is_empty() {
104            std::fs::create_dir_all(parent)?;
105        }
106    }
107    let service = KindlingService::open(&path)?;
108    Ok((service, path))
109}
110
111/// Parse args from the process and execute, returning the desired exit code.
112///
113/// The bin (`main.rs`) calls this and maps the result onto its exit code.
114pub fn cli_main() -> i32 {
115    let cli = <Cli as clap::Parser>::parse();
116    run(cli)
117}
118
119/// Execute a parsed [`Cli`], printing output and returning an exit code.
120///
121/// Each command handler does its own printing (text or `--json`); on error we
122/// print in the same `Error:` / `{"error":…}` shape the TS CLI used and return
123/// exit code 1, matching `handleError`'s `process.exit(1)`.
124pub fn run(cli: Cli) -> i32 {
125    let as_json = json_flag(&cli.command);
126    match dispatch(cli) {
127        Ok(()) => 0,
128        Err(err) => {
129            output::print_error(&err.to_string(), as_json);
130            1
131        }
132    }
133}
134
135/// Whether the selected command was invoked with `--json` (so errors print in
136/// JSON too, matching the TS `handleError(error, options.json)`).
137fn json_flag(command: &Command) -> bool {
138    match command {
139        Command::Init(a) => a.json,
140        Command::Log(a) => a.common.json,
141        Command::Capsule(CapsuleCommand::Open(a)) => a.common.json,
142        Command::Capsule(CapsuleCommand::Close(a)) => a.common.json,
143        Command::Status(a) => a.common.json,
144        Command::Search(a) => a.common.json,
145        Command::List(a) => a.common.json,
146        Command::Pin(a) => a.common.json,
147        Command::Unpin(a) => a.common.json,
148        Command::Forget(a) => a.common.json,
149        Command::Export(a) => a.common.json,
150        Command::Import(a) => a.common.json,
151        Command::Serve(_) => false,
152        Command::Demo(a) => a.common.json,
153        Command::Browse(a) => a.common.json,
154        Command::Spool(SpoolCommand::Status(a)) => a.json,
155    }
156}
157
158fn dispatch(cli: Cli) -> CliResult {
159    let via_daemon = cli.via_daemon;
160    match cli.command {
161        Command::Init(args) => commands::init::run(args),
162        Command::Log(args) => commands::log::run(args, via_daemon),
163        Command::Capsule(CapsuleCommand::Open(args)) => {
164            commands::capsule::run_open(args, via_daemon)
165        }
166        Command::Capsule(CapsuleCommand::Close(args)) => {
167            commands::capsule::run_close(args, via_daemon)
168        }
169        Command::Status(args) => commands::status::run(args),
170        Command::Search(args) => commands::search::run(args, via_daemon),
171        Command::List(args) => commands::list::run(args),
172        Command::Pin(args) => commands::pin::run_pin(args, via_daemon),
173        Command::Unpin(args) => commands::pin::run_unpin(args, via_daemon),
174        Command::Forget(args) => commands::forget::run(args, via_daemon),
175        Command::Export(args) => commands::export::run_export(args, via_daemon),
176        Command::Import(args) => commands::export::run_import(args, via_daemon),
177        Command::Serve(args) => commands::serve::run(args),
178        Command::Demo(args) => commands::demo::run(args),
179        Command::Browse(args) => commands::browse::run(args),
180        Command::Spool(SpoolCommand::Status(args)) => commands::spool::run_status(args),
181    }
182}
183
184/// Build a daemon client honouring `--db` as a socket-routing project hint.
185///
186/// The client routes by project root (hashed into a per-project DB by the
187/// daemon), not by an explicit DB file, so `--via-daemon` + `--db` cannot point
188/// the daemon at an arbitrary file. We use the default socket + the current
189/// working directory as the project root (mirroring `ClientConfig::defaults`).
190pub(crate) fn build_client() -> Result<kindling_client::Client, CliError> {
191    Ok(kindling_client::Client::new()?)
192}
193
194/// A small single-threaded Tokio runtime for the async client/serve paths.
195pub(crate) fn runtime() -> std::io::Result<tokio::runtime::Runtime> {
196    tokio::runtime::Builder::new_current_thread()
197        .enable_all()
198        .build()
199}