use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use std::ffi::OsString;
use std::path::PathBuf;
use crate::db::SearchMode;
mod commands;
mod db_path;
mod output;
#[derive(Debug, Parser)]
#[command(name = "clipmem")]
#[command(version)]
#[command(about = "macOS clipboard memory backed by SQLite")]
struct Cli {
#[arg(long, global = true)]
db: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Watch(WatchArgs),
CaptureOnce(CaptureOnceArgs),
Search(SearchArgs),
Recent(RecentArgs),
Get(GetArgs),
Export(ExportArgs),
Doctor(DoctorArgs),
}
#[derive(Debug, Args)]
struct WatchArgs {
#[arg(long, default_value_t = 400)]
interval_ms: u64,
#[arg(long, default_value_t = false)]
quiet: bool,
#[arg(long, default_value_t = false)]
skip_initial: bool,
}
#[derive(Debug, Args)]
struct CaptureOnceArgs {
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Debug, Args)]
struct SearchArgs {
query: String,
#[arg(long, value_enum, default_value_t = SearchMode::Auto)]
mode: SearchMode,
#[arg(long, default_value_t = 10, value_parser = parse_bounded_limit)]
limit: usize,
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LimitParseError(String);
impl std::fmt::Display for LimitParseError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for LimitParseError {}
fn parse_bounded_limit(value: &str) -> Result<usize, LimitParseError> {
let parsed = value
.parse::<usize>()
.map_err(|_| LimitParseError(format!("invalid integer value '{value}'")))?;
if (1..=250).contains(&parsed) {
Ok(parsed)
} else {
Err(LimitParseError(format!(
"value must be between 1 and 250, got {parsed}"
)))
}
}
#[derive(Debug, Args)]
struct RecentArgs {
#[arg(long, default_value_t = 10, value_parser = parse_bounded_limit)]
limit: usize,
#[arg(long)]
hours: Option<u32>,
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Debug, Args)]
struct GetArgs {
snapshot_id: i64,
#[arg(long, default_value_t = 10, value_parser = parse_bounded_limit)]
events: usize,
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Debug, Args)]
struct ExportArgs {
snapshot_id: i64,
#[arg(long)]
item: usize,
#[arg(long)]
uti: String,
#[arg(long)]
out: PathBuf,
}
#[derive(Debug, Args)]
struct DoctorArgs {
#[arg(long, default_value_t = false)]
json: bool,
}
pub fn run() -> Result<()> {
run_from(std::env::args_os())
}
pub fn run_from<I, T>(args: I) -> Result<()>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let cli = Cli::try_parse_from(args)?;
let db_path = cli.db.unwrap_or_else(db_path::default_db_path);
commands::run_command(cli.command, &db_path)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use clap::Parser;
use crate::db::SearchMode;
use super::{Cli, Command};
#[test]
fn watch_command_parses_global_db_and_runtime_flags() {
let cli = Cli::parse_from([
"clipmem",
"--db",
"/tmp/clipmem.sqlite3",
"watch",
"--interval-ms",
"250",
"--quiet",
"--skip-initial",
]);
assert_eq!(cli.db, Some(PathBuf::from("/tmp/clipmem.sqlite3")));
match cli.command {
Command::Watch(args) => {
assert_eq!(args.interval_ms, 250);
assert!(args.quiet);
assert!(args.skip_initial);
}
other => panic!("expected watch command, got {other:?}"),
}
}
#[test]
fn search_command_parses_explicit_mode() {
let cli = Cli::parse_from(["clipmem", "search", "--mode", "literal", "50%"]);
match cli.command {
Command::Search(args) => {
assert!(matches!(args.mode, SearchMode::Literal));
assert_eq!(args.query, "50%");
}
other => panic!("expected search command, got {other:?}"),
}
}
#[test]
fn search_command_rejects_zero_limit() {
let result = Cli::try_parse_from(["clipmem", "search", "--limit", "0", "git"]);
assert!(result.is_err());
}
#[test]
fn export_command_parses_required_arguments() {
let cli = Cli::parse_from([
"clipmem",
"export",
"42",
"--item",
"1",
"--uti",
"public.png",
"--out",
"/tmp/clipmem.bin",
]);
match cli.command {
Command::Export(args) => {
assert_eq!(args.snapshot_id, 42);
assert_eq!(args.item, 1);
assert_eq!(args.uti, "public.png");
assert_eq!(args.out, PathBuf::from("/tmp/clipmem.bin"));
}
other => panic!("expected export command, got {other:?}"),
}
}
#[test]
fn get_command_rejects_zero_event_limit() {
let result = Cli::try_parse_from(["clipmem", "get", "42", "--events", "0"]);
assert!(result.is_err());
}
}