Skip to main content

muster/adapter/cli/
projects.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4};
5
6use super::{
7    error::CliError,
8    report::{Row, RowKind},
9};
10use crate::{
11    adapter::path::absolutize,
12    constants::WORKSPACE_FILE_NAME,
13    domain::{port::ProjectRegistry, project::Project},
14};
15
16/// Marker prefixing the project that contains the current directory.
17const CURRENT_MARKER: &str = "* ";
18/// Indent for projects that are not current, aligning the columns.
19const OTHER_INDENT: &str = "  ";
20/// Line printed when no projects are registered.
21const EMPTY_NOTE: &str = "no registered projects; run `muster init` in a project folder";
22
23/// Lists registered projects, marking the one containing `current_dir`.
24///
25/// # Errors
26/// Returns [`CliError`] when the registry cannot be read.
27pub fn list(registry: &dyn ProjectRegistry, current_dir: &Path) -> Result<Vec<Row>, CliError> {
28    let projects = registry.projects()?;
29    if projects.is_empty() {
30        return Ok(vec![Row::unlabeled(RowKind::Hint, EMPTY_NOTE)]);
31    }
32    Ok(projects
33        .iter()
34        .map(|project| {
35            let marker = if is_current(project, current_dir) {
36                CURRENT_MARKER
37            } else {
38                OTHER_INDENT
39            };
40            let folder = absolutize(project.config())
41                .parent()
42                .map(|p| p.display().to_string())
43                .unwrap_or_else(|| project.config().display().to_string());
44            Row::unlabeled(
45                RowKind::Plain,
46                format!("{marker}{}  {folder}", project.name().as_ref()),
47            )
48        })
49        .collect())
50}
51
52/// Whether the project's folder contains the current directory. Both sides
53/// compare by canonical identity: the registry may hold a symlink alias while
54/// the OS reports the resolved working directory.
55fn is_current(project: &Project, current_dir: &Path) -> bool {
56    absolutize(project.config())
57        .parent()
58        .is_some_and(|folder| resolved_identity(current_dir).starts_with(resolved_identity(folder)))
59}
60
61/// The canonical form of a path when it exists, else the path itself.
62fn resolved_identity(path: &Path) -> PathBuf {
63    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
64}
65
66/// Registers an existing folder that already contains a workspace file.
67///
68/// # Errors
69/// Returns [`CliError::MissingWorkspaceFile`] when the folder has no
70/// `muster.yml`, when the path is a directory, or when it is a dangling
71/// symlink - anything other than a readable regular file.
72pub fn add(directory: &Path, registry: &dyn ProjectRegistry) -> Result<Vec<Row>, CliError> {
73    let config_path = absolutize(&directory.join(WORKSPACE_FILE_NAME));
74    if !fs::metadata(&config_path)
75        .map(|meta| meta.is_file())
76        .unwrap_or(false)
77    {
78        return Err(CliError::MissingWorkspaceFile(config_path));
79    }
80    let name = super::init::project_name(directory)?;
81    super::init::ensure_representable(&config_path)?;
82    Ok(vec![super::init::register_folder(
83        name,
84        &config_path,
85        registry,
86    )?])
87}
88
89/// Outcome captured inside the `update_projects` closure.
90enum RemoveOutcome {
91    Removed,
92    Unknown { known: String },
93    Ambiguous { count: usize, paths: String },
94}
95
96/// Unregisters the named project; files on disk are untouched.
97///
98/// The match-count check and removal run inside `update_projects` so the read
99/// and the write are atomic with respect to concurrent registry mutations.
100///
101/// # Errors
102/// Returns [`CliError::UnknownProjectAmong`] when no project has that name, or
103/// [`CliError::AmbiguousProject`] when more than one project shares the name.
104pub fn remove(name: &str, registry: &dyn ProjectRegistry) -> Result<Vec<Row>, CliError> {
105    let mut outcome = RemoveOutcome::Removed;
106
107    registry.update_projects(&mut |projects| {
108        let matches: Vec<&Project> = projects
109            .iter()
110            .filter(|p| p.name().as_ref() == name)
111            .collect();
112
113        if matches.is_empty() {
114            outcome = RemoveOutcome::Unknown {
115                known: projects
116                    .iter()
117                    .map(|p| p.name().as_ref().to_string())
118                    .collect::<Vec<_>>()
119                    .join(", "),
120            };
121            return projects;
122        }
123
124        if matches.len() > 1 {
125            let paths = matches
126                .iter()
127                .map(|p| p.config().display().to_string())
128                .collect::<Vec<_>>()
129                .join(", ");
130            outcome = RemoveOutcome::Ambiguous {
131                count: matches.len(),
132                paths,
133            };
134            return projects;
135        }
136
137        projects
138            .into_iter()
139            .filter(|p| p.name().as_ref() != name)
140            .collect()
141    })?;
142
143    match outcome {
144        RemoveOutcome::Removed => Ok(vec![Row::unlabeled(
145            RowKind::Ok,
146            format!("removed '{name}' from the registry"),
147        )]),
148        RemoveOutcome::Unknown { known } => Err(CliError::UnknownProjectAmong {
149            name: name.to_string(),
150            known,
151        }),
152        RemoveOutcome::Ambiguous { count, paths } => Err(CliError::AmbiguousProject {
153            name: name.to_string(),
154            count,
155            paths,
156        }),
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use std::{cell::RefCell, fs, path::PathBuf};
163
164    use super::*;
165    use crate::domain::{
166        config::{ConfigError, WorkspaceConfig},
167        project::Project,
168        value::ProjectName,
169    };
170
171    /// A registry recording saves of projects and workspaces.
172    #[derive(Default)]
173    struct RecordingRegistry {
174        projects: Vec<Project>,
175        saved_projects: RefCell<Option<Vec<Project>>>,
176    }
177
178    impl ProjectRegistry for RecordingRegistry {
179        fn projects(&self) -> Result<Vec<Project>, ConfigError> {
180            Ok(self.projects.clone())
181        }
182
183        fn workspace(&self, _config_path: &Path) -> Result<WorkspaceConfig, ConfigError> {
184            unreachable!("projects never loads a workspace")
185        }
186
187        fn workspace_exists(&self, _config_path: &Path) -> bool {
188            false
189        }
190
191        fn save(&self, projects: &[Project]) -> Result<(), ConfigError> {
192            *self.saved_projects.borrow_mut() = Some(projects.to_vec());
193            Ok(())
194        }
195
196        fn save_workspace(
197            &self,
198            _config_path: &Path,
199            _config: &WorkspaceConfig,
200        ) -> Result<(), ConfigError> {
201            unreachable!("projects never saves a workspace")
202        }
203    }
204
205    fn project(name: &str, config: &str) -> Project {
206        Project::builder()
207            .name(ProjectName::try_new(name).unwrap())
208            .config(PathBuf::from(config))
209            .build()
210    }
211
212    fn temp_dir(tag: &str) -> PathBuf {
213        let dir =
214            std::env::temp_dir().join(format!("muster-projects-{tag}-{}", uuid::Uuid::new_v4()));
215        fs::create_dir_all(&dir).unwrap();
216        dir
217    }
218
219    /// Listing marks the project containing the current directory.
220    #[test]
221    fn list_marks_the_current_project() {
222        let registry = RecordingRegistry {
223            projects: vec![
224                project("web", "/w/web/muster.yml"),
225                project("api", "/w/api/muster.yml"),
226            ],
227            ..RecordingRegistry::default()
228        };
229        let rows = list(&registry, Path::new("/w/api/src")).unwrap();
230        assert_eq!(rows.len(), 2);
231        assert!(!rows[0].detail().starts_with(CURRENT_MARKER));
232        assert!(rows[1].detail().starts_with(CURRENT_MARKER));
233        assert!(rows[1].detail().contains("api") && rows[1].detail().contains("/w/api"));
234        // The second column must be the project folder, not the config file path.
235        assert!(!rows[1].detail().contains("muster.yml"));
236    }
237
238    /// A project registered through a symlink alias still gets the current
239    /// marker when the working directory is its resolved target.
240    #[cfg(unix)]
241    #[test]
242    fn list_marks_a_symlinked_current_project() {
243        use std::os::unix::fs::symlink;
244
245        let base = temp_dir("symlink-current");
246        let real = base.join("real");
247        let link = base.join("link");
248        fs::create_dir_all(real.join("src")).unwrap();
249        symlink(&real, &link).unwrap();
250        let registry = RecordingRegistry {
251            projects: vec![
252                Project::builder()
253                    .name(ProjectName::try_new("here").unwrap())
254                    .config(link.join(WORKSPACE_FILE_NAME))
255                    .build(),
256            ],
257            ..RecordingRegistry::default()
258        };
259
260        let rows = list(&registry, &real.join("src")).unwrap();
261
262        assert!(
263            rows[0].detail().starts_with(CURRENT_MARKER),
264            "resolved cwd matches the aliased registration: {}",
265            rows[0].detail()
266        );
267        fs::remove_dir_all(base).unwrap();
268    }
269
270    /// An empty registry says so instead of printing nothing.
271    #[test]
272    fn list_reports_an_empty_registry() {
273        let registry = RecordingRegistry::default();
274        let rows = list(&registry, Path::new("/")).unwrap();
275        assert_eq!(rows[0].detail(), EMPTY_NOTE);
276    }
277
278    /// Adding requires the folder to contain a workspace file.
279    #[test]
280    fn add_requires_a_workspace_file() {
281        let dir = temp_dir("no-config");
282        let registry = RecordingRegistry::default();
283        assert!(matches!(
284            add(&dir, &registry),
285            Err(CliError::MissingWorkspaceFile(_))
286        ));
287        fs::remove_dir_all(dir).unwrap();
288    }
289
290    /// A directory named muster.yml is not a valid workspace file.
291    #[test]
292    fn add_rejects_a_directory_named_muster_yml() {
293        let dir = temp_dir("dir-as-config");
294        fs::create_dir(dir.join(WORKSPACE_FILE_NAME)).unwrap();
295        let registry = RecordingRegistry::default();
296        assert!(matches!(
297            add(&dir, &registry),
298            Err(CliError::MissingWorkspaceFile(_))
299        ));
300        fs::remove_dir_all(dir).unwrap();
301    }
302
303    /// Adding a folder with a config registers it once.
304    #[test]
305    fn add_registers_and_readd_is_a_no_op() {
306        let dir = temp_dir("add");
307        fs::write(dir.join(WORKSPACE_FILE_NAME), "agents: []\n").unwrap();
308        let registry = RecordingRegistry::default();
309
310        let rows = add(&dir, &registry).unwrap();
311        assert!(registry.saved_projects.borrow().is_some());
312        assert!(rows[0].detail().contains("registered"));
313
314        let config = crate::adapter::path::absolutize(&dir.join(WORKSPACE_FILE_NAME));
315        let seeded = RecordingRegistry {
316            projects: vec![
317                Project::builder()
318                    .name(ProjectName::try_new("here").unwrap())
319                    .config(config)
320                    .build(),
321            ],
322            ..RecordingRegistry::default()
323        };
324        let again = add(&dir, &seeded).unwrap();
325        // The list is written back unchanged: still exactly the seeded entry.
326        {
327            let saved = seeded.saved_projects.borrow();
328            let written = saved.as_ref().expect("list written back");
329            assert_eq!(written.len(), 1, "re-add keeps exactly one project");
330            assert_eq!(written[0].name().as_ref(), "here", "the seeded entry kept");
331        }
332        assert!(again[0].detail().contains("already registered"));
333
334        fs::remove_dir_all(dir).unwrap();
335    }
336
337    /// The same folder addressed through parent components is recognized as
338    /// already registered instead of appended twice.
339    #[test]
340    fn add_normalizes_parent_components() {
341        let dir = temp_dir("dotdot");
342        fs::write(dir.join(WORKSPACE_FILE_NAME), "agents: []\n").unwrap();
343        fs::create_dir_all(dir.join("sub")).unwrap();
344        let config = crate::adapter::path::absolutize(&dir.join(WORKSPACE_FILE_NAME));
345        let seeded = RecordingRegistry {
346            projects: vec![
347                Project::builder()
348                    .name(ProjectName::try_new("here").unwrap())
349                    .config(config)
350                    .build(),
351            ],
352            ..RecordingRegistry::default()
353        };
354
355        let aliased = dir.join("sub").join("..");
356        let rows = add(&aliased, &seeded).unwrap();
357
358        let saved = seeded.saved_projects.borrow();
359        let written = saved.as_ref().expect("list written back");
360        assert_eq!(written.len(), 1, "no duplicate entry for the same folder");
361        assert!(rows[0].detail().contains("already registered"));
362        fs::remove_dir_all(dir).unwrap();
363    }
364
365    /// Removing an unknown name lists the known ones.
366    #[test]
367    fn remove_unknown_lists_known_names() {
368        let registry = RecordingRegistry {
369            projects: vec![project("web", "/w/web/muster.yml")],
370            ..RecordingRegistry::default()
371        };
372        match remove("nope", &registry) {
373            Err(CliError::UnknownProjectAmong { name, known }) => {
374                assert_eq!(name, "nope");
375                assert!(known.contains("web"));
376            },
377            other => panic!("unexpected: {other:?}"),
378        }
379    }
380
381    /// Removing a name that matches two projects returns AmbiguousProject and
382    /// leaves the list unchanged.
383    #[test]
384    fn remove_rejects_ambiguous_name() {
385        let registry = RecordingRegistry {
386            projects: vec![
387                project("web", "/w/site/muster.yml"),
388                project("web", "/w/app/muster.yml"),
389            ],
390            ..RecordingRegistry::default()
391        };
392        match remove("web", &registry) {
393            Err(CliError::AmbiguousProject { name, count, paths }) => {
394                assert_eq!(name, "web");
395                assert_eq!(count, 2);
396                assert!(paths.contains("/w/site/muster.yml"));
397                assert!(paths.contains("/w/app/muster.yml"));
398            },
399            other => panic!("unexpected: {other:?}"),
400        }
401        // The list is written back unchanged: both same-named entries survive
402        // with their distinct paths.
403        let saved = registry.saved_projects.borrow();
404        let written = saved.as_ref().expect("list written back");
405        assert_eq!(written.len(), 2, "both projects remain");
406        assert!(
407            written
408                .iter()
409                .any(|project| project.config().ends_with("site/muster.yml"))
410                && written
411                    .iter()
412                    .any(|project| project.config().ends_with("app/muster.yml")),
413            "each original path survives"
414        );
415    }
416
417    /// Removing keeps every other project and never touches files.
418    #[test]
419    fn remove_drops_only_the_named_project() {
420        let registry = RecordingRegistry {
421            projects: vec![
422                project("web", "/w/web/muster.yml"),
423                project("api", "/w/api/muster.yml"),
424            ],
425            ..RecordingRegistry::default()
426        };
427        remove("web", &registry).unwrap();
428        let saved = registry.saved_projects.borrow();
429        let names: Vec<_> = saved
430            .as_ref()
431            .unwrap()
432            .iter()
433            .map(|p| p.name().as_ref().to_string())
434            .collect();
435        assert_eq!(names, vec!["api"]);
436    }
437}