1pub 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 #[arg(long, global = true, default_value = "table")]
31 format: Format,
32
33 #[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
35 verbose: u8,
36
37 #[arg(long, global = true, env = "LINKMARKS_STORE")]
39 store: Option<PathBuf>,
40
41 #[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#[derive(Debug, Clone)]
71pub struct Paths {
72 pub store: PathBuf,
74 pub config: PathBuf,
76}
77
78impl Paths {
79 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 Init(cmd::init::InitArgs),
97 List(cmd::list::ListArgs),
99 Import(cmd::import::ImportArgs),
101 Export(cmd::export::ExportArgs),
103 Dedupe(cmd::dedupe::DedupeArgs),
105 Tui(cmd::tui::TuiArgs),
107 Sync(cmd::sync::SyncArgs),
109 Completions(cmd::completions::CompletionsArgs),
111}
112
113pub fn build_cli() -> clap::Command {
120 Cli::command()
121}
122
123pub 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
157pub mod exit_codes {
159 pub const OK: i32 = 0;
161 pub const PARTIAL: i32 = 1;
163 pub const INVALID_ARGS: i32 = 2;
165 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 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}