rust_filesearch/px/
index.rs

1//! Project index management
2//!
3//! Handles loading, saving, and syncing the project index.
4//! The index is cached as JSON in ~/.cache/px/projects.json
5
6use 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/// Project index with caching and sync capabilities
17#[derive(Debug, Serialize, Deserialize)]
18pub struct ProjectIndex {
19    /// All discovered projects, keyed by absolute path
20    pub projects: HashMap<String, Project>,
21
22    /// Last sync timestamp
23    #[serde(with = "chrono::serde::ts_seconds")]
24    pub last_sync: DateTime<Utc>,
25
26    /// Schema version for future migrations
27    pub version: u32,
28}
29
30impl ProjectIndex {
31    /// Create a new empty project index
32    pub fn new() -> Self {
33        Self {
34            projects: HashMap::new(),
35            last_sync: Utc::now(),
36            version: 1,
37        }
38    }
39
40    /// Load index from cache file (~/.cache/px/projects.json)
41    ///
42    /// If the cache file doesn't exist or can't be read, returns a new empty index.
43    /// This makes the first run seamless - users just run `px sync` to populate.
44    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    /// Save index to cache file
66    ///
67    /// Creates the cache directory if it doesn't exist.
68    pub fn save(&self) -> Result<()> {
69        let cache_path = Self::cache_path()?;
70
71        // Ensure cache directory exists
72        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        // Serialize to pretty JSON for readability
80        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    /// Sync/rebuild the project index by scanning configured directories
93    ///
94    /// This is the core indexing operation that:
95    /// 1. Scans all configured directories for git repositories
96    /// 2. Extracts metadata for each project
97    /// 3. Preserves frecency data for existing projects
98    /// 4. Saves the updated index to disk
99    ///
100    /// Returns the number of projects found.
101    pub fn sync(&mut self, scan_dirs: &[PathBuf]) -> Result<usize> {
102        let mut new_projects = HashMap::new();
103
104        // Traverse each scan directory
105        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            // Configure traversal for git repo discovery
115            let config = TraverseConfig {
116                max_depth: Some(3), // Don't go too deep
117                follow_symlinks: false,
118                include_hidden: false,
119                respect_gitignore: true,
120                threads: 4, // Parallel scan (feature enabled by default)
121                quiet: true, // Suppress permission errors
122            };
123
124            // Use existing fexplorer traverse infrastructure
125            let entries = walk_no_filter(scan_dir, &config)?;
126
127            // Filter for git repositories
128            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                    // Try to create Project from git repo
133                    match Project::from_git_repo(entry.path.clone()) {
134                        Ok(mut project) => {
135                            // Preserve frecency data if project already exists
136                            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                            // Log error but continue indexing
146                            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        // Persist to disk
162        self.save()?;
163
164        Ok(count)
165    }
166
167    /// Record project access for frecency tracking
168    ///
169    /// Updates access_count, last_accessed, and recalculates frecency_score.
170    /// Changes are persisted to disk immediately.
171    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            // Persist immediately (write-through cache)
178            self.save()?;
179        }
180
181        Ok(())
182    }
183
184    /// Get the cache file path (~/.cache/px/projects.json)
185    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    /// Get projects as a sorted vector (by frecency)
194    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        // Create temp cache directory
227        let temp_dir = TempDir::new().unwrap();
228        let cache_path = temp_dir.path().join("test_cache.json");
229
230        // Create index with a test project
231        let mut index = ProjectIndex::new();
232
233        // Manually create a project for testing
234        let test_project = Project::from_git_repo(PathBuf::from(".")).unwrap_or_else(|_| {
235            // Fallback if current dir is not a git repo
236            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        // Save
259        let json = serde_json::to_string_pretty(&index).unwrap();
260        fs::write(&cache_path, json).unwrap();
261
262        // Load
263        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        // Create a test project
275        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        // Record access (skip save for test)
296        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