clipmem 0.4.3

macOS clipboard memory backed by SQLite and searchable from agent runtimes
Documentation
use anyhow::Result;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};

use crate::db::{RetrievalKind, SearchMode, TimelineSort};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct DurationValue {
    pub(in crate::cli) raw: String,
    pub(in crate::cli) seconds: u64,
}

impl DurationValue {
    #[must_use]
    pub(super) fn new(raw: String, seconds: u64) -> Self {
        Self { raw, seconds }
    }

    #[must_use]
    pub(super) fn raw(&self) -> &str {
        &self.raw
    }

    #[must_use]
    pub(super) fn seconds(&self) -> u64 {
        self.seconds
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum RetentionValue {
    Forever,
    Duration(DurationValue),
}

impl RetentionValue {
    #[must_use]
    pub(super) fn retention_seconds(&self) -> Option<u64> {
        match self {
            Self::Forever => None,
            Self::Duration(duration) => Some(duration.seconds()),
        }
    }
}

pub(super) fn parse_normalized_score(value: &str) -> Result<f64, LimitParseError> {
    let parsed = value
        .parse::<f64>()
        .map_err(|_| LimitParseError(format!("invalid floating-point value '{value}'")))?;

    if (0.0..=1.0).contains(&parsed) {
        Ok(parsed)
    } else {
        Err(LimitParseError(format!(
            "value must be between 0.0 and 1.0, got {parsed}"
        )))
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) 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 {}

pub(super) 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}"
        )))
    }
}

pub(super) fn parse_rfc3339_timestamp(value: &str) -> Result<String, LimitParseError> {
    OffsetDateTime::parse(value, &Rfc3339)
        .map_err(|_| LimitParseError(format!("invalid RFC3339 timestamp '{value}'")))
        .and_then(|timestamp| {
            timestamp
                .format(&Rfc3339)
                .map_err(|error| LimitParseError(format!("format timestamp '{value}': {error}")))
        })
}

pub(super) fn parse_nonnegative_bytes(value: &str) -> Result<usize, LimitParseError> {
    value
        .parse::<usize>()
        .map_err(|_| LimitParseError(format!("invalid integer value '{value}'")))
}

pub(super) fn parse_search_mode(value: &str) -> Result<SearchMode, LimitParseError> {
    match value.trim().to_ascii_lowercase().as_str() {
        "auto" => Ok(SearchMode::Auto),
        "fts" => Ok(SearchMode::Fts),
        "literal" => Ok(SearchMode::Literal),
        _ => Err(one_of_error("search mode", value, "auto, fts, or literal")),
    }
}

pub(super) fn parse_timeline_sort(value: &str) -> Result<TimelineSort, LimitParseError> {
    match value.trim().to_ascii_lowercase().as_str() {
        "asc" => Ok(TimelineSort::Asc),
        "desc" => Ok(TimelineSort::Desc),
        _ => Err(one_of_error("timeline sort", value, "asc or desc")),
    }
}

pub(super) fn parse_retrieval_kind(value: &str) -> Result<RetrievalKind, LimitParseError> {
    match value.trim().to_ascii_lowercase().as_str() {
        "text" => Ok(RetrievalKind::Text),
        "html" => Ok(RetrievalKind::Html),
        "rtf" => Ok(RetrievalKind::Rtf),
        "url" => Ok(RetrievalKind::Url),
        "file" => Ok(RetrievalKind::File),
        "image" => Ok(RetrievalKind::Image),
        "pdf" => Ok(RetrievalKind::Pdf),
        "binary" => Ok(RetrievalKind::Binary),
        "other" => Ok(RetrievalKind::Other),
        _ => Err(one_of_error(
            "retrieval kind",
            value,
            "text, html, rtf, url, file, image, pdf, binary, or other",
        )),
    }
}

fn one_of_error(label: &str, value: &str, allowed: &str) -> LimitParseError {
    LimitParseError(format!("invalid {label} '{value}'; expected {allowed}"))
}

pub(super) fn parse_duration_value(value: &str) -> Result<DurationValue, LimitParseError> {
    let trimmed = value.trim();
    if trimmed.len() < 2 {
        return Err(LimitParseError(format!(
            "invalid duration '{value}'; expected <integer><unit> like 30d, 12h, or 15m"
        )));
    }

    let (amount, unit) = trimmed.split_at(trimmed.len() - 1);
    let amount = amount.parse::<u64>().map_err(|_| {
        LimitParseError(format!(
            "invalid duration '{value}'; expected an integer amount before the unit"
        ))
    })?;
    if amount == 0 {
        return Err(LimitParseError(
            "duration must be greater than zero".to_string(),
        ));
    }

    let seconds = match unit.to_ascii_lowercase().as_str() {
        "d" => amount.saturating_mul(24 * 60 * 60),
        "h" => amount.saturating_mul(60 * 60),
        "m" => amount.saturating_mul(60),
        _ => {
            return Err(LimitParseError(format!(
                "invalid duration unit '{unit}'; expected d, h, or m"
            )))
        }
    };

    if seconds == u64::MAX {
        return Err(LimitParseError(format!(
            "duration '{value}' exceeds supported range"
        )));
    }

    Ok(DurationValue::new(trimmed.to_string(), seconds))
}

pub(super) fn parse_retention_value(value: &str) -> Result<RetentionValue, LimitParseError> {
    if value.trim().eq_ignore_ascii_case("forever") {
        Ok(RetentionValue::Forever)
    } else {
        parse_duration_value(value).map(RetentionValue::Duration)
    }
}

#[cfg(test)]
mod tests {
    use crate::db::{RetrievalKind, SearchMode, TimelineSort};

    use super::{
        parse_retrieval_kind, parse_search_mode, parse_timeline_sort, DurationValue, RetentionValue,
    };

    #[test]
    fn retention_value_returns_none_for_forever_and_seconds_for_duration() {
        assert_eq!(RetentionValue::Forever.retention_seconds(), None);
        assert_eq!(
            RetentionValue::Duration(DurationValue::new("2h".to_string(), 7_200))
                .retention_seconds(),
            Some(7_200)
        );
    }

    #[test]
    fn cli_domain_parsers_return_database_enums() {
        assert_eq!(parse_search_mode("literal").unwrap(), SearchMode::Literal);
        assert_eq!(parse_timeline_sort("asc").unwrap(), TimelineSort::Asc);
        assert_eq!(parse_retrieval_kind("file").unwrap(), RetrievalKind::File);
        assert!(parse_search_mode("regex").is_err());
        assert!(parse_timeline_sort("newest").is_err());
        assert!(parse_retrieval_kind("folder").is_err());
    }
}