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 default_dry_run_publish_command(&self) -> Option<String> {
102            Some("echo test --dry-run".to_string())
103        }
104        fn inherits_workspace_version(&self) -> bool {
105            false
106        }
107        fn workspace_root_path(&self) -> Option<&Path> {
108            None
109        }
110    }
111
112    #[derive(Debug)]
113    struct MockWorkspace {
114        name: Option<String>,
115        path: PathBuf,
116        relative_path: PathBuf,
117        changed: bool,
118        dependencies: HashSet<String>,
119    }
120
121    impl MockWorkspace {
122        fn new(name: &str, path: &str) -> Self {
123            Self {
124                name: Some(name.to_string()),
125                path: PathBuf::from(path),
126                relative_path: PathBuf::from(path),
127                changed: false,
128                dependencies: HashSet::new(),
129            }
130        }
131    }
132
133    #[async_trait]
134    impl Workspace for MockWorkspace {
135        fn name(&self) -> Option<&str> {
136            self.name.as_deref()
137        }
138        fn path(&self) -> &Path {
139            &self.path
140        }
141        fn relative_path(&self) -> &Path {
142            &self.relative_path
143        }
144        fn version(&self) -> Option<&str> {
145            Some("1.0.0")
146        }
147        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
148            Ok(())
149        }
150        fn language(&self) -> Language {
151            Language::Node
152        }
153        fn dependencies(&self) -> &HashSet<String> {
154            &self.dependencies
155        }
156        fn add_dependency(&mut self, dep: &str) {
157            self.dependencies.insert(dep.to_string());
158        }
159        fn is_changed(&self) -> bool {
160            self.changed
161        }
162        fn set_changed(&mut self, changed: bool) {
163            self.changed = changed;
164        }
165        fn default_publish_command(&self) -> String {
166            "echo test".to_string()
167        }
168        fn default_dry_run_publish_command(&self) -> Option<String> {
169            Some("echo test --dry-run".to_string())
170        }
171    }
172
173    #[derive(Debug)]
174    struct MockProjectFinder {
175        projects: Vec<Project>,
176    }
177
178    impl MockProjectFinder {
179        fn new() -> Self {
180            Self { projects: vec![] }
181        }
182
183        fn with_package(mut self, package: MockPackage) -> Self {
184            self.projects.push(Project::Package(Box::new(package)));
185            self
186        }
187
188        fn with_workspace(mut self, workspace: MockWorkspace) -> Self {
189            self.projects.push(Project::Workspace(Box::new(workspace)));
190            self
191        }
192    }
193
194    #[async_trait]
195    impl ProjectFinder for MockProjectFinder {
196        fn projects(&self) -> Vec<&Project> {
197            self.projects.iter().collect()
198        }
199
200        fn projects_mut(&mut self) -> Vec<&mut Project> {
201            self.projects.iter_mut().collect()
202        }
203
204        fn project_files(&self) -> &[&str] {
205            &["package.json"]
206        }
207
208        async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
209            Ok(())
210        }
211    }
212
213    #[test]
214    fn test_project_finder_check_changed() {
215        let package = MockPackage::new("test", "/project/package.json");
216        let mut finder = MockProjectFinder::new().with_package(package);
217
218        // Check a file that's in the project directory
219        finder
220            .check_changed(Path::new("/project/src/index.js"))
221            .unwrap();
222
223        // The project should be marked as changed
224        assert!(finder.projects()[0].is_changed());
225    }
226
227    #[test]
228    fn test_project_finder_check_changed_multiple_projects() {
229        let package1 = MockPackage::new("pkg1", "/project1/package.json");
230        let package2 = MockPackage::new("pkg2", "/project2/package.json");
231        let mut finder = MockProjectFinder::new()
232            .with_package(package1)
233            .with_package(package2);
234
235        // Check a file in project1 only
236        finder
237            .check_changed(Path::new("/project1/src/index.js"))
238            .unwrap();
239
240        // Only project1 should be changed
241        assert!(finder.projects()[0].is_changed());
242        assert!(!finder.projects()[1].is_changed());
243    }
244
245    #[test]
246    fn test_project_finder_with_workspace() {
247        let workspace = MockWorkspace::new("root", "/project/package.json");
248        let mut finder = MockProjectFinder::new().with_workspace(workspace);
249
250        finder
251            .check_changed(Path::new("/project/src/index.js"))
252            .unwrap();
253
254        assert!(finder.projects()[0].is_changed());
255    }
256
257    #[tokio::test]
258    async fn test_project_finder_finalize() {
259        let mut finder = MockProjectFinder::new();
260        let result = finder.finalize().await;
261        assert!(result.is_ok());
262    }
263
264    #[tokio::test]
265    async fn test_project_finder_finalize_with_projects() {
266        let package = MockPackage::new("pkg1", "/project/package.json");
267        let mut finder = MockProjectFinder::new().with_package(package);
268        let result = finder.finalize().await;
269        assert!(result.is_ok());
270        assert_eq!(finder.projects().len(), 1);
271    }
272}