mii-memory 0.1.0

A local-first memory store for AI agents with CLI, MCP server, and web explorer interfaces.
Documentation
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;

use anyhow::{Context, Result, anyhow, bail};
use chrono::{DateTime, Duration, NaiveDate, TimeZone, Utc};
use serde_json::Value;
use sha2::{Digest, Sha256};

use crate::model::ExpirationCondition;

pub fn validate_expiration(condition: ExpirationCondition, value: &str) -> Result<()> {
    match condition {
        ExpirationCondition::Time => {
            parse_duration(value).or_else(|_| parse_instant(value).map(|_| Duration::zero()))?;
        }
        ExpirationCondition::Usage => {
            parse_usage_limit(value)?;
        }
        ExpirationCondition::FileExist | ExpirationCondition::FilePristine => {
            if value.trim().is_empty() {
                bail!("file-based expiration requires a path");
            }
        }
        ExpirationCondition::Period => {
            parse_period(value)?;
        }
    }

    Ok(())
}

pub fn fingerprint_for_condition(
    condition: Option<ExpirationCondition>,
    value: Option<&str>,
) -> Result<Option<String>> {
    if condition != Some(ExpirationCondition::FilePristine) {
        return Ok(None);
    }

    let path = value.ok_or_else(|| anyhow!("file_pristine expiration requires a path"))?;
    fingerprint_file(path)
}

pub fn is_expired(
    condition: Option<ExpirationCondition>,
    value: Option<&str>,
    created_at: DateTime<Utc>,
    usage_count: i64,
    file_fingerprint: Option<&str>,
    now: DateTime<Utc>,
) -> bool {
    let Some(condition) = condition else {
        return false;
    };
    let Some(value) = value else {
        return true;
    };

    match condition {
        ExpirationCondition::Time => time_expired(value, created_at, now),
        ExpirationCondition::Usage => usage_expired(value, usage_count),
        ExpirationCondition::FileExist => !Path::new(value).exists(),
        ExpirationCondition::FilePristine => file_changed(value, file_fingerprint),
        ExpirationCondition::Period => period_expired(value, now),
    }
}

fn time_expired(value: &str, created_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
    if let Ok(expires_at) = parse_instant(value) {
        return now >= expires_at;
    }

    parse_duration(value)
        .map(|duration| now >= created_at + duration)
        .unwrap_or(true)
}

fn usage_expired(value: &str, usage_count: i64) -> bool {
    parse_usage_limit(value)
        .map(|limit| usage_count >= limit)
        .unwrap_or(true)
}

fn file_changed(value: &str, original_fingerprint: Option<&str>) -> bool {
    let Some(original_fingerprint) = original_fingerprint else {
        return true;
    };

    match fingerprint_file(value) {
        Ok(Some(current_fingerprint)) => current_fingerprint != original_fingerprint,
        Ok(None) | Err(_) => true,
    }
}

fn period_expired(value: &str, now: DateTime<Utc>) -> bool {
    parse_period(value)
        .map(|period| !period.contains(now))
        .unwrap_or(true)
}

fn parse_usage_limit(value: &str) -> Result<i64> {
    let limit = value
        .trim()
        .parse::<i64>()
        .with_context(|| format!("invalid usage expiration value: {value}"))?;

    if limit <= 0 {
        bail!("usage expiration value must be greater than zero");
    }

    Ok(limit)
}

fn parse_duration(value: &str) -> Result<Duration> {
    let value = value.trim();
    if value.is_empty() {
        bail!("duration cannot be empty");
    }

    let split_at = value
        .find(|character: char| !character.is_ascii_digit())
        .unwrap_or(value.len());
    let (number, unit) = value.split_at(split_at);
    let amount = number
        .parse::<i64>()
        .with_context(|| format!("invalid duration value: {value}"))?;

    if amount < 0 {
        bail!("duration must not be negative");
    }

    match unit.trim().to_ascii_lowercase().as_str() {
        "" | "s" | "sec" | "secs" | "second" | "seconds" => Ok(Duration::seconds(amount)),
        "m" | "min" | "mins" | "minute" | "minutes" => Ok(Duration::minutes(amount)),
        "h" | "hr" | "hrs" | "hour" | "hours" => Ok(Duration::hours(amount)),
        "d" | "day" | "days" => Ok(Duration::days(amount)),
        "w" | "week" | "weeks" => Ok(Duration::weeks(amount)),
        other => bail!("unsupported duration unit: {other}"),
    }
}

