clipmem 0.1.0

macOS clipboard memory backed by SQLite and searchable from OpenClaw
Documentation
use std::fmt::Write;

use anyhow::Result;
use serde::Serialize;

use crate::db::SearchResults;
use crate::model::{
    CaptureStoreResult, ClipboardSnapshot, DoctorReport, SearchHit, SnapshotDetails,
};

pub(super) fn emit_json_or_text<T>(
    json: bool,
    value: &T,
    render_text: impl FnOnce(&T) -> String,
) -> Result<()>
where
    T: Serialize,
{
    if json {
        print_json(value)
    } else {
        print!("{}", render_text(value));
        Ok(())
    }
}

pub(super) fn render_capture_once_text(
    store: &CaptureStoreResult,
    snapshot: &ClipboardSnapshot,
) -> String {
    let mut out = String::new();
    let store_state = if store.inserted_new_snapshot() {
        "new content"
    } else {
        "already known content"
    };
    let _ = writeln!(
        out,
        "stored snapshot {} via event {} ({store_state})",
        store.snapshot_id(),
        store.event_id()
    );
    let _ = writeln!(out, "kind: {}", snapshot.snapshot_kind());
    let _ = writeln!(out, "bytes: {}", snapshot.total_bytes());
    if let Some(app) = snapshot.frontmost_app_name() {
        let _ = writeln!(out, "frontmost app: {app}");
    }
    if !snapshot.preview_text().is_empty() {
        let _ = writeln!(out, "preview: {}", snapshot.preview_text());
    }
    out
}

pub(super) fn render_hits_text(hits: &[SearchHit]) -> String {
    let mut out = String::new();
    for hit in hits {
        let app = hit
            .last_frontmost_app_name()
            .or(hit.last_frontmost_app_bundle_id())
            .unwrap_or("unknown app");

        let _ = writeln!(
            out,
            "[{}] {} · last seen {} · captures {} · {} · {} bytes · {} items",
            hit.snapshot_id(),
            hit.snapshot_kind(),
            format_utc_timestamp(hit.last_observed_at()),
            hit.capture_count(),
            app,
            hit.total_bytes(),
            hit.item_count()
        );

        if !hit.preview_text().is_empty() {
            let _ = writeln!(out, "  preview: {}", hit.preview_text());
        }
        if !hit.snippet().is_empty() && hit.snippet() != hit.preview_text() {
            let _ = writeln!(out, "  match:   {}", hit.snippet());
        }
        if let Some(score) = hit.score() {
            let _ = writeln!(out, "  score:   {score:.3}");
        }
        push_blank_line(&mut out);
    }
    out
}

pub(super) fn render_search_results_text(results: &SearchResults) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "search mode: {}", results.mode_used().as_str());
    if !results.hits().is_empty() {
        out.push('\n');
    }
    out.push_str(&render_hits_text(results.hits()));
    out
}

pub(super) fn render_snapshot_text(snapshot: &SnapshotDetails) -> String {
    let mut out = String::new();
    let _ = writeln!(
        out,
        "snapshot {} · sha256={} · kind={} · captures={}",
        snapshot.snapshot_id(),
        snapshot.sha256(),
        snapshot.snapshot_kind(),
        snapshot.capture_count()
    );
    let _ = writeln!(
        out,
        "first seen: {} · last seen: {}",
        format_utc_timestamp(snapshot.first_observed_at()),
        format_utc_timestamp(snapshot.last_observed_at())
    );
    let _ = writeln!(
        out,
        "bytes: {} · items: {}",
        snapshot.total_bytes(),
        snapshot.item_count()
    );
    if let Some(app) = snapshot
        .last_frontmost_app_name()
        .or(snapshot.last_frontmost_app_bundle_id())
    {
        let _ = writeln!(out, "last frontmost app: {app}");
    }
    if !snapshot.preview_text().is_empty() {
        let _ = writeln!(out, "preview: {}", snapshot.preview_text());
    }
    push_blank_line(&mut out);

    for item in snapshot.items() {
        let _ = writeln!(
            out,
            "item {} · kind={} · bytes={}{}",
            item.item_index(),
            item.primary_kind(),
            item.total_bytes(),
            item.primary_uti()
                .map(|uti| format!(" · primary type={uti}"))
                .unwrap_or_default()
        );
        if !item.preview_text().is_empty() {
            let _ = writeln!(out, "  preview: {}", item.preview_text());
        }
        for rep in item.representations() {
            let _ = writeln!(
                out,
                "  - {} · kind={} · {} bytes · sha256={}",
                rep.uti(),
                rep.kind(),
                rep.byte_len(),
                rep.raw_sha256()
            );
            if let Some(text) = rep.text_value() {
                let _ = writeln!(out, "    text: {text}");
            }
        }
        push_blank_line(&mut out);
    }

    if !snapshot.recent_events().is_empty() {
        let _ = writeln!(out, "recent events:");
        for event in snapshot.recent_events() {
            let source = event
                .frontmost_app_name()
                .or(event.frontmost_app_bundle_id())
                .unwrap_or("unknown app");
            let _ = writeln!(
                out,
                "  event {} · {} · change_count={} · {}",
                event.event_id(),
                format_utc_timestamp(event.observed_at()),
                event.change_count(),
                source
            );
        }
    }

    out
}

