Skip to main content

punch_types/
workspace.rs

1//! # Workspace Context — mapping the battlefield before the fight begins.
2//!
3//! This module tracks the working context of an agent's environment, including
4//! project structure, open files, recent changes, and git status, providing
5//! situational awareness for tactical decisions.
6
7use std::path::{Path, PathBuf};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12/// The type of project detected in the workspace — identifying the fighting discipline.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum ProjectType {
16    /// Rust project (Cargo.toml).
17    Rust,
18    /// Python project (pyproject.toml, setup.py, requirements.txt).
19    Python,
20    /// JavaScript project (package.json without tsconfig).
21    JavaScript,
22    /// TypeScript project (tsconfig.json).
23    TypeScript,
24    /// Go project (go.mod).
25    Go,
26    /// Java project (pom.xml, build.gradle).
27    Java,
28    /// Unknown or unrecognized project type.
29    Unknown,
30}
31
32/// The type of change made to a file — classifying the maneuver.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "snake_case")]
35pub enum ChangeType {
36    /// A new file was created.
37    Created,
38    /// An existing file was modified.
39    Modified,
40    /// A file was deleted.
41    Deleted,
42    /// A file was renamed (contains the old name).
43    Renamed(String),
44}
45
46/// A currently active/open file — a weapon in the fighter's hands.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ActiveFile {
49    /// Path to the file.
50    pub path: PathBuf,
51    /// Programming language of the file.
52    pub language: String,
53    /// When the file was last modified.
54    pub last_modified: DateTime<Utc>,
55    /// Number of lines in the file.
56    pub line_count: usize,
57}
58
59/// A recorded file change — a move logged in the fight record.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct FileChange {
62    /// Path to the changed file.
63    pub path: PathBuf,
64    /// Type of change.
65    pub change_type: ChangeType,
66    /// When the change occurred.
67    pub timestamp: DateTime<Utc>,
68}
69
70/// Git repository information — the battle formation's version control intel.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct GitInfo {
73    /// Current branch name.
74    pub branch: String,
75    /// Current commit hash.
76    pub commit: String,
77    /// Whether there are uncommitted changes.
78    pub is_dirty: bool,
79    /// Remote URL if configured.
80    pub remote_url: Option<String>,
81}
82
83/// The full workspace context — complete situational awareness for the fighter.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct WorkspaceContext {
86    /// Root path of the workspace/project.
87    pub root_path: PathBuf,
88    /// Detected project type.
89    pub project_type: Option<ProjectType>,
90    /// Currently active/open files.
91    pub active_files: Vec<ActiveFile>,
92    /// Recent file changes.
93    pub recent_changes: Vec<FileChange>,
94    /// Git repository information.
95    pub git_info: Option<GitInfo>,
96}
97
98impl WorkspaceContext {
99    /// Create a new workspace context for the given root path — enter the arena.
100    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    /// Detect the project type from marker files in the root directory — identify the fighting style.
112    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    /// Add an active file to the context — equip a new weapon.
134    pub fn add_active_file(&mut self, file: ActiveFile) {
135        self.active_files.push(file);
136    }
137
138    /// Record a file change — log a combat move.
139    pub fn record_change(&mut self, change: FileChange) {
140        self.recent_changes.push(change);
141    }
142
143    /// Get the most recently active files — review the fighter's current loadout.
144    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    /// Generate a text summary of the workspace — the battlefield briefing for the system prompt.
152    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}