fn parse_instant(value: &str) -> Result<DateTime<Utc>> {
    let value = value.trim();

    if let Ok(datetime) = DateTime::parse_from_rfc3339(value) {
        return Ok(datetime.with_timezone(&Utc));
    }

    let date = NaiveDate::parse_from_str(value, "%Y-%m-%d")
        .with_context(|| format!("invalid instant value: {value}"))?;
    let datetime = date
        .and_hms_opt(0, 0, 0)
        .ok_or_else(|| anyhow!("invalid date value: {value}"))?;
    Ok(Utc.from_utc_datetime(&datetime))
}

fn parse_period(value: &str) -> Result<Period> {
    let value = value.trim();

    if let Ok(json) = serde_json::from_str::<Value>(value) {
        let start = json
            .get("start")
            .and_then(Value::as_str)
            .map(parse_instant)
            .transpose()?;
        let end = json
            .get("end")
            .and_then(Value::as_str)
            .map(parse_instant)
            .transpose()?;
        return Ok(Period { start, end });
    }

    let (start, end) = value
        .split_once("..")
        .or_else(|| value.split_once(','))
        .ok_or_else(|| anyhow!("period must be formatted as start..end, start,end, or JSON"))?;

    Ok(Period {
        start: parse_optional_instant(start)?,
        end: parse_optional_instant(end)?,
    })
}

fn parse_optional_instant(value: &str) -> Result<Option<DateTime<Utc>>> {
    let value = value.trim();
    if value.is_empty() {
        return Ok(None);
    }

    parse_instant(value).map(Some)
}

fn fingerprint_file(path: impl AsRef<Path>) -> Result<Option<String>> {
    let path = path.as_ref();
    if !path.exists() {
        return Ok(None);
    }

    if !path.is_file() {
        let metadata = path
            .metadata()
            .with_context(|| format!("failed to read metadata for {}", path.display()))?;
        let mut hasher = Sha256::new();
        hasher.update(metadata.len().to_le_bytes());
        if let Ok(modified) = metadata.modified()
            && let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH)
        {
            hasher.update(duration.as_nanos().to_le_bytes());
        }
        return Ok(Some(hex::encode(hasher.finalize())));
    }

    let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
    let mut reader = BufReader::new(file);
    let mut hasher = Sha256::new();
    let mut buffer = [0_u8; 8192];

    loop {
        let read = reader
            .read(&mut buffer)
            .with_context(|| format!("failed to read {}", path.display()))?;
        if read == 0 {
            break;
        }
        hasher.update(&buffer[..read]);
    }

    Ok(Some(hex::encode(hasher.finalize())))
}

#[derive(Debug, Clone, Copy)]
struct Period {
    start: Option<DateTime<Utc>>,
    end: Option<DateTime<Utc>>,
}

impl Period {
    fn contains(self, now: DateTime<Utc>) -> bool {
        if self.start.is_some_and(|start| now < start) {
            return false;
        }

        if self.end.is_some_and(|end| now > end) {
            return false;
        }

        true
    }
}

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

    #[test]
    fn usage_expiration_allows_exact_number_of_reads() {
        let now = Utc::now();

        assert!(!is_expired(
            Some(ExpirationCondition::Usage),
            Some("2"),
            now,
            1,
            None,
            now,
        ));
        assert!(is_expired(
            Some(ExpirationCondition::Usage),
            Some("2"),
            now,
            2,
            None,
            now,
        ));
    }

    #[test]
    fn period_accepts_open_start() {
        let now = Utc.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap();

        assert!(!is_expired(
            Some(ExpirationCondition::Period),
            Some("..2026-05-19T00:00:00Z"),
            now,
            0,
            None,
            now,
        ));
    }
}