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());
}
}