1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Project {
16 pub path: PathBuf,
18
19 pub name: String,
21
22 #[serde(with = "chrono::serde::ts_seconds")]
24 pub last_modified: DateTime<Utc>,
25
26 pub git_status: ProjectGitStatus,
28
29 pub frecency_score: f64,
31
32 #[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 pub access_count: u32,
42
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub readme_excerpt: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ProjectGitStatus {
51 pub current_branch: String,
53
54 pub has_uncommitted: bool,
56
57 pub ahead: usize,
59
60 pub behind: usize,
62
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub last_commit: Option<CommitInfo>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CommitInfo {
71 pub hash: String,
73
74 pub message: String,
76
77 pub author: String,
79
80 #[serde(with = "chrono::serde::ts_seconds")]
82 pub timestamp: DateTime<Utc>,
83}
84
85impl Project {
86 pub fn from_git_repo(path: PathBuf) -> Result<Self> {
91 if !path.is_dir() {
93 return Err(FsError::InvalidFormat {
94 format: format!("{} is not a directory", path.display()),
95 });
96 }
97
98 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 let name = path
107 .file_name()
108 .and_then(|n| n.to_str())
109 .unwrap_or("unknown")
110 .to_string();
111
112 let git_status = Self::get_git_status(&path)?;
114
115 let last_modified = Self::get_last_modified_time(&path, &git_status)?;
117
118 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 fn get_git_status(repo_path: &Path) -> Result<ProjectGitStatus> {
135 let current_branch = Self::get_current_branch(repo_path)?;
137
138 let has_uncommitted = Self::has_uncommitted_changes(repo_path)?;
140
141 let (ahead, behind) = Self::get_ahead_behind(repo_path)?;
143
144 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 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 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 fn get_ahead_behind(repo_path: &Path) -> Result<(usize, usize)> {
195 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)), }
216 }
217
218 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 fn get_last_modified_time(
263 repo_path: &Path,
264 git_status: &ProjectGitStatus,
265 ) -> Result<DateTime<Utc>> {
266 if let Some(commit) = &git_status.last_commit {
268 return Ok(commit.timestamp);
269 }
270
271 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 pub fn extract_readme_excerpt(repo_path: &Path) -> Option<String> {
289 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 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 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 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 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 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}