pub(super) fn render_doctor_text(report: &DoctorReport) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "database: {}", report.db_path());
    let _ = writeln!(out, "sqlite version: {}", report.sqlite_version());
    let _ = writeln!(out, "journal mode: {}", report.journal_mode());
    let _ = writeln!(
        out,
        "fts5 compile option present: {}",
        report.fts5_compile_option_present()
    );
    let _ = writeln!(
        out,
        "fts5 temp table creation works: {}",
        report.fts5_create_virtual_table_ok()
    );

    if !report.compile_options().is_empty() {
        out.push('\n');
        let _ = writeln!(out, "compile options:");
        for opt in report.compile_options() {
            let _ = writeln!(out, "  {opt}");
        }
    }

    out
}

fn print_json<T: Serialize>(value: &T) -> Result<()> {
    let json = serde_json::to_string_pretty(value)?;
    println!("{json}");
    Ok(())
}

fn push_blank_line(out: &mut String) {
    out.push('\n');
}

fn format_utc_timestamp(timestamp: &str) -> String {
    if timestamp.ends_with('Z')
        || timestamp.ends_with(" UTC")
        || timestamp.contains('+')
        || timestamp.rfind('-').is_some_and(|idx| idx > 9)
    {
        timestamp.to_string()
    } else {
        format!("{timestamp} UTC")
    }
}

#[cfg(test)]
mod tests {
    use crate::db::{SearchMode, SearchResults};
    use crate::model::{
        build_item, build_representation, build_snapshot, CaptureContext, CaptureEvent,
        CaptureStoreResult, DoctorReport, SearchHit, SnapshotDetails, SnapshotKind,
    };

    use super::{
        render_capture_once_text, render_doctor_text, render_hits_text, render_search_results_text,
        render_snapshot_text,
    };

    #[test]
    fn render_capture_once_text_reports_summary_lines() {
        let text = render_capture_once_text(
            &CaptureStoreResult::new(9, 12, true),
            &build_snapshot(
                CaptureContext::new(3)
                    .with_frontmost_app_name("Terminal")
                    .with_frontmost_app_bundle_id("com.apple.Terminal"),
                vec![build_item(
                    0,
                    vec![build_representation(
                        "public.utf8-plain-text".to_string(),
                        None,
                        b"git status".to_vec(),
                    )],
                )],
            ),
        );

        assert!(text.contains("stored snapshot 9 via event 12 (new content)"));
        assert!(text.contains("kind: plain_text"));
        assert!(text.contains("frontmost app: Terminal"));
        assert!(text.contains("preview: git status"));
    }

    #[test]
    fn render_hits_text_includes_snippet_and_score() {
        let text = render_hits_text(&[SearchHit::new(
            42,
            "deadbeef".to_string(),
            SnapshotKind::PlainText,
            "git status".to_string(),
            "git ⟦status⟧".to_string(),
            2,
            "2026-04-16 18:00:00".to_string(),
            Some("Terminal".to_string()),
            Some("com.apple.Terminal".to_string()),
            10,
            1,
            Some(0.125),
        )]);

        assert!(text.contains("[42] plain_text"));
        assert!(text.contains("2026-04-16 18:00:00 UTC"));
        assert!(text.contains("preview: git status"));
        assert!(text.contains("match:   git ⟦status⟧"));
        assert!(text.contains("score:   0.125"));
    }

    #[test]
    fn render_snapshot_text_lists_items_and_events() {
        let text = render_snapshot_text(&SnapshotDetails::new(
            7,
            "abc123".to_string(),
            SnapshotKind::PlainText,
            "git push".to_string(),
            "git push".to_string(),
            1,
            8,
            "2026-04-16 10:00:00".to_string(),
            2,
            "2026-04-16 10:00:00".to_string(),
            "2026-04-16 11:00:00".to_string(),
            Some("Terminal".to_string()),
            Some("com.apple.Terminal".to_string()),
            vec![CaptureEvent::new(
                21,
                "2026-04-16 11:00:00".to_string(),
                3,
                Some("Terminal".to_string()),
                Some("com.apple.Terminal".to_string()),
            )],
            vec![build_item(
                0,
                vec![build_representation(
                    "public.utf8-plain-text".to_string(),
                    None,
                    b"git push".to_vec(),
                )],
            )],
        ));

        assert!(text.contains("snapshot 7"));
        assert!(text.contains("first seen: 2026-04-16 10:00:00 UTC"));
        assert!(text.contains("last seen: 2026-04-16 11:00:00 UTC"));
        assert!(text.contains("preview: git push"));
        assert!(text.contains("item 0 · kind=plain_text"));
        assert!(text.contains("event 21 · 2026-04-16 11:00:00 UTC"));
    }

    #[test]
    fn render_doctor_text_lists_compile_options() {
        let text = render_doctor_text(&DoctorReport::new(
            "/tmp/clipmem.sqlite3".to_string(),
            "3.46.0".to_string(),
            "wal".to_string(),
            true,
            true,
            vec!["ENABLE_FTS5".to_string()],
        ));

        assert!(text.contains("database: /tmp/clipmem.sqlite3"));
        assert!(text.contains("sqlite version: 3.46.0"));
        assert!(text.contains("compile options:"));
        assert!(text.contains("ENABLE_FTS5"));
    }

    #[test]
    fn render_search_results_text_reports_effective_mode() {
        let text = render_search_results_text(&SearchResults::new(
            SearchMode::Literal,
            vec![SearchHit::new(
                42,
                "deadbeef".to_string(),
                SnapshotKind::PlainText,
                "git status".to_string(),
                "git status".to_string(),
                2,
                "2026-04-16 18:00:00".to_string(),
                Some("Terminal".to_string()),
                Some("com.apple.Terminal".to_string()),
                10,
                1,
                None,
            )],
        ));

        assert!(text.contains("search mode: literal"));
        assert!(text.contains("[42] plain_text"));
    }
}