textlog 0.1.9

macOS clipboard + OCR daemon exposed to Claude Code as an MCP server
//! Argument + result shapes for the `textlog__*` MCP tools.
//!
//! Inputs derive `JsonSchema` so rmcp can publish them via `tools/list`;
//! outputs derive `Serialize` so rmcp's `Json<T>` wrapper can ship them
//! back as structured content. Field naming/defaults match spec
//! §MCP Server & Tools.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

// ---- argument structs -------------------------------------------------

#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum KindFilter {
    Text,
    Image,
    /// Sentinel meaning "no kind filter".
    Any,
}

#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct GetRecentArgs {
    /// Number of captures to return. Defaults to 5; capped server-side
    /// at `mcp.max_recent` (default 100).
    #[serde(default = "default_recent_n")]
    pub n: u32,
    /// Optional capture-kind filter. `Any` (or omitted) returns all
    /// kinds.
    #[serde(default)]
    pub kind: Option<KindFilter>,
}

fn default_recent_n() -> u32 {
    5
}

#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct SearchArgs {
    /// FTS5-style query. Words are AND-ed. Use double-quotes for phrases.
    pub query: String,
    /// Max hits to return. Defaults to 20; capped at `mcp.max_search_limit`.
    #[serde(default = "default_search_limit")]
    pub limit: u32,
    /// Optional ISO 8601 lower bound — only return rows with `ts >= since`.
    #[serde(default)]
    pub since: Option<String>,
}

fn default_search_limit() -> u32 {
    20
}

#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct ListTodayArgs {
    #[serde(default)]
    pub kind: Option<KindFilter>,
}

#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct ClearSinceArgs {
    /// ISO 8601 timestamp. Every capture row with `ts >= ts` is deleted.
    pub ts: String,
}

#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct OcrImageArgs {
    /// Absolute filesystem path to the image file.
    pub path: String,
}

#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct GetCaptureArgs {
    /// SQLite row id of the capture (from `CaptureSummary.id`).
    pub id: i64,
    /// Character offset into `text` to start the slice. Defaults to 0.
    /// Use with `limit` to page through bodies that overflow the MCP
    /// per-tool token budget.
    #[serde(default)]
    pub offset: Option<usize>,
    /// Max characters of `text` to return. Defaults to 8000
    /// (≈2000 tokens). Capped server-side at 32000 (≈8000 tokens) to
    /// prevent single-call token blow-ups on huge captures.
    #[serde(default)]
    pub limit: Option<usize>,
}

/// Default character window when caller omits `limit`.
pub const GET_CAPTURE_DEFAULT_LIMIT: usize = 8000;
/// Hard ceiling on `limit` regardless of caller request.
pub const GET_CAPTURE_MAX_LIMIT: usize = 32000;

// ---- result structs ---------------------------------------------------

/// MCP requires tool outputs to be JSON objects (not bare arrays), so
/// list-style results are wrapped in a `captures` field.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct CaptureList {
    pub captures: Vec<CaptureSummary>,
}

#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct SearchResults {
    pub hits: Vec<SearchResult>,
}

#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct CaptureSummary {
    pub id: i64,
    pub ts: String,
    pub kind: String,
    pub sha256: String,
    pub size_bytes: usize,
    /// First N characters of the capture body (text or OCR result).
    /// Truncated to keep MCP responses small. When `truncated` is true,
    /// fetch the full body via `textlog__get_capture` with this row's `id`.
    pub text_preview: Option<String>,
    /// `true` when `text_preview` is shorter than the full content.
    pub truncated: bool,
    /// Absolute path to the daily Markdown archive this row was
    /// mirrored into. Lets Claude `Read` the full day's context as a
    /// single attachment without needing the path on the clipboard.
    pub md_path: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_app: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ocr_confidence: Option<f32>,
}

/// Full capture body — returned only by `textlog__get_capture` to avoid
/// dumping potentially large bodies into list-style responses. The
/// `text` field is windowed by the request's `offset`/`limit`; callers
/// page using `text_offset` + `text.chars().count()` against
/// `text_total_chars`.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct CaptureFull {
    pub id: i64,
    pub ts: String,
    pub kind: String,
    pub sha256: String,
    pub size_bytes: usize,
    /// Slice of the capture body covering chars
    /// `[text_offset, text_offset + text.chars().count())`. May be
    /// empty or null when the row has no body.
    pub text: Option<String>,
    /// Starting character index of `text` within the full body.
    pub text_offset: usize,
    /// Total character count of the full body, regardless of slice.
    pub text_total_chars: usize,
    /// `true` when the returned `text` slice does not reach the end of
    /// the full body — page forward with `offset = text_offset +
    /// text.chars().count()`.
    pub truncated: bool,
    pub md_path: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_app: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ocr_confidence: Option<f32>,
}

#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct SearchResult {
    pub capture: CaptureSummary,
    /// Set if another row in *this* result set with a smaller index
    /// already carries the same sha256 — Claude can elide the body.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub duplicate_of: Option<i64>,
}

#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct OcrResult {
    pub text: String,
    pub confidence: f32,
    pub block_count: usize,
}

#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct OcrLatestResult {
    pub text: Option<String>,
    pub confidence: Option<f32>,
    pub captured_at: Option<String>,
}

#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ClearSinceResult {
    pub deleted_count: usize,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn get_recent_defaults_when_args_missing() {
        let args: GetRecentArgs = serde_json::from_str("{}").unwrap();
        assert_eq!(args.n, 5);
        assert!(args.kind.is_none());
    }

    #[test]
    fn search_defaults_limit_to_20() {
        let args: SearchArgs = serde_json::from_str(r#"{"query":"x"}"#).unwrap();
        assert_eq!(args.limit, 20);
        assert!(args.since.is_none());
    }

    #[test]
    fn kind_filter_serializes_lowercase() {
        let s = serde_json::to_string(&KindFilter::Image).unwrap();
        assert_eq!(s, "\"image\"");
        let parsed: KindFilter = serde_json::from_str("\"any\"").unwrap();
        assert_eq!(parsed, KindFilter::Any);
    }
}