Skip to main content

matrixcode_core/tools/
read_history.rs

1//! Read history tracker for edit/write precondition checks.
2//!
3//! Ensures files are read before being modified to prevent accidental overwrites.
4
5use chrono::{DateTime, Utc};
6use std::collections::{HashMap, HashSet};
7
8/// Tracker for files that have been read in the current session.
9///
10/// This tracker is used to enforce the "read before edit/write" rule:
11/// - Files must be read with the `read` tool before they can be edited or written
12/// - This prevents accidental overwrites and ensures context awareness
13#[derive(Debug, Clone, Default)]
14pub struct ReadHistoryTracker {
15    /// Set of file paths that have been read (normalized paths)
16    read_files: HashSet<String>,
17
18    /// Timestamps of when each file was read (for debugging/auditing)
19    read_timestamps: HashMap<String, DateTime<Utc>>,
20}
21
22impl ReadHistoryTracker {
23    /// Create a new empty tracker.
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Mark a file as having been read.
29    ///
30    /// # Arguments
31    /// * `file_path` - The path to the file that was read
32    ///
33    /// # Note
34    /// Paths are normalized to handle different path representations:
35    /// - Relative paths are converted to absolute paths where possible
36    /// - Path separators are normalized
37    pub fn mark_read(&mut self, file_path: &str) {
38        let normalized = self.normalize_path(file_path);
39        self.read_files.insert(normalized.clone());
40        self.read_timestamps.insert(normalized, Utc::now());
41    }
42
43    /// Check if a file has been read in this session.
44    ///
45    /// # Arguments
46    /// * `file_path` - The path to check
47    ///
48    /// # Returns
49    /// `true` if the file has been read, `false` otherwise
50    pub fn has_read(&self, file_path: &str) -> bool {
51        let normalized = self.normalize_path(file_path);
52        self.read_files.contains(&normalized)
53    }
54
55    /// Clear all read history (for new session).
56    pub fn clear(&mut self) {
57        self.read_files.clear();
58        self.read_timestamps.clear();
59    }
60
61    /// Get the count of files that have been read.
62    pub fn count(&self) -> usize {
63        self.read_files.len()
64    }
65
66    /// Get the list of files that have been read.
67    pub fn read_files(&self) -> Vec<&String> {
68        self.read_files.iter().collect()
69    }
70
71    /// Get the timestamp when a file was read.
72    pub fn read_timestamp(&self, file_path: &str) -> Option<DateTime<Utc>> {
73        let normalized = self.normalize_path(file_path);
74        self.read_timestamps.get(&normalized).copied()
75    }
76
77    /// Normalize a file path for consistent comparison.
78    ///
79    /// This handles:
80    /// - Path separator normalization (Windows vs Unix)
81    /// - Trailing slashes
82    fn normalize_path(&self, path: &str) -> String {
83        // Convert backslashes to forward slashes for consistency
84        let normalized = path.replace('\\', "/");
85        // Remove trailing slash (except for root paths like "/")
86        if normalized.len() > 1 && normalized.ends_with('/') {
87            normalized.trim_end_matches('/').to_string()
88        } else {
89            normalized
90        }
91    }
92}
93
94/// Error type for "must read first" violations.
95///
96/// This error is returned when attempting to edit or write a file
97/// that has not been read in the current session.
98#[derive(Debug, Clone)]
99pub struct MustReadFirstError {
100    /// The file path that needs to be read
101    pub file: String,
102    /// Human-readable error message
103    pub message: String,
104}
105
106impl MustReadFirstError {
107    /// Create a new MustReadFirstError.
108    pub fn new(file: impl Into<String>) -> Self {
109        let file_path = file.into();
110        Self {
111            file: file_path.clone(),
112            message: format!(
113                "File '{}' has not been read in this session. \
114                 Please use the 'read' tool to read the file first before editing or writing. \
115                 This ensures you understand the current file content and context.",
116                file_path
117            ),
118        }
119    }
120
121    /// Get the file path.
122    pub fn file(&self) -> &str {
123        &self.file
124    }
125
126    /// Get the error message.
127    pub fn message(&self) -> &str {
128        &self.message
129    }
130}
131
132impl std::fmt::Display for MustReadFirstError {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        write!(f, "{}", self.message)
135    }
136}
137
138impl std::error::Error for MustReadFirstError {}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_mark_and_check_read() {
146        let mut tracker = ReadHistoryTracker::new();
147
148        // Initially no files are read
149        assert!(!tracker.has_read("test.txt"));
150
151        // Mark as read
152        tracker.mark_read("test.txt");
153
154        // Now it should be marked as read
155        assert!(tracker.has_read("test.txt"));
156    }
157
158    #[test]
159    fn test_path_normalization() {
160        let mut tracker = ReadHistoryTracker::new();
161
162        // Windows-style path
163        tracker.mark_read("C:\\Users\\test\\file.txt");
164
165        // Should recognize Unix-style equivalent
166        assert!(tracker.has_read("C:/Users/test/file.txt"));
167
168        // And vice versa
169        tracker.mark_read("/home/user/file.txt");
170        assert!(tracker.has_read("/home/user/file.txt"));
171    }
172
173    #[test]
174    fn test_trailing_slash() {
175        let mut tracker = ReadHistoryTracker::new();
176
177        tracker.mark_read("path/to/file");
178
179        // Should recognize path with trailing slash
180        assert!(tracker.has_read("path/to/file/"));
181    }
182
183    #[test]
184    fn test_clear() {
185        let mut tracker = ReadHistoryTracker::new();
186
187        tracker.mark_read("file1.txt");
188        tracker.mark_read("file2.txt");
189
190        assert_eq!(tracker.count(), 2);
191
192        tracker.clear();
193
194        assert_eq!(tracker.count(), 0);
195        assert!(!tracker.has_read("file1.txt"));
196    }
197
198    #[test]
199    fn test_error_message() {
200        let error = MustReadFirstError::new("test.rs");
201
202        assert_eq!(error.file(), "test.rs");
203        assert!(error.message().contains("test.rs"));
204        assert!(error.message().contains("read"));
205    }
206}