Skip to main content

changepacks_core/
project_finder.rs

1use std::path::Path;
2
3use crate::project::Project;
4use anyhow::Result;
5use async_trait::async_trait;
6
7/// Visitor pattern for discovering projects by walking the git tree.
8///
9/// Each language implements this trait to detect its project files (package.json, Cargo.toml, etc.)
10/// and build a collection of projects. The `visit` method is called for each file in the git tree.
11#[async_trait]
12pub trait ProjectFinder: std::fmt::Debug + Send + Sync {
13    fn projects(&self) -> Vec<&Project>;
14    fn projects_mut(&mut self) -> Vec<&mut Project>;
15    fn project_files(&self) -> &[&str];
16    /// # Errors
17    /// Returns error if the file visitation fails.
18    async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()>;
19    /// # Errors
20    /// Returns error if checking changed status fails for any project.
21    fn check_changed(&mut self, path: &Path) -> Result<()> {
22        for project in self.projects_mut() {
23            project.check_changed(path)?;
24        }
25        Ok(())
26    }
27    /// Post-visit processing hook for resolving deferred state (e.g., workspace-inherited versions).
28    /// Called once after all `visit()` calls complete.
29    /// # Errors
30    /// Returns error if finalization fails.
31    #[cfg(not(tarpaulin_include))]
32    async fn finalize(&mut self) -> Result<()> {
33        Ok(())
34    }
35}
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40    use crate::{Language, Package, UpdateType, Workspace};
41    use async_trait::async_trait;
42    use std::collections::HashSet;
43    use std::path::PathBuf;
44
45    #[derive(Debug)]
46    struct MockPackage {
47        name: Option<String>,
48        path: PathBuf,
49        relative_path: PathBuf,
50        changed: bool,
51        dependencies: HashSet<String>,
52    }
53
54    impl MockPackage {
55        fn new(name: &str, path: &str) -> Self {
56            Self {
57                name: Some(name.to_string()),
58                path: PathBuf::from(path),
59                relative_path: PathBuf::from(path),
60                changed: false,
61                dependencies: HashSet::new(),
62            }
63        }
64    }
65
66    #[async_trait]
67    impl Package for MockPackage {
68        fn name(&self) -> Option<&str> {
69            self.name.as_deref()
70        }
71        fn version(&self) -> Option<&str> {
72            Some("1.0.0")
73        }
74        fn path(&self) -> &Path {
75            &self.path
76        }
77        fn relative_path(&self) -> &Path {
78            &self.relative_path
79        }
80        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
81            Ok(())
82        }
83        fn is_changed(&self) -> bool {
84            self.changed
85        }
86        fn language(&self) -> Language {
87            Language::Node
88        }
89        fn dependencies(&self) -> &HashSet<String> {
90            &self.dependencies
91        }
92        fn add_dependency(&mut self, dep: &str) {
93            self.dependencies.insert(dep.to_string());
94        }
95        fn set_changed(&mut self, changed: bool) {
96            self.changed = changed;
97        }
98        fn default_publish_command(&self) -> String {
99            "echo test".to_string()
100        }
101        fn inherits_workspace_version(&self) -> bool {
102            false
103        }
104        fn workspace_root_path(&self) -> Option<&Path> {
105            None
106        }
107    }
108
109    #[derive(Debug)]
110    struct MockWorkspace {
111        name: Option<String>,
112        path: PathBuf,
113        relative_path: PathBuf,
114        changed: bool,
115        dependencies: HashSet<String>,
116    }
117
118    impl MockWorkspace {
119        fn new(name: &str, path: &str) -> Self {
120            Self {
121                name: Some(name.to_string()),
122                path: PathBuf::from(path),
123                relative_path: PathBuf::from(path),
124                changed: false,
125                dependencies: HashSet::new(),
126            }
127        }
128    }
129
130    #[async_trait]
131    impl Workspace for MockWorkspace {
132        fn name(&self) -> Option<&str> {
133            self.name.as_deref()
134        }
135        fn path(&self) -> &Path {
136            &self.path
137        }
138        fn relative_path(&self) -> &Path {
139            &self.relative_path
140        }
141        fn version(&self) -> Option<&str> {
142            Some("1.0.0")
143        }
144        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
145            Ok(())
146        }
147        fn language(&self) -> Language {
148            Language::Node
149        }
150        fn dependencies(&self) -> &HashSet<String> {
151            &self.dependencies
152        }
153        fn add_dependency(&mut self, dep: &str) {
154            self.dependencies.insert(dep.to_string());
155        }
156        fn is_changed(&self) -> bool {
157            self.changed
158        }
159        fn set_changed(&mut self, changed: bool) {
160            self.changed = changed;
161        }
162        fn default_publish_command(&self) -> String {
163            "echo test".to_string()
164        }
165    }
166
167    #[derive(Debug)]
168    struct MockProjectFinder {
169        projects: Vec<Project>,
170    }
171
172    impl MockProjectFinder {
173        fn new() -> Self {
174            Self { projects: vec![] }
175        }
176
177        fn with_package(mut self, package: MockPackage) -> Self {
178            self.projects.push(Project::Package(Box::new(package)));
179            self
180        }
181
182        fn with_workspace(mut self, workspace: MockWorkspace) -> Self {
183            self.projects.push(Project::Workspace(Box::new(workspace)));
184            self
185        }
186    }
187
188    #[async_trait]
189    impl ProjectFinder for MockProjectFinder {
190        fn projects(&self) -> Vec<&Project> {
191            self.projects.iter().collect()
192        }
193
194        fn projects_mut(&mut self) -> Vec<&mut Project> {
195            self.projects.iter_mut().collect()
196        }
197
198        fn project_files(&self) -> &[&str] {
199            &["package.json"]
200        }
201
202        async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
203            Ok(())
204        }
205    }
206
207    #[test]
208    fn test_project_finder_check_changed() {
209        let package = MockPackage::new("test", "/project/package.json");
210        let mut finder = MockProjectFinder::new().with_package(package);
211
212        // Check a file that's in the project directory
213        finder
214            .check_changed(Path::new("/project/src/index.js"))
215            .unwrap();
216
217        // The project should be marked as changed
218        assert!(finder.projects()[0].is_changed());
219    }
220
221    #[test]
222    fn test_project_finder_check_changed_multiple_projects() {
223        let package1 = MockPackage::new("pkg1", "/project1/package.json");
224        let package2 = MockPackage::new("pkg2", "/project2/package.json");
225        let mut finder = MockProjectFinder::new()
226            .with_package(package1)
227            .with_package(package2);
228
229        // Check a file in project1 only
230        finder
231            .check_changed(Path::new("/project1/src/index.js"))
232            .unwrap();
233
234        // Only project1 should be changed
235        assert!(finder.projects()[0].is_changed());
236        assert!(!finder.projects()[1].is_changed());
237    }
238
239    #[test]
240    fn test_project_finder_with_workspace() {
241        let workspace = MockWorkspace::new("root", "/project/package.json");
242        let mut finder = MockProjectFinder::new().with_workspace(workspace);
243
244        finder
245            .check_changed(Path::new("/project/src/index.js"))
246            .unwrap();
247
248        assert!(finder.projects()[0].is_changed());
249    }
250
251    #[tokio::test]
252    async fn test_project_finder_finalize() {
253        let mut finder = MockProjectFinder::new();
254        let result = finder.finalize().await;
255        assert!(result.is_ok());
256    }
257
258    #[tokio::test]
259    async fn test_project_finder_finalize_with_projects() {
260        let package = MockPackage::new("pkg1", "/project/package.json");
261        let mut finder = MockProjectFinder::new().with_package(package);
262        let result = finder.finalize().await;
263        assert!(result.is_ok());
264        assert_eq!(finder.projects().len(), 1);
265    }
266}