Skip to main content

cersei_tools/
file_history.rs

1//! File history: track which files were accessed during a session.
2//!
3//! Used for context injection ("files you've been working on").
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9/// Tracks file access during a session.
10#[derive(Debug, Clone, Default)]
11pub struct FileHistory {
12    entries: HashMap<PathBuf, FileAccess>,
13}
14
15#[derive(Debug, Clone)]
16pub struct FileAccess {
17    pub path: PathBuf,
18    pub read_count: u32,
19    pub write_count: u32,
20    pub edit_count: u32,
21    pub last_accessed: u64,
22}
23
24impl FileHistory {
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    pub fn record_read(&mut self, path: &PathBuf) {
30        let entry = self
31            .entries
32            .entry(path.clone())
33            .or_insert_with(|| FileAccess {
34                path: path.clone(),
35                read_count: 0,
36                write_count: 0,
37                edit_count: 0,
38                last_accessed: 0,
39            });
40        entry.read_count += 1;
41        entry.last_accessed = now_secs();
42    }
43
44    pub fn record_write(&mut self, path: &PathBuf) {
45        let entry = self
46            .entries
47            .entry(path.clone())
48            .or_insert_with(|| FileAccess {
49                path: path.clone(),
50                read_count: 0,
51                write_count: 0,
52                edit_count: 0,
53                last_accessed: 0,
54            });
55        entry.write_count += 1;
56        entry.last_accessed = now_secs();
57    }
58
59    pub fn record_edit(&mut self, path: &PathBuf) {
60        let entry = self
61            .entries
62            .entry(path.clone())
63            .or_insert_with(|| FileAccess {
64                path: path.clone(),
65                read_count: 0,
66                write_count: 0,
67                edit_count: 0,
68                last_accessed: 0,
69            });
70        entry.edit_count += 1;
71        entry.last_accessed = now_secs();
72    }
73
74    /// Get all accessed files, sorted by last access (most recent first).
75    pub fn all_files(&self) -> Vec<&FileAccess> {
76        let mut files: Vec<_> = self.entries.values().collect();
77        files.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
78        files
79    }
80
81    /// Get modified files (written or edited), sorted by recency.
82    pub fn modified_files(&self) -> Vec<&FileAccess> {
83        let mut files: Vec<_> = self
84            .entries
85            .values()
86            .filter(|f| f.write_count > 0 || f.edit_count > 0)
87            .collect();
88        files.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
89        files
90    }
91
92    /// Build a context string for the system prompt.
93    pub fn build_context(&self) -> Option<String> {
94        let modified = self.modified_files();
95        if modified.is_empty() {
96            return None;
97        }
98
99        let lines: Vec<String> = modified
100            .iter()
101            .take(20)
102            .map(|f| {
103                let ops = format!(
104                    "{}{}{}",
105                    if f.read_count > 0 {
106                        format!("r{} ", f.read_count)
107                    } else {
108                        String::new()
109                    },
110                    if f.write_count > 0 {
111                        format!("w{} ", f.write_count)
112                    } else {
113                        String::new()
114                    },
115                    if f.edit_count > 0 {
116                        format!("e{}", f.edit_count)
117                    } else {
118                        String::new()
119                    },
120                );
121                format!("- {} ({})", f.path.display(), ops.trim())
122            })
123            .collect();
124
125        Some(format!(
126            "Files modified this session:\n{}",
127            lines.join("\n")
128        ))
129    }
130
131    pub fn file_count(&self) -> usize {
132        self.entries.len()
133    }
134
135    pub fn clear(&mut self) {
136        self.entries.clear();
137    }
138}
139
140fn now_secs() -> u64 {
141    SystemTime::now()
142        .duration_since(UNIX_EPOCH)
143        .map(|d| d.as_secs())
144        .unwrap_or(0)
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_file_history() {
153        let mut history = FileHistory::new();
154        let path1 = PathBuf::from("src/main.rs");
155        let path2 = PathBuf::from("Cargo.toml");
156
157        history.record_read(&path1);
158        history.record_read(&path1);
159        history.record_edit(&path1);
160        history.record_write(&path2);
161
162        assert_eq!(history.file_count(), 2);
163        assert_eq!(history.all_files().len(), 2);
164        assert_eq!(history.modified_files().len(), 2);
165
166        let main = history.entries.get(&path1).unwrap();
167        assert_eq!(main.read_count, 2);
168        assert_eq!(main.edit_count, 1);
169    }
170
171    #[test]
172    fn test_build_context() {
173        let mut history = FileHistory::new();
174        history.record_edit(&PathBuf::from("src/lib.rs"));
175        history.record_write(&PathBuf::from("README.md"));
176
177        let ctx = history.build_context();
178        assert!(ctx.is_some());
179        assert!(ctx.unwrap().contains("src/lib.rs"));
180    }
181
182    #[test]
183    fn test_empty_context() {
184        let history = FileHistory::new();
185        assert!(history.build_context().is_none());
186
187        // Read-only doesn't count as modified
188        let mut history = FileHistory::new();
189        history.record_read(&PathBuf::from("file.txt"));
190        assert!(history.build_context().is_none());
191    }
192}