rust_filesearch/px/
index.rs1use crate::errors::{FsError, Result};
7use crate::fs::traverse::{walk_no_filter, TraverseConfig};
8use crate::models::EntryKind;
9use crate::px::project::Project;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::fs;
14use std::path::PathBuf;
15
16#[derive(Debug, Serialize, Deserialize)]
18pub struct ProjectIndex {
19 pub projects: HashMap<String, Project>,
21
22 #[serde(with = "chrono::serde::ts_seconds")]
24 pub last_sync: DateTime<Utc>,
25
26 pub version: u32,
28}
29
30impl ProjectIndex {
31 pub fn new() -> Self {
33 Self {
34 projects: HashMap::new(),
35 last_sync: Utc::now(),
36 version: 1,
37 }
38 }
39
40 pub fn load() -> Result<Self> {
45 let cache_path = Self::cache_path()?;
46
47 if !cache_path.exists() {
48 return Ok(Self::new());
49 }
50
51 let data = fs::read_to_string(&cache_path).map_err(|e| FsError::IoError {
52 context: format!("Failed to read cache file: {}", cache_path.display()),
53 source: e,
54 })?;
55
56 let index: ProjectIndex = serde_json::from_str(&data).map_err(|e| {
57 FsError::InvalidFormat {
58 format: format!("Invalid cache JSON: {}", e),
59 }
60 })?;
61
62 Ok(index)
63 }
64
65 pub fn save(&self) -> Result<()> {
69 let cache_path = Self::cache_path()?;
70
71 if let Some(parent) = cache_path.parent() {
73 fs::create_dir_all(parent).map_err(|e| FsError::IoError {
74 context: format!("Failed to create cache directory: {}", parent.display()),
75 source: e,
76 })?;
77 }
78
79 let json = serde_json::to_string_pretty(self).map_err(|e| FsError::InvalidFormat {
81 format: format!("Failed to serialize index: {}", e),
82 })?;
83
84 fs::write(&cache_path, json).map_err(|e| FsError::IoError {
85 context: format!("Failed to write cache file: {}", cache_path.display()),
86 source: e,
87 })?;
88
89 Ok(())
90 }
91
92 pub fn sync(&mut self, scan_dirs: &[PathBuf]) -> Result<usize> {
102 let mut new_projects = HashMap::new();
103
104 for scan_dir in scan_dirs {
106 if !scan_dir.exists() {
107 eprintln!(
108 "Warning: Scan directory does not exist: {}",
109 scan_dir.display()
110 );
111 continue;
112 }
113
114 let config = TraverseConfig {
116 max_depth: Some(3), follow_symlinks: false,
118 include_hidden: false,
119 respect_gitignore: true,
120 threads: 4, quiet: true, };
123
124 let entries = walk_no_filter(scan_dir, &config)?;
126
127 for entry in entries {
129 if entry.kind == EntryKind::Dir && crate::fs::git::is_git_repo(&entry.path) {
130 let path_str = entry.path.to_string_lossy().to_string();
131
132 match Project::from_git_repo(entry.path.clone()) {
134 Ok(mut project) => {
135 if let Some(existing) = self.projects.get(&path_str) {
137 project.access_count = existing.access_count;
138 project.last_accessed = existing.last_accessed;
139 project.frecency_score = existing.frecency_score;
140 }
141
142 new_projects.insert(path_str, project);
143 }
144 Err(e) => {
145 eprintln!(
147 "Warning: Failed to index {}: {}",
148 entry.path.display(),
149 e
150 );
151 }
152 }
153 }
154 }
155 }
156
157 let count = new_projects.len();
158 self.projects = new_projects;
159 self.last_sync = Utc::now();
160
161 self.save()?;
163
164 Ok(count)
165 }
166
167 pub fn record_access(&mut self, project_path: &str) -> Result<()> {
172 if let Some(project) = self.projects.get_mut(project_path) {
173 project.access_count += 1;
174 project.last_accessed = Some(Utc::now());
175 project.update_frecency_score();
176
177 self.save()?;
179 }
180
181 Ok(())
182 }
183
184 fn cache_path() -> Result<PathBuf> {
186 let cache_dir = dirs::cache_dir().ok_or_else(|| FsError::InvalidFormat {
187 format: "Could not determine cache directory".to_string(),
188 })?;
189
190 Ok(cache_dir.join("px").join("projects.json"))
191 }
192
193 pub fn sorted_projects(&self) -> Vec<&Project> {
195 let mut projects: Vec<&Project> = self.projects.values().collect();
196 projects.sort_by(|a, b| {
197 b.frecency_score
198 .partial_cmp(&a.frecency_score)
199 .unwrap_or(std::cmp::Ordering::Equal)
200 });
201 projects
202 }
203}
204
205impl Default for ProjectIndex {
206 fn default() -> Self {
207 Self::new()
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use std::fs;
215 use tempfile::TempDir;
216
217 #[test]
218 fn test_new_index() {
219 let index = ProjectIndex::new();
220 assert_eq!(index.version, 1);
221 assert!(index.projects.is_empty());
222 }
223
224 #[test]
225 fn test_save_and_load() {
226 let temp_dir = TempDir::new().unwrap();
228 let cache_path = temp_dir.path().join("test_cache.json");
229
230 let mut index = ProjectIndex::new();
232
233 let test_project = Project::from_git_repo(PathBuf::from(".")).unwrap_or_else(|_| {
235 Project {
237 path: PathBuf::from("/test/path"),
238 name: "test-project".to_string(),
239 last_modified: Utc::now(),
240 git_status: crate::px::project::ProjectGitStatus {
241 current_branch: "main".to_string(),
242 has_uncommitted: false,
243 ahead: 0,
244 behind: 0,
245 last_commit: None,
246 },
247 frecency_score: 0.0,
248 last_accessed: None,
249 access_count: 0,
250 readme_excerpt: Some("Test project".to_string()),
251 }
252 });
253
254 index
255 .projects
256 .insert(test_project.path.to_string_lossy().to_string(), test_project);
257
258 let json = serde_json::to_string_pretty(&index).unwrap();
260 fs::write(&cache_path, json).unwrap();
261
262 let data = fs::read_to_string(&cache_path).unwrap();
264 let loaded: ProjectIndex = serde_json::from_str(&data).unwrap();
265
266 assert_eq!(loaded.version, 1);
267 assert_eq!(loaded.projects.len(), 1);
268 }
269
270 #[test]
271 fn test_record_access() {
272 let mut index = ProjectIndex::new();
273
274 let test_path = "/test/path";
276 let mut project = Project {
277 path: PathBuf::from(test_path),
278 name: "test".to_string(),
279 last_modified: Utc::now(),
280 git_status: crate::px::project::ProjectGitStatus {
281 current_branch: "main".to_string(),
282 has_uncommitted: false,
283 ahead: 0,
284 behind: 0,
285 last_commit: None,
286 },
287 frecency_score: 0.0,
288 last_accessed: None,
289 access_count: 0,
290 readme_excerpt: None,
291 };
292
293 index.projects.insert(test_path.to_string(), project);
294
295 let project = index.projects.get_mut(test_path).unwrap();
297 project.access_count += 1;
298 project.last_accessed = Some(Utc::now());
299 project.update_frecency_score();
300
301 assert_eq!(project.access_count, 1);
302 assert!(project.last_accessed.is_some());
303 assert!(project.frecency_score > 0.0);
304 }
305}
306