pub mod cmd;
pub mod ui;
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;
#[derive(Parser, Debug)]
#[command(name = "linkmarks", version, about = "Local-first bookmark manager")]
pub struct Cli {
#[arg(long, global = true, default_value = "table")]
format: Format,
#[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
verbose: u8,
#[arg(long, global = true, env = "LINKMARKS_STORE")]
store: Option<PathBuf>,
#[arg(long, global = true, env = "LINKMARKS_CONFIG")]
config: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Format {
#[default]
Table,
Json,
Yaml,
}
impl Format {
#[allow(dead_code)]
fn as_str(self) -> &'static str {
match self {
Self::Table => "table",
Self::Json => "json",
Self::Yaml => "yaml",
}
}
}
#[derive(Debug, Clone)]
pub struct Paths {
pub store: PathBuf,
pub config: PathBuf,
}
impl Paths {
pub fn resolve(cli: &Cli) -> Self {
let store = cli
.store
.clone()
.unwrap_or_else(linkmarks_core::paths::linkmarks_store_path);
let config = cli
.config
.clone()
.unwrap_or_else(linkmarks_core::paths::linkmarks_config_path);
Self { store, config }
}
}
#[derive(Subcommand, Debug)]
enum Commands {
Init(cmd::init::InitArgs),
List(cmd::list::ListArgs),
Import(cmd::import::ImportArgs),
Export(cmd::export::ExportArgs),
Dedupe(cmd::dedupe::DedupeArgs),
Tui(cmd::tui::TuiArgs),
Sync(cmd::sync::SyncArgs),
Completions(cmd::completions::CompletionsArgs),
}
pub fn build_cli() -> clap::Command {
Cli::command()
}
pub fn run() -> Result<i32> {
init_tracing();
let cli = Cli::parse();
let paths = Paths::resolve(&cli);
dispatch(cli, paths).context("linkmarks command failed")
}
fn init_tracing() {
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("linkmarks=warn,warn"));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.try_init();
}
fn dispatch(cli: Cli, paths: Paths) -> Result<i32> {
match cli.command {
Commands::Init(args) => cmd::init::run(args, cli.format, paths),
Commands::List(args) => cmd::list::run(args, cli.format, paths),
Commands::Import(args) => cmd::import::run(args, cli.format, paths),
Commands::Export(args) => cmd::export::run(args, cli.format, paths),
Commands::Dedupe(args) => cmd::dedupe::run(args, cli.format, paths),
Commands::Tui(args) => cmd::tui::execute(args, paths),
Commands::Sync(args) => cmd::sync::run(args, cli.format, paths),
Commands::Completions(args) => cmd::completions::run(args, paths),
}
}
pub mod exit_codes {
pub const OK: i32 = 0;
pub const PARTIAL: i32 = 1;
pub const INVALID_ARGS: i32 = 2;
pub const DEDUPE_CONFLICTS: i32 = 3;
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn format_round_trip() {
for f in [Format::Table, Format::Json, Format::Yaml] {
assert_eq!(f.as_str(), f.as_str());
}
}
#[test]
fn format_default_is_table() {
assert!(matches!(Format::default(), Format::Table));
}
#[test]
fn paths_default_to_xdg() {
let cli = Cli::try_parse_from(["linkmarks", "list"]).unwrap();
let p = Paths::resolve(&cli);
assert!(
p.store
.starts_with(linkmarks_core::paths::linkmarks_data_dir()),
"store path {:?} not under data dir",
p.store
);
assert!(
p.config
.starts_with(linkmarks_core::paths::linkmarks_config_dir()),
"config path {:?} not under config dir",
p.config
);
}
#[test]
fn paths_override_via_flags() {
let cli = Cli::try_parse_from([
"linkmarks",
"--store",
"/tmp/lm.db",
"--config",
"/tmp/lm.toml",
"list",
])
.unwrap();
let p = Paths::resolve(&cli);
assert_eq!(p.store, PathBuf::from("/tmp/lm.db"));
assert_eq!(p.config, PathBuf::from("/tmp/lm.toml"));
}
}