1use std::path::{Path, PathBuf};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum ProjectType {
16 Rust,
18 Python,
20 JavaScript,
22 TypeScript,
24 Go,
26 Java,
28 Unknown,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "snake_case")]
35pub enum ChangeType {
36 Created,
38 Modified,
40 Deleted,
42 Renamed(String),
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ActiveFile {
49 pub path: PathBuf,
51 pub language: String,
53 pub last_modified: DateTime<Utc>,
55 pub line_count: usize,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct FileChange {
62 pub path: PathBuf,
64 pub change_type: ChangeType,
66 pub timestamp: DateTime<Utc>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct GitInfo {
73 pub branch: String,
75 pub commit: String,
77 pub is_dirty: bool,
79 pub remote_url: Option<String>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct WorkspaceContext {
86 pub root_path: PathBuf,
88 pub project_type: Option<ProjectType>,
90 pub active_files: Vec<ActiveFile>,
92 pub recent_changes: Vec<FileChange>,
94 pub git_info: Option<GitInfo>,
96}
97
98impl WorkspaceContext {
99 pub fn new(root_path: PathBuf) -> Self {
101 let project_type = Self::detect_project_type(&root_path);
102 Self {
103 root_path,
104 project_type,
105 active_files: Vec::new(),
106 recent_changes: Vec::new(),
107 git_info: None,
108 }
109 }
110
111 pub fn detect_project_type(root: &Path) -> Option<ProjectType> {
113 if root.join("Cargo.toml").exists() {
114 Some(ProjectType::Rust)
115 } else if root.join("go.mod").exists() {
116 Some(ProjectType::Go)
117 } else if root.join("tsconfig.json").exists() {
118 Some(ProjectType::TypeScript)
119 } else if root.join("package.json").exists() {
120 Some(ProjectType::JavaScript)
121 } else if root.join("pyproject.toml").exists()
122 || root.join("setup.py").exists()
123 || root.join("requirements.txt").exists()
124 {
125 Some(ProjectType::Python)
126 } else if root.join("pom.xml").exists() || root.join("build.gradle").exists() {
127 Some(ProjectType::Java)
128 } else {
129 None
130 }
131 }
132
133 pub fn add_active_file(&mut self, file: ActiveFile) {
135 self.active_files.push(file);
136 }
137
138 pub fn record_change(&mut self, change: FileChange) {
140 self.recent_changes.push(change);
141 }
142
143 pub fn recent_files(&self, limit: usize) -> Vec<&ActiveFile> {
145 let mut files: Vec<&ActiveFile> = self.active_files.iter().collect();
146 files.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
147 files.truncate(limit);
148 files
149 }
150
151 pub fn summary(&self) -> String {
153 let mut parts = Vec::new();
154
155 parts.push(format!("Workspace: {}", self.root_path.display()));
156
157 if let Some(ref pt) = self.project_type {
158 parts.push(format!("Project type: {pt:?}"));
159 }
160
161 if !self.active_files.is_empty() {
162 parts.push(format!("Active files: {}", self.active_files.len()));
163 for file in self.recent_files(5) {
164 parts.push(format!(
165 " - {} ({}, {} lines)",
166 file.path.display(),
167 file.language,
168 file.line_count
169 ));
170 }
171 }
172
173 if !self.recent_changes.is_empty() {
174 parts.push(format!("Recent changes: {}", self.recent_changes.len()));
175 }
176
177 if let Some(ref git) = self.git_info {
178 parts.push(format!("Git branch: {}", git.branch));
179 parts.push(format!("Git commit: {}", &git.commit));
180 if git.is_dirty {
181 parts.push("Git status: dirty (uncommitted changes)".to_string());
182 }
183 }
184
185 parts.join("\n")
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use std::fs;
193 use tempfile::TempDir;
194
195 fn temp_dir_with_file(filename: &str) -> TempDir {
196 let dir = TempDir::new().expect("create temp dir");
197 fs::write(dir.path().join(filename), "").expect("create marker file");
198 dir
199 }
200
201 #[test]
202 fn test_detect_project_type_rust() {
203 let dir = temp_dir_with_file("Cargo.toml");
204 let detected = WorkspaceContext::detect_project_type(dir.path());
205 assert_eq!(detected, Some(ProjectType::Rust));
206 }
207
208 #[test]
209 fn test_detect_project_type_javascript() {
210 let dir = temp_dir_with_file("package.json");
211 let detected = WorkspaceContext::detect_project_type(dir.path());
212 assert_eq!(detected, Some(ProjectType::JavaScript));
213 }
214
215 #[test]
216 fn test_active_files() {
217 let mut ctx = WorkspaceContext {
218 root_path: PathBuf::from("/tmp/project"),
219 project_type: Some(ProjectType::Rust),
220 active_files: Vec::new(),
221 recent_changes: Vec::new(),
222 git_info: None,
223 };
224
225 let file1 = ActiveFile {
226 path: PathBuf::from("src/main.rs"),
227 language: "rust".to_string(),
228 last_modified: Utc::now(),
229 line_count: 100,
230 };
231
232 let file2 = ActiveFile {
233 path: PathBuf::from("src/lib.rs"),
234 language: "rust".to_string(),
235 last_modified: Utc::now(),
236 line_count: 250,
237 };
238
239 ctx.add_active_file(file1);
240 ctx.add_active_file(file2);
241
242 assert_eq!(ctx.active_files.len(), 2);
243
244 let recent = ctx.recent_files(1);
245 assert_eq!(recent.len(), 1);
246 }
247
248 #[test]
249 fn test_recent_changes() {
250 let mut ctx = WorkspaceContext::new(PathBuf::from("/tmp/project"));
251
252 ctx.record_change(FileChange {
253 path: PathBuf::from("src/main.rs"),
254 change_type: ChangeType::Modified,
255 timestamp: Utc::now(),
256 });
257
258 ctx.record_change(FileChange {
259 path: PathBuf::from("src/new_module.rs"),
260 change_type: ChangeType::Created,
261 timestamp: Utc::now(),
262 });
263
264 ctx.record_change(FileChange {
265 path: PathBuf::from("src/old.rs"),
266 change_type: ChangeType::Renamed("src/new.rs".to_string()),
267 timestamp: Utc::now(),
268 });
269
270 assert_eq!(ctx.recent_changes.len(), 3);
271 assert_eq!(ctx.recent_changes[0].change_type, ChangeType::Modified);
272 assert_eq!(ctx.recent_changes[1].change_type, ChangeType::Created);
273 }
274
275 #[test]
276 fn test_summary_generation() {
277 let mut ctx = WorkspaceContext {
278 root_path: PathBuf::from("/home/fighter/project"),
279 project_type: Some(ProjectType::Rust),
280 active_files: vec![ActiveFile {
281 path: PathBuf::from("src/main.rs"),
282 language: "rust".to_string(),
283 last_modified: Utc::now(),
284 line_count: 42,
285 }],
286 recent_changes: Vec::new(),
287 git_info: Some(GitInfo {
288 branch: "main".to_string(),
289 commit: "abc1234".to_string(),
290 is_dirty: true,
291 remote_url: Some("https://github.com/humancto/punch".to_string()),
292 }),
293 };
294
295 ctx.record_change(FileChange {
296 path: PathBuf::from("src/main.rs"),
297 change_type: ChangeType::Modified,
298 timestamp: Utc::now(),
299 });
300
301 let summary = ctx.summary();
302
303 assert!(summary.contains("/home/fighter/project"));
304 assert!(summary.contains("Rust"));
305 assert!(summary.contains("Active files: 1"));
306 assert!(summary.contains("src/main.rs"));
307 assert!(summary.contains("42 lines"));
308 assert!(summary.contains("Git branch: main"));
309 assert!(summary.contains("dirty"));
310 }
311
312 #[test]
313 fn test_git_info() {
314 let git = GitInfo {
315 branch: "feat/new-move".to_string(),
316 commit: "deadbeef1234567890".to_string(),
317 is_dirty: false,
318 remote_url: Some("git@github.com:humancto/punch.git".to_string()),
319 };
320
321 let json = serde_json::to_string(&git).expect("serialize git info");
322 let deser: GitInfo = serde_json::from_str(&json).expect("deserialize git info");
323
324 assert_eq!(deser.branch, "feat/new-move");
325 assert_eq!(deser.commit, "deadbeef1234567890");
326 assert!(!deser.is_dirty);
327 assert!(deser.remote_url.is_some());
328 }
329}