1use crate::config::Config;
2use crate::db;
3use crate::errors::CoreError;
4use crate::models::{Pattern, PatternStatus, Projection};
5use crate::projection::claude_md;
6use crate::util::backup_file;
7use chrono::{Duration, Utc};
8use rusqlite::Connection;
9use std::path::Path;
10
11#[derive(Debug, Clone)]
13pub struct StaleItem {
14 pub pattern: Pattern,
15 pub projection: Projection,
16 pub reason: String,
17}
18
19#[derive(Debug)]
21pub struct CleanResult {
22 pub archived_count: usize,
23 pub skills_removed: usize,
24 pub claude_md_rules_removed: usize,
25 pub agents_removed: usize,
26}
27
28pub fn detect_stale(
35 conn: &Connection,
36 config: &Config,
37) -> Result<Vec<StaleItem>, CoreError> {
38 let staleness_days = config.analysis.staleness_days as i64;
39 let cutoff = Utc::now() - Duration::days(staleness_days);
40
41 let active_patterns = db::get_patterns(conn, &["active"], None)?;
43 let projections = db::get_projections_for_active_patterns(conn)?;
44
45 let mut stale_items = Vec::new();
46
47 for pattern in &active_patterns {
48 if pattern.last_seen >= cutoff {
49 continue;
50 }
51
52 if let Some(proj) = projections.iter().find(|p| p.pattern_id == pattern.id) {
54 let days_stale = (Utc::now() - pattern.last_seen).num_days();
55 stale_items.push(StaleItem {
56 pattern: pattern.clone(),
57 projection: proj.clone(),
58 reason: format!(
59 "not seen in {} days (threshold: {} days)",
60 days_stale, staleness_days
61 ),
62 });
63 }
64 }
65
66 Ok(stale_items)
67}
68
69pub fn archive_stale_items(
71 conn: &Connection,
72 _config: &Config,
73 items: &[StaleItem],
74) -> Result<CleanResult, CoreError> {
75 let backup_dir = crate::config::retro_dir().join("backups");
76 std::fs::create_dir_all(&backup_dir)
77 .map_err(|e| CoreError::Io(format!("creating backup dir: {e}")))?;
78
79 let mut result = CleanResult {
80 archived_count: 0,
81 skills_removed: 0,
82 claude_md_rules_removed: 0,
83 agents_removed: 0,
84 };
85
86 let claude_md_items: Vec<&StaleItem> = items
88 .iter()
89 .filter(|i| i.projection.target_type == "claude_md")
90 .collect();
91
92 if !claude_md_items.is_empty() {
94 let target_path = &claude_md_items[0].projection.target_path;
95 if let Ok(content) = std::fs::read_to_string(target_path) {
96 if let Some(current_rules) = claude_md::read_managed_section(&content) {
98 let stale_contents: Vec<&str> = claude_md_items
100 .iter()
101 .map(|i| i.projection.content.as_str())
102 .collect();
103
104 let remaining_rules: Vec<String> = current_rules
105 .into_iter()
106 .filter(|rule| !stale_contents.contains(&rule.as_str()))
107 .collect();
108
109 backup_file(target_path, &backup_dir)?;
111
112 let updated = claude_md::update_claude_md_content(&content, &remaining_rules);
113 std::fs::write(target_path, &updated)
114 .map_err(|e| CoreError::Io(format!("writing {target_path}: {e}")))?;
115
116 result.claude_md_rules_removed = claude_md_items.len();
117 }
118 }
119 }
120
121 for item in items {
123 match item.projection.target_type.as_str() {
124 "skill" => {
125 let skill_path = Path::new(&item.projection.target_path);
126 if skill_path.exists() {
127 backup_file(&item.projection.target_path, &backup_dir)?;
128
129 let _ = std::fs::remove_file(skill_path);
131
132 if let Some(parent) = skill_path.parent() {
134 let _ = std::fs::remove_dir(parent); }
136
137 result.skills_removed += 1;
138 }
139 }
140 "global_agent" => {
141 let agent_path = Path::new(&item.projection.target_path);
142 if agent_path.exists() {
143 backup_file(&item.projection.target_path, &backup_dir)?;
144 let _ = std::fs::remove_file(agent_path);
145 result.agents_removed += 1;
146 }
147 }
148 "claude_md" => {
149 }
151 _ => {}
152 }
153
154 db::update_pattern_status(conn, &item.pattern.id, &PatternStatus::Archived)?;
156 result.archived_count += 1;
157 }
158
159 Ok(result)
160}
161
162pub const AUDIT_RESPONSE_SCHEMA: &str = r#"{"type":"object","properties":{"findings":{"type":"array","items":{"type":"object","properties":{"finding_type":{"type":"string","enum":["redundant","contradictory","oversized","stale"]},"description":{"type":"string"},"affected_items":{"type":"array","items":{"type":"string"}},"suggestion":{"type":"string"}},"required":["finding_type","description","affected_items","suggestion"],"additionalProperties":false}}},"required":["findings"],"additionalProperties":false}"#;
164
165#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
167pub struct AuditFinding {
168 pub finding_type: String,
169 pub description: String,
170 pub affected_items: Vec<String>,
171 pub suggestion: String,
172}
173
174#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
176pub struct AuditResponse {
177 pub findings: Vec<AuditFinding>,
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn test_audit_response_schema_is_valid_json() {
186 let value: serde_json::Value = serde_json::from_str(AUDIT_RESPONSE_SCHEMA)
187 .expect("AUDIT_RESPONSE_SCHEMA must be valid JSON");
188 assert_eq!(value["type"], "object");
189 assert!(value["properties"]["findings"].is_object());
190 }
191}