rust_filesearch/px/
project.rs

1//! Project data structures and operations
2//!
3//! Represents a git repository project with metadata, git status,
4//! and frecency tracking for intelligent ranking.
5
6use crate::errors::{FsError, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13/// Represents a project (git repository) with metadata and access tracking
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Project {
16    /// Absolute path to project root
17    pub path: PathBuf,
18
19    /// Project name (directory name)
20    pub name: String,
21
22    /// Last modified time (from git or filesystem)
23    #[serde(with = "chrono::serde::ts_seconds")]
24    pub last_modified: DateTime<Utc>,
25
26    /// Git status information
27    pub git_status: ProjectGitStatus,
28
29    /// Frecency score (frequency + recency)
30    pub frecency_score: f64,
31
32    /// Last accessed timestamp
33    #[serde(
34        with = "chrono::serde::ts_seconds_option",
35        skip_serializing_if = "Option::is_none",
36        default
37    )]
38    pub last_accessed: Option<DateTime<Utc>>,
39
40    /// Access count for frecency calculation
41    pub access_count: u32,
42
43    /// First line of README (if exists)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub readme_excerpt: Option<String>,
46}
47
48/// Git repository status information
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ProjectGitStatus {
51    /// Current branch name
52    pub current_branch: String,
53
54    /// Has uncommitted changes (modified, staged, or untracked files)
55    pub has_uncommitted: bool,
56
57    /// Commits ahead of remote
58    pub ahead: usize,
59
60    /// Commits behind remote
61    pub behind: usize,
62
63    /// Most recent commit information
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub last_commit: Option<CommitInfo>,
66}
67
68/// Information about a git commit
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CommitInfo {
71    /// Short commit hash
72    pub hash: String,
73
74    /// Commit message (first line)
75    pub message: String,
76
77    /// Commit author name
78    pub author: String,
79
80    /// Commit timestamp
81    #[serde(with = "chrono::serde::ts_seconds")]
82    pub timestamp: DateTime<Utc>,
83}
84
85impl Project {
86    /// Create a Project from a git repository path
87    ///
88    /// Extracts git status, last commit, README excerpt, and initializes
89    /// frecency tracking fields.
90    pub fn from_git_repo(path: PathBuf) -> Result<Self> {
91        // Validate that path is a directory
92        if !path.is_dir() {
93            return Err(FsError::InvalidFormat {
94                format: format!("{} is not a directory", path.display()),
95            });
96        }
97
98        // Check if it's a git repository
99        if !crate::fs::git::is_git_repo(&path) {
100            return Err(FsError::InvalidFormat {
101                format: format!("{} is not a git repository", path.display()),
102            });
103        }
104
105        // Extract project name from directory name
106        let name = path
107            .file_name()
108            .and_then(|n| n.to_str())
109            .unwrap_or("unknown")
110            .to_string();
111
112        // Get git status information
113        let git_status = Self::get_git_status(&path)?;
114
115        // Get last modified time (from git or filesystem)
116        let last_modified = Self::get_last_modified_time(&path, &git_status)?;
117
118        // Extract README excerpt
119        let readme_excerpt = Self::extract_readme_excerpt(&path);
120
121        Ok(Project {
122            path,
123            name,
124            last_modified,
125            git_status,
126            frecency_score: 0.0,
127            last_accessed: None,
128            access_count: 0,
129            readme_excerpt,
130        })
131    }
132
133    /// Get comprehensive git status for a repository
134    fn get_git_status(repo_path: &Path) -> Result<ProjectGitStatus> {
135        // Get current branch
136        let current_branch = Self::get_current_branch(repo_path)?;
137
138        // Check for uncommitted changes
139        let has_uncommitted = Self::has_uncommitted_changes(repo_path)?;
140
141        // Get ahead/behind counts
142        let (ahead, behind) = Self::get_ahead_behind(repo_path)?;
143
144        // Get last commit info
145        let last_commit = Self::get_last_commit(repo_path).ok();
146
147        Ok(ProjectGitStatus {
148            current_branch,
149            has_uncommitted,
150            ahead,
151            behind,
152            last_commit,
153        })
154    }
155
156    /// Get the current branch name
157    fn get_current_branch(repo_path: &Path) -> Result<String> {
158        let output = Command::new("git")
159            .args(["branch", "--show-current"])
160            .current_dir(repo_path)
161            .output()
162            .map_err(|e| FsError::IoError {
163                context: "Failed to get git branch".to_string(),
164                source: e,
165            })?;
166
167        if !output.status.success() {
168            return Ok("(detached)".to_string());
169        }
170
171        let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
172        Ok(if branch.is_empty() {
173            "(detached)".to_string()
174        } else {
175            branch
176        })
177    }
178
179    /// Check if repository has uncommitted changes
180    fn has_uncommitted_changes(repo_path: &Path) -> Result<bool> {
181        let output = Command::new("git")
182            .args(["status", "--porcelain"])
183            .current_dir(repo_path)
184            .output()
185            .map_err(|e| FsError::IoError {
186                context: "Failed to check git status".to_string(),
187                source: e,
188            })?;
189
190        Ok(!output.stdout.is_empty())
191    }
192
193    /// Get commits ahead/behind of remote
194    fn get_ahead_behind(repo_path: &Path) -> Result<(usize, usize)> {
195        // Try to get upstream branch
196        let output = Command::new("git")
197            .args(["rev-list", "--left-right", "--count", "HEAD...@{u}"])
198            .current_dir(repo_path)
199            .output();
200
201        match output {
202            Ok(output) if output.status.success() => {
203                let counts = String::from_utf8_lossy(&output.stdout);
204                let parts: Vec<&str> = counts.trim().split_whitespace().collect();
205
206                if parts.len() == 2 {
207                    let ahead = parts[0].parse().unwrap_or(0);
208                    let behind = parts[1].parse().unwrap_or(0);
209                    return Ok((ahead, behind));
210                }
211
212                Ok((0, 0))
213            }
214            _ => Ok((0, 0)), // No upstream or error - return (0, 0)
215        }
216    }
217
218    /// Get information about the last commit
219    fn get_last_commit(repo_path: &Path) -> Result<CommitInfo> {
220        let output = Command::new("git")
221            .args([
222                "log",
223                "-1",
224                "--format=%h|%s|%an|%at",
225                "--date=unix",
226            ])
227            .current_dir(repo_path)
228            .output()
229            .map_err(|e| FsError::IoError {
230                context: "Failed to get last commit".to_string(),
231                source: e,
232            })?;
233
234        if !output.status.success() {
235            return Err(FsError::InvalidFormat {
236                format: "Failed to get last commit".to_string(),
237            });
238        }
239
240        let output_str = String::from_utf8_lossy(&output.stdout);
241        let parts: Vec<&str> = output_str.trim().split('|').collect();
242
243        if parts.len() < 4 {
244            return Err(FsError::InvalidFormat {
245                format: "Invalid git log format".to_string(),
246            });
247        }
248
249        let timestamp_secs: i64 = parts[3].parse().map_err(|_| FsError::InvalidFormat {
250            format: "Invalid timestamp".to_string(),
251        })?;
252
253        Ok(CommitInfo {
254            hash: parts[0].to_string(),
255            message: parts[1].to_string(),
256            author: parts[2].to_string(),
257            timestamp: DateTime::from_timestamp(timestamp_secs, 0).unwrap_or_else(Utc::now),
258        })
259    }
260
261    /// Get last modified time from git or filesystem
262    fn get_last_modified_time(
263        repo_path: &Path,
264        git_status: &ProjectGitStatus,
265    ) -> Result<DateTime<Utc>> {
266        // Use last commit timestamp if available
267        if let Some(commit) = &git_status.last_commit {
268            return Ok(commit.timestamp);
269        }
270
271        // Fallback to filesystem modified time
272        let metadata = fs::metadata(repo_path).map_err(|e| FsError::IoError {
273            context: format!("Failed to read directory metadata: {}", repo_path.display()),
274            source: e,
275        })?;
276
277        let modified = metadata
278            .modified()
279            .map_err(|e| FsError::IoError {
280                context: "Failed to get modified time".to_string(),
281                source: e,
282            })?;
283
284        Ok(DateTime::from(modified))
285    }
286
287    /// Extract first line of README file
288    pub fn extract_readme_excerpt(repo_path: &Path) -> Option<String> {
289        // Common README file names
290        let readme_names = ["README.md", "README.MD", "readme.md", "README", "Readme.md"];
291
292        for name in &readme_names {
293            let readme_path = repo_path.join(name);
294            if let Ok(content) = fs::read_to_string(&readme_path) {
295                // Get first non-empty line, skip markdown heading markers
296                for line in content.lines() {
297                    let trimmed = line.trim().trim_start_matches('#').trim();
298                    if !trimmed.is_empty() {
299                        return Some(trimmed.to_string());
300                    }
301                }
302            }
303        }
304
305        None
306    }
307
308    /// Update frecency score based on current access_count and last_accessed
309    ///
310    /// This should be called after updating access tracking fields.
311    pub fn update_frecency_score(&mut self) {
312        self.frecency_score = crate::px::frecency::calculate_frecency(
313            self.access_count,
314            self.last_accessed,
315        );
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_extract_readme_excerpt() {
325        use std::fs;
326        use tempfile::TempDir;
327
328        let temp_dir = TempDir::new().unwrap();
329        let readme_path = temp_dir.path().join("README.md");
330
331        // Test with markdown heading
332        fs::write(&readme_path, "# My Project\nDescription here").unwrap();
333        let excerpt = Project::extract_readme_excerpt(temp_dir.path());
334        assert_eq!(excerpt, Some("My Project".to_string()));
335
336        // Test with plain text
337        fs::write(&readme_path, "Simple description").unwrap();
338        let excerpt = Project::extract_readme_excerpt(temp_dir.path());
339        assert_eq!(excerpt, Some("Simple description".to_string()));
340
341        // Test with empty lines
342        fs::write(&readme_path, "\n\n# Title\n").unwrap();
343        let excerpt = Project::extract_readme_excerpt(temp_dir.path());
344        assert_eq!(excerpt, Some("Title".to_string()));
345    }
346}