use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use clipmem::archive::Database;
use clipmem::capture::{
build_item, build_representation, build_snapshot, CaptureContext, ClipboardSnapshot,
};
use serde_json::Value;
fn temp_db_path(test_name: &str) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos();
std::env::temp_dir()
.join("clipmem-cli-tests")
.join(format!("{test_name}-{}-{timestamp}.sqlite3", process::id()))
}
fn cleanup_db(path: &Path) {
for suffix in ["", "-shm", "-wal"] {
let candidate = if suffix.is_empty() {
path.to_path_buf()
} else {
PathBuf::from(format!("{}{suffix}", path.display()))
};
let _ = fs::remove_file(candidate);
}
if let Some(parent) = path.parent() {
let _ = fs::remove_dir(parent);
}
}
fn text_snapshot(change_count: i64, text: &str) -> ClipboardSnapshot {
let item = build_item(
0,
vec![build_representation(
"public.utf8-plain-text".to_string(),
None,
text.as_bytes().to_vec(),
)],
);
build_snapshot(
CaptureContext::new(change_count)
.with_frontmost_app_name("Terminal")
.with_frontmost_app_bundle_id("com.apple.Terminal"),
vec![item],
)
}
fn seed_database(path: &Path, snapshots: &[ClipboardSnapshot]) -> Result<Vec<i64>> {
let mut db = Database::open_or_init(path)?;
let mut ids = Vec::new();
for snapshot in snapshots {
let stored = db.store_capture(snapshot)?;
ids.push(stored.snapshot_id());
}
Ok(ids)
}
fn run_cli(args: &[&str]) -> process::Output {
Command::new(env!("CARGO_BIN_EXE_clipmem"))
.args(args)
.output()
.expect("clipmem binary should execute")
}
#[test]
fn search_command_prints_literal_results() -> Result<()> {
let path = temp_db_path("search-text");
seed_database(
&path,
&[
text_snapshot(1, "Discount: 50 percent off"),
text_snapshot(2, "Discount: 50%"),
],
)?;
let output = run_cli(&[
"--db",
path.to_str().expect("db path should be UTF-8"),
"search",
"--mode",
"literal",
"50%",
]);
let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8");
assert!(output.status.success());
assert!(stdout.contains("preview: Discount: 50%"));
assert!(!stdout.contains("Discount: 50 percent off"));
cleanup_db(&path);
Ok(())
}
#[test]
fn recent_command_emits_json_hits() -> Result<()> {
let path = temp_db_path("recent-json");
let ids = seed_database(&path, &[text_snapshot(1, "git status")])?;
let output = run_cli(&[
"--db",
path.to_str().expect("db path should be UTF-8"),
"recent",
"--json",
]);
let hits: Vec<Value> =
serde_json::from_slice(&output.stdout).expect("recent JSON output should parse");
assert!(output.status.success());
assert_eq!(hits.len(), 1);
assert_eq!(hits[0]["snapshot_id"].as_i64(), Some(ids[0]));
assert_eq!(hits[0]["preview_text"].as_str(), Some("git status"));
cleanup_db(&path);
Ok(())
}
#[test]
fn get_command_prints_snapshot_details() -> Result<()> {
let path = temp_db_path("get-text");
let ids = seed_database(
&path,
&[text_snapshot(1, "git clone https://example.com/repo")],
)?;
let output = run_cli(&[
"--db",
path.to_str().expect("db path should be UTF-8"),
"get",
&ids[0].to_string(),
]);
let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8");
assert!(output.status.success());
assert!(stdout.contains(&format!("snapshot {}", ids[0])));
assert!(stdout.contains("item 0 · kind=plain_text"));
assert!(stdout.contains("text: git clone https://example.com/repo"));
cleanup_db(&path);
Ok(())
}
#[test]
fn doctor_command_reports_database_capabilities() {
let path = temp_db_path("doctor-text");
let db = Database::open_or_init(&path).expect("test database should open");
drop(db);
let output = run_cli(&[
"--db",
path.to_str().expect("db path should be UTF-8"),
"doctor",
]);
let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8");
assert!(output.status.success());
assert!(stdout.contains("database:"));
assert!(stdout.contains("sqlite version:"));
assert!(stdout.contains("fts5 temp table creation works:"));
cleanup_db(&path);
}
#[test]
fn recent_command_rejects_zero_limit() {
let path = temp_db_path("recent-zero-limit");
let output = run_cli(&[
"--db",
path.to_str().expect("db path should be UTF-8"),
"recent",
"--limit",
"0",
]);
let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
assert!(!output.status.success());
assert!(stderr.contains('0'));
cleanup_db(&path);
}
#[test]
fn export_command_writes_raw_representation_bytes() -> Result<()> {
let path = temp_db_path("export-bytes");
let output_path = std::env::temp_dir()
.join(format!("clipmem-export-{}-{}.bin", process::id(), 1));
let snapshot = build_snapshot(
CaptureContext::new(1)
.with_frontmost_app_name("Preview")
.with_frontmost_app_bundle_id("com.apple.Preview"),
vec![build_item(
0,
vec![build_representation(
"public.png".to_string(),
None,
vec![0x89, b'P', b'N', b'G'],
)],
)],
);
let ids = seed_database(&path, &[snapshot])?;
let output = run_cli(&[
"--db",
path.to_str().expect("db path should be UTF-8"),
"export",
&ids[0].to_string(),
"--item",
"0",
"--uti",
"public.png",
"--out",
output_path.to_str().expect("output path should be UTF-8"),
]);
let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8");
let bytes = fs::read(&output_path)?;
assert!(output.status.success());
assert_eq!(bytes, vec![0x89, b'P', b'N', b'G']);
assert!(stdout.contains("snapshot="));
assert!(stdout.contains("uti=public.png"));
let _ = fs::remove_file(&output_path);
cleanup_db(&path);
Ok(())
}
#[test]
fn export_command_fails_for_unknown_representation() -> Result<()> {
let path = temp_db_path("export-missing");
let snapshot = text_snapshot(1, "git status");
let ids = seed_database(&path, &[snapshot])?;
let output = run_cli(&[
"--db",
path.to_str().expect("db path should be UTF-8"),
"export",
&ids[0].to_string(),
"--item",
"0",
"--uti",
"public.png",
"--out",
"/tmp/clipmem-missing.bin",
]);
let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
assert!(!output.status.success());
assert!(stderr.contains("representation not found"));
cleanup_db(&path);
Ok(())
}