difflore-core 0.1.0

Core library for the difflore CLI — rule store, retrieval, MCP server, hooks, cloud sync. Not intended for direct use; depend on `difflore-cli` instead.
Documentation
use std::collections::BTreeMap;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InjectionEntry {
    ts_ms: i64,
    path: String,
    rules_injected: usize,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    file_path: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InjectionLog {
    version: u32,
    entries: Vec<InjectionEntry>,
}

#[derive(Debug, Clone, Default)]
pub struct InjectionPathSummary {
    pub count_24h: usize,
    pub by_path: BTreeMap<String, usize>,
    pub injected_by_path: BTreeMap<String, usize>,
    pub total_rules_injected: usize,
    pub path: Option<PathBuf>,
    pub detail: Option<String>,
}

fn now_ms() -> i64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |d| d.as_millis() as i64)
}

fn log_path() -> Option<PathBuf> {
    crate::paths::data_home()
        .ok()
        .map(|dir| dir.join("injection-paths.json"))
}

pub fn record(path_name: &str, rules_injected: usize, file_path: Option<&str>) {
    let Some(path) = log_path() else {
        return;
    };
    let cutoff = now_ms().saturating_sub(24 * 60 * 60 * 1000);
    let mut log = std::fs::read_to_string(&path)
        .ok()
        .and_then(|raw| serde_json::from_str::<InjectionLog>(&raw).ok())
        .unwrap_or(InjectionLog {
            version: 1,
            entries: Vec::new(),
        });
    log.entries.retain(|entry| entry.ts_ms >= cutoff);
    log.entries.push(InjectionEntry {
        ts_ms: now_ms(),
        path: path_name.to_owned(),
        rules_injected,
        file_path: file_path.map(|p| {
            if p.len() > 200 {
                p.chars().take(200).collect()
            } else {
                p.to_owned()
            }
        }),
    });
    if log.entries.len() > 2_000 {
        let keep_from = log.entries.len().saturating_sub(2_000);
        log.entries = log.entries.split_off(keep_from);
    }
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    if let Ok(json) = serde_json::to_string_pretty(&log) {
        let _ = std::fs::write(path, json);
    }
}

pub fn summary_24h() -> InjectionPathSummary {
    let Some(path) = log_path() else {
        return InjectionPathSummary {
            detail: Some("could not resolve DIFFLORE_HOME".into()),
            ..InjectionPathSummary::default()
        };
    };
    let Ok(raw) = std::fs::read_to_string(&path) else {
        return InjectionPathSummary {
            path: Some(path),
            detail: Some("no injection path log yet".into()),
            ..InjectionPathSummary::default()
        };
    };
    let log = match serde_json::from_str::<InjectionLog>(&raw) {
        Ok(log) => log,
        Err(e) => {
            return InjectionPathSummary {
                path: Some(path),
                detail: Some(format!("injection path log is unreadable: {e}")),
                ..InjectionPathSummary::default()
            };
        }
    };
    let cutoff = now_ms().saturating_sub(24 * 60 * 60 * 1000);
    let mut summary = InjectionPathSummary {
        path: Some(path),
        ..InjectionPathSummary::default()
    };
    for entry in log
        .entries
        .into_iter()
        .filter(|entry| entry.ts_ms >= cutoff)
    {
        summary.count_24h += 1;
        *summary.by_path.entry(entry.path.clone()).or_insert(0) += 1;
        if entry.rules_injected > 0 {
            *summary.injected_by_path.entry(entry.path).or_insert(0) += 1;
            summary.total_rules_injected += entry.rules_injected;
        }
    }
    summary
}