spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! User-defined rules that influence memory extraction, injection,
//! and classification behavior. Stored at `~/.spool/rules.toml`.

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

const RULES_FILE_NAME: &str = "rules.toml";

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserRules {
    #[serde(default)]
    pub extraction: Vec<ExtractionRule>,
    #[serde(default)]
    pub context: Vec<ContextRule>,
    #[serde(default)]
    pub suppress: Vec<SuppressRule>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractionRule {
    pub trigger: String,
    #[serde(default = "default_memory_type")]
    pub memory_type: String,
    #[serde(default)]
    pub description: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextRule {
    #[serde(default = "default_scope")]
    pub scope: String,
    pub always_include: Vec<String>,
    #[serde(default)]
    pub description: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuppressRule {
    pub pattern: String,
    #[serde(default = "default_action")]
    pub action: String,
    #[serde(default)]
    pub description: String,
}

fn default_memory_type() -> String {
    "preference".to_string()
}
fn default_scope() -> String {
    "project".to_string()
}
fn default_action() -> String {
    "skip".to_string()
}

pub fn rules_path(spool_root: &Path) -> PathBuf {
    spool_root.join(RULES_FILE_NAME)
}

pub fn load(spool_root: &Path) -> UserRules {
    let path = rules_path(spool_root);
    match fs::read_to_string(&path) {
        Ok(content) => toml::from_str(&content).unwrap_or_default(),
        Err(_) => UserRules::default(),
    }
}

pub fn save(spool_root: &Path, rules: &UserRules) -> anyhow::Result<()> {
    let path = rules_path(spool_root);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let content = toml::to_string_pretty(rules)?;
    fs::write(&path, content)?;
    Ok(())
}

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

    #[test]
    fn load_returns_default_when_file_missing() {
        let temp = tempdir().unwrap();
        let rules = load(temp.path());
        assert!(rules.extraction.is_empty());
        assert!(rules.context.is_empty());
        assert!(rules.suppress.is_empty());
    }

    #[test]
    fn save_and_load_roundtrip() {
        let temp = tempdir().unwrap();
        let rules = UserRules {
            extraction: vec![ExtractionRule {
                trigger: "技术选型".to_string(),
                memory_type: "decision".to_string(),
                description: "技术选型相关决策".to_string(),
            }],
            context: vec![ContextRule {
                scope: "project".to_string(),
                always_include: vec!["架构约束".to_string()],
                description: "".to_string(),
            }],
            suppress: vec![SuppressRule {
                pattern: "临时.*测试".to_string(),
                action: "skip".to_string(),
                description: "跳过临时测试内容".to_string(),
            }],
        };
        save(temp.path(), &rules).unwrap();
        let loaded = load(temp.path());
        assert_eq!(loaded.extraction.len(), 1);
        assert_eq!(loaded.extraction[0].trigger, "技术选型");
        assert_eq!(loaded.context[0].always_include[0], "架构约束");
        assert_eq!(loaded.suppress[0].pattern, "临时.*测试");
    }

    #[test]
    fn load_handles_partial_toml() {
        let temp = tempdir().unwrap();
        fs::write(
            temp.path().join(RULES_FILE_NAME),
            "[[extraction]]\ntrigger = \"test\"\n",
        )
        .unwrap();
        let rules = load(temp.path());
        assert_eq!(rules.extraction.len(), 1);
        assert!(rules.context.is_empty());
    }
}