Skip to main content

linkmarks_cli/
lib.rs

1//! `linkmarks-cli` — the LinkMarks command-line interface.
2//!
3//! Re-exports the CLI parsing, XDG paths resolution, and command
4//! dispatch. The [`run`] function is the canonical entry point that
5//! binaries (both the workspace-local `linkmarks` bin and any future
6//! embedding crate — including the `linkmarks` umbrella) call.
7//!
8//! ## Subcommands
9//!
10//! - `init`        — initialize the XDG store + config.
11//! - `list`        — list bookmarks deterministically.
12//! - `import`      — import from a bridge source.
13//! - `export`      — export to a sink format.
14//! - `dedupe`      — local deterministic dedupe by canonical URL.
15//! - `tui`         — launch the interactive terminal browser.
16//! - `completions` — emit a shell completion script (bash/zsh/fish/powershell/elvish).
17
18pub mod cmd;
19pub mod ui;
20
21use anyhow::{Context, Result};
22use clap::{CommandFactory, Parser, Subcommand};
23use std::path::PathBuf;
24use tracing_subscriber::EnvFilter;
25
26#[derive(Parser, Debug)]
27#[command(name = "linkmarks", version, about = "Local-first bookmark manager")]
28pub struct Cli {
29    /// Output format for the active command.
30    #[arg(long, global = true, default_value = "table")]
31    format: Format,
32
33    /// Verbosity. `-v` for info, `-vv` for debug.
34    #[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
35    verbose: u8,
36
37    /// Path to the SQLite store (defaults to XDG data dir).
38    #[arg(long, global = true, env = "LINKMARKS_STORE")]
39    store: Option<PathBuf>,
40
41    /// Path to the config file (defaults to XDG config dir).
42    #[arg(long, global = true, env = "LINKMARKS_CONFIG")]
43    config: Option<PathBuf>,
44
45    #[command(subcommand)]
46    command: Commands,
47}
48
49#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum Format {
51    #[default]
52    Table,
53    Json,
54    Yaml,
55}
56
57impl Format {
58    #[allow(dead_code)]
59    fn as_str(self) -> &'static str {
60        match self {
61            Self::Table => "table",
62            Self::Json => "json",
63            Self::Yaml => "yaml",
64        }
65    }
66}
67
68/// Resolved XDG paths. Built once per invocation and threaded into
69/// every subcommand that needs them.
70#[derive(Debug, Clone)]
71pub struct Paths {
72    /// Path to the SQLite store.
73    pub store: PathBuf,
74    /// Path to the config file.
75    pub config: PathBuf,
76}
77
78impl Paths {
79    /// Resolve from CLI flags + defaults.
80    pub fn resolve(cli: &Cli) -> Self {
81        let store = cli
82            .store
83            .clone()
84            .unwrap_or_else(linkmarks_core::paths::linkmarks_store_path);
85        let config = cli
86            .config
87            .clone()
88            .unwrap_or_else(linkmarks_core::paths::linkmarks_config_path);
89        Self { store, config }
90    }
91}
92
93#[derive(Subcommand, Debug)]
94enum Commands {
95    /// Initialize the LinkMarks store and config.
96    Init(cmd::init::InitArgs),
97    /// List bookmarks from a source or store.
98    List(cmd::list::ListArgs),
99    /// Import bookmarks from a source file.
100    Import(cmd::import::ImportArgs),
101    /// Export bookmarks to a sink format.
102    Export(cmd::export::ExportArgs),
103    /// Dedupe by canonical URL with a conflict report.
104    Dedupe(cmd::dedupe::DedupeArgs),
105    /// Launch the interactive terminal browser.
106    Tui(cmd::tui::TuiArgs),
107    /// Multi-device sync (preview: dry-run only).
108    Sync(cmd::sync::SyncArgs),
109    /// Emit a shell completion script to stdout.
110    Completions(cmd::completions::CompletionsArgs),
111}
112
113/// Build the canonical `Cli` command tree.
114///
115/// Public so `completions.rs` (and any future helper) can reach a
116/// mutable `Command` for `clap_complete::generate`. We construct
117/// from `Cli::command()` rather than `Cli::parse()` because parse
118/// also validates against `std::env::args()` (inconvenient in tests).
119pub fn build_cli() -> clap::Command {
120    Cli::command()
121}
122
123/// Run the LinkMarks CLI.
124///
125/// Returns the process exit code (see [`exit_codes`]). The caller
126/// (typically a 3-line `main` wrapper) is responsible for translating
127/// that into `std::process::exit`.
128pub fn run() -> Result<i32> {
129    init_tracing();
130    let cli = Cli::parse();
131    let paths = Paths::resolve(&cli);
132    dispatch(cli, paths).context("linkmarks command failed")
133}
134
135fn init_tracing() {
136    let filter =
137        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("linkmarks=warn,warn"));
138    let _ = tracing_subscriber::fmt()
139        .with_env_filter(filter)
140        .with_writer(std::io::stderr)
141        .try_init();
142}
143
144fn dispatch(cli: Cli, paths: Paths) -> Result<i32> {
145    match cli.command {
146        Commands::Init(args) => cmd::init::run(args, cli.format, paths),
147        Commands::List(args) => cmd::list::run(args, cli.format, paths),
148        Commands::Import(args) => cmd::import::run(args, cli.format, paths),
149        Commands::Export(args) => cmd::export::run(args, cli.format, paths),
150        Commands::Dedupe(args) => cmd::dedupe::run(args, cli.format, paths),
151        Commands::Tui(args) => cmd::tui::execute(args, paths),
152        Commands::Sync(args) => cmd::sync::run(args, cli.format, paths),
153        Commands::Completions(args) => cmd::completions::run(args, paths),
154    }
155}
156
157/// Exit codes per SPEC.md §Acceptance criteria (CLI summary).
158pub mod exit_codes {
159    /// Success.
160    pub const OK: i32 = 0;
161    /// Partial success or source error.
162    pub const PARTIAL: i32 = 1;
163    /// Invalid arguments.
164    pub const INVALID_ARGS: i32 = 2;
165    /// Dedupe conflicts found (non-fatal).
166    pub const DEDUPE_CONFLICTS: i32 = 3;
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use clap::Parser;
173
174    #[test]
175    fn format_round_trip() {
176        for f in [Format::Table, Format::Json, Format::Yaml] {
177            assert_eq!(f.as_str(), f.as_str());
178        }
179    }
180
181    #[test]
182    fn format_default_is_table() {
183        assert!(matches!(Format::default(), Format::Table));
184    }
185
186    #[test]
187    fn paths_default_to_xdg() {
188        let cli = Cli::try_parse_from(["linkmarks", "list"]).unwrap();
189        let p = Paths::resolve(&cli);
190        // The store path lives under the data dir.
191        assert!(
192            p.store
193                .starts_with(linkmarks_core::paths::linkmarks_data_dir()),
194            "store path {:?} not under data dir",
195            p.store
196        );
197        assert!(
198            p.config
199                .starts_with(linkmarks_core::paths::linkmarks_config_dir()),
200            "config path {:?} not under config dir",
201            p.config
202        );
203    }
204
205    #[test]
206    fn paths_override_via_flags() {
207        let cli = Cli::try_parse_from([
208            "linkmarks",
209            "--store",
210            "/tmp/lm.db",
211            "--config",
212            "/tmp/lm.toml",
213            "list",
214        ])
215        .unwrap();
216        let p = Paths::resolve(&cli);
217        assert_eq!(p.store, PathBuf::from("/tmp/lm.db"));
218        assert_eq!(p.config, PathBuf::from("/tmp/lm.toml"));
219    }
220}