next_plaid_cli/index/
state.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::SystemTime;
7use xxhash_rust::xxh3::xxh3_64;
8
9use super::paths::get_state_path;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct IndexState {
13    /// CLI version that created/updated this index
14    #[serde(default)]
15    pub cli_version: String,
16    pub files: HashMap<PathBuf, FileInfo>,
17    /// Number of searches performed against this index
18    #[serde(default)]
19    pub search_count: u64,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct FileInfo {
24    pub content_hash: u64,
25    pub mtime: u64,
26}
27
28impl IndexState {
29    /// Load state from the given index directory
30    pub fn load(index_dir: &Path) -> Result<Self> {
31        let state_path = get_state_path(index_dir);
32        if state_path.exists() {
33            let content = fs::read_to_string(&state_path)?;
34            Ok(serde_json::from_str(&content)?)
35        } else {
36            Ok(Self::default())
37        }
38    }
39
40    /// Save state to the given index directory
41    pub fn save(&self, index_dir: &Path) -> Result<()> {
42        fs::create_dir_all(index_dir)?;
43
44        // Update CLI version before saving
45        let mut state = self.clone();
46        state.cli_version = env!("CARGO_PKG_VERSION").to_string();
47
48        let state_path = get_state_path(index_dir);
49        let content = serde_json::to_string_pretty(&state)?;
50        fs::write(&state_path, content)?;
51        Ok(())
52    }
53
54    /// Increment the search count
55    pub fn increment_search_count(&mut self) {
56        self.search_count += 1;
57    }
58
59    /// Reset the search count to zero
60    pub fn reset_search_count(&mut self) {
61        self.search_count = 0;
62    }
63}
64
65/// Hash file content using xxHash for fast comparison
66pub fn hash_file(path: &Path) -> Result<u64> {
67    let content = fs::read(path)?;
68    Ok(xxh3_64(&content))
69}
70
71/// Get file modification time as unix timestamp
72pub fn get_mtime(path: &Path) -> Result<u64> {
73    let metadata = fs::metadata(path)?;
74    let mtime = metadata
75        .modified()?
76        .duration_since(SystemTime::UNIX_EPOCH)?
77        .as_secs();
78    Ok(mtime)
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use std::io::Write;
85    use tempfile::TempDir;
86
87    #[test]
88    fn test_index_state_default() {
89        let state = IndexState::default();
90        assert!(state.cli_version.is_empty());
91        assert!(state.files.is_empty());
92    }
93
94    #[test]
95    fn test_file_info_serialization() {
96        let info = FileInfo {
97            content_hash: 12345678901234567890,
98            mtime: 1700000000,
99        };
100
101        let json = serde_json::to_string(&info).unwrap();
102        assert!(json.contains("12345678901234567890"));
103        assert!(json.contains("1700000000"));
104
105        let deserialized: FileInfo = serde_json::from_str(&json).unwrap();
106        assert_eq!(deserialized.content_hash, 12345678901234567890);
107        assert_eq!(deserialized.mtime, 1700000000);
108    }
109
110    #[test]
111    fn test_index_state_serialization() {
112        let mut files = HashMap::new();
113        files.insert(
114            PathBuf::from("src/main.rs"),
115            FileInfo {
116                content_hash: 123456,
117                mtime: 1700000000,
118            },
119        );
120        let state = IndexState {
121            cli_version: "1.0.0".to_string(),
122            files,
123            search_count: 0,
124        };
125
126        let json = serde_json::to_string(&state).unwrap();
127        assert!(json.contains("1.0.0"));
128        assert!(json.contains("src/main.rs"));
129
130        let deserialized: IndexState = serde_json::from_str(&json).unwrap();
131        assert_eq!(deserialized.cli_version, "1.0.0");
132        assert!(deserialized
133            .files
134            .contains_key(&PathBuf::from("src/main.rs")));
135    }
136
137    #[test]
138    fn test_index_state_load_nonexistent() {
139        let temp_dir = TempDir::new().unwrap();
140        let result = IndexState::load(temp_dir.path());
141        assert!(result.is_ok());
142        let state = result.unwrap();
143        assert!(state.files.is_empty());
144    }
145
146    #[test]
147    fn test_index_state_save_and_load() {
148        let temp_dir = TempDir::new().unwrap();
149
150        let mut state = IndexState::default();
151        state.files.insert(
152            PathBuf::from("test.rs"),
153            FileInfo {
154                content_hash: 999999,
155                mtime: 1700000000,
156            },
157        );
158
159        // Save
160        state.save(temp_dir.path()).unwrap();
161
162        // Load and verify
163        let loaded = IndexState::load(temp_dir.path()).unwrap();
164        assert!(loaded.files.contains_key(&PathBuf::from("test.rs")));
165        let file_info = loaded.files.get(&PathBuf::from("test.rs")).unwrap();
166        assert_eq!(file_info.content_hash, 999999);
167
168        // CLI version should be set after saving
169        assert!(!loaded.cli_version.is_empty());
170    }
171
172    #[test]
173    fn test_hash_file() {
174        let temp_dir = TempDir::new().unwrap();
175        let file_path = temp_dir.path().join("test.txt");
176
177        // Create a file with known content
178        let mut file = fs::File::create(&file_path).unwrap();
179        file.write_all(b"Hello, World!").unwrap();
180
181        let hash = hash_file(&file_path).unwrap();
182        assert!(hash > 0);
183
184        // Same content should produce same hash
185        let hash2 = hash_file(&file_path).unwrap();
186        assert_eq!(hash, hash2);
187    }
188
189    #[test]
190    fn test_hash_file_different_content() {
191        let temp_dir = TempDir::new().unwrap();
192
193        let file1 = temp_dir.path().join("file1.txt");
194        let file2 = temp_dir.path().join("file2.txt");
195
196        fs::write(&file1, "Content A").unwrap();
197        fs::write(&file2, "Content B").unwrap();
198
199        let hash1 = hash_file(&file1).unwrap();
200        let hash2 = hash_file(&file2).unwrap();
201
202        assert_ne!(hash1, hash2);
203    }
204
205    #[test]
206    fn test_get_mtime() {
207        let temp_dir = TempDir::new().unwrap();
208        let file_path = temp_dir.path().join("test.txt");
209
210        fs::write(&file_path, "test content").unwrap();
211
212        let mtime = get_mtime(&file_path).unwrap();
213        // mtime should be a reasonable Unix timestamp (after year 2000)
214        assert!(mtime > 946684800); // Jan 1, 2000
215    }
216
217    #[test]
218    fn test_hash_file_nonexistent() {
219        let result = hash_file(Path::new("/nonexistent/file.txt"));
220        assert!(result.is_err());
221    }
222
223    #[test]
224    fn test_get_mtime_nonexistent() {
225        let result = get_mtime(Path::new("/nonexistent/file.txt"));
226        assert!(result.is_err());
227    }
228
229    #[test]
230    fn test_search_count_increment_and_reset() {
231        let temp_dir = TempDir::new().unwrap();
232
233        // Create initial state with zero search count
234        let mut state = IndexState::default();
235        assert_eq!(state.search_count, 0);
236
237        // Increment search count
238        state.increment_search_count();
239        assert_eq!(state.search_count, 1);
240
241        state.increment_search_count();
242        state.increment_search_count();
243        assert_eq!(state.search_count, 3);
244
245        // Save and reload to verify persistence
246        state.save(temp_dir.path()).unwrap();
247        let loaded = IndexState::load(temp_dir.path()).unwrap();
248        assert_eq!(loaded.search_count, 3);
249
250        // Reset search count
251        let mut loaded = loaded;
252        loaded.reset_search_count();
253        assert_eq!(loaded.search_count, 0);
254
255        // Save and reload to verify reset persists
256        loaded.save(temp_dir.path()).unwrap();
257        let reloaded = IndexState::load(temp_dir.path()).unwrap();
258        assert_eq!(reloaded.search_count, 0);
259    }
260}