Skip to main content

muster/adapter/cli/
init.rs

1use std::path::{Path, PathBuf};
2
3use super::{
4    error::CliError,
5    report::{Row, RowKind},
6};
7use crate::{
8    adapter::{config::starter_workspace, path::absolutize},
9    constants::WORKSPACE_FILE_NAME,
10    domain::{port::ProjectRegistry, project::Project, value::ProjectName},
11};
12
13/// Note printed when the workspace file already exists.
14const EXISTS_NOTE: &str = "already exists, left unchanged";
15/// Note printed when the folder is already a registered project.
16const REGISTERED_NOTE: &str = "already registered";
17/// Closing hint after a successful init.
18const RUN_HINT: &str = "run `muster` here to start";
19
20/// Scaffolds a starter workspace in `directory` and registers it as a project.
21/// Returns the report rows to print, in order.
22///
23/// # Errors
24/// Returns [`CliError`] when the folder name is not a usable project name or
25/// the registry cannot be read or written.
26pub fn init(directory: &Path, registry: &dyn ProjectRegistry) -> Result<Vec<Row>, CliError> {
27    // Validate everything the registry will persist first: a folder that
28    // cannot become a project (a filesystem root, a non-UTF-8 path) must
29    // fail before anything is written.
30    let name = project_name(directory)?;
31    let config_path = absolutize(&directory.join(WORKSPACE_FILE_NAME));
32    ensure_representable(&config_path)?;
33    let mut rows = Vec::new();
34    // An exclusive create keeps the no-overwrite guarantee even against a
35    // concurrent creation. Anything already occupying the path - including a
36    // dangling symlink, which the exclusive open also refuses - is left
37    // untouched, intentionally asymmetric with `projects add` and `doctor`,
38    // which require a reachable regular file.
39    if registry.create_workspace(&config_path, &starter_workspace())? {
40        rows.push(Row::unlabeled(
41            RowKind::Ok,
42            format!("created {WORKSPACE_FILE_NAME}"),
43        ));
44    } else {
45        rows.push(Row::unlabeled(
46            RowKind::Hint,
47            format!("{WORKSPACE_FILE_NAME} {EXISTS_NOTE}"),
48        ));
49    }
50    rows.push(register_folder(name, &config_path, registry)?);
51    rows.push(Row::unlabeled(RowKind::Dim, RUN_HINT));
52    Ok(rows)
53}
54
55/// Registers `name` at `config_path` unless that config path already is a
56/// project. `pub(super)` because `muster projects add` reuses it.
57///
58/// The check-and-insert runs inside `update_projects` so it is atomic with
59/// respect to concurrent `muster init` or `muster projects add` invocations.
60///
61/// # Errors
62/// Returns [`CliError`] when the registry cannot be updated.
63pub(super) fn register_folder(
64    name: ProjectName,
65    config_path: &Path,
66    registry: &dyn ProjectRegistry,
67) -> Result<Row, CliError> {
68    let label = name.as_ref().to_string();
69
70    // Capture the already-registered project name (if any) inside the closure
71    // so the outcome is observable after the lock releases.
72    let mut existing_name: Option<String> = None;
73
74    registry.update_projects(&mut |projects| {
75        if let Some(existing) = projects
76            .iter()
77            .find(|project| absolutize(project.config()) == config_path)
78        {
79            existing_name = Some(existing.name().as_ref().to_string());
80            // Already registered - return list unchanged.
81            return projects;
82        }
83        let mut updated = projects;
84        updated.push(
85            Project::builder()
86                .name(name.clone())
87                .config(config_path.to_path_buf())
88                .build(),
89        );
90        updated
91    })?;
92
93    if let Some(found) = existing_name {
94        Ok(Row::unlabeled(
95            RowKind::Hint,
96            format!("{REGISTERED_NOTE} as '{found}'"),
97        ))
98    } else {
99        Ok(Row::unlabeled(
100            RowKind::Ok,
101            format!("registered project '{label}'"),
102        ))
103    }
104}
105
106/// The project name derived from the folder's file name. `pub(super)` because
107/// `muster projects add` derives the same way.
108///
109/// # Errors
110/// Returns [`CliError::InvalidProjectFolder`] when the folder yields no name.
111pub(super) fn project_name(directory: &Path) -> Result<ProjectName, CliError> {
112    // Normalize without resolving the final symlink: the registry preserves
113    // the alias the user selected, so its name must come from the alias too.
114    // No lossy conversion: a non-UTF-8 name would not round-trip through the
115    // registry, so it is rejected rather than mangled.
116    let file_name = absolutize(directory)
117        .file_name()
118        .and_then(|name| name.to_str().map(str::to_string))
119        .unwrap_or_default();
120    ProjectName::try_new(&file_name)
121        .map_err(|_| CliError::InvalidProjectFolder(PathBuf::from(directory)))
122}
123
124/// Rejects a config path the registry cannot persist as UTF-8.
125///
126/// # Errors
127/// Returns [`CliError::UnrepresentablePath`] for non-UTF-8 paths.
128pub(super) fn ensure_representable(config_path: &Path) -> Result<(), CliError> {
129    if config_path.to_str().is_none() {
130        return Err(CliError::UnrepresentablePath(config_path.to_path_buf()));
131    }
132    Ok(())
133}
134
135#[cfg(test)]
136mod tests {
137    use std::{cell::RefCell, fs, path::PathBuf};
138
139    use super::*;
140    use crate::domain::{
141        config::{ConfigError, WorkspaceConfig},
142        project::Project,
143        value::ProjectName,
144    };
145
146    /// A registry recording saves of projects and workspaces.
147    #[derive(Default)]
148    struct RecordingRegistry {
149        projects: Vec<Project>,
150        saved_projects: RefCell<Option<Vec<Project>>>,
151        saved_workspace: RefCell<Option<PathBuf>>,
152    }
153
154    impl ProjectRegistry for RecordingRegistry {
155        fn projects(&self) -> Result<Vec<Project>, ConfigError> {
156            Ok(self.projects.clone())
157        }
158
159        fn workspace(&self, _config_path: &Path) -> Result<WorkspaceConfig, ConfigError> {
160            unreachable!("init never loads a workspace")
161        }
162
163        fn workspace_exists(&self, config_path: &Path) -> bool {
164            config_path.exists()
165        }
166
167        fn save(&self, projects: &[Project]) -> Result<(), ConfigError> {
168            *self.saved_projects.borrow_mut() = Some(projects.to_vec());
169            Ok(())
170        }
171
172        fn save_workspace(
173            &self,
174            config_path: &Path,
175            _config: &WorkspaceConfig,
176        ) -> Result<(), ConfigError> {
177            *self.saved_workspace.borrow_mut() = Some(config_path.to_path_buf());
178            Ok(())
179        }
180    }
181
182    fn temp_dir(tag: &str) -> PathBuf {
183        let dir = std::env::temp_dir().join(format!("muster-init-{tag}-{}", uuid::Uuid::new_v4()));
184        fs::create_dir_all(&dir).unwrap();
185        dir
186    }
187
188    /// A fresh folder gets a starter config and a registry entry.
189    #[test]
190    fn scaffolds_and_registers_a_fresh_folder() {
191        let dir = temp_dir("fresh");
192        let registry = RecordingRegistry::default();
193
194        let rows = init(&dir, &registry).unwrap();
195
196        assert!(
197            registry.saved_workspace.borrow().is_some(),
198            "config written"
199        );
200        let saved = registry.saved_projects.borrow();
201        assert_eq!(saved.as_ref().unwrap().len(), 1, "project registered");
202        assert!(
203            rows.iter()
204                .any(|row| row.detail().contains(WORKSPACE_FILE_NAME))
205        );
206        fs::remove_dir_all(dir).unwrap();
207    }
208
209    /// An existing config is never overwritten, but registration still runs.
210    #[test]
211    fn refuses_to_overwrite_but_still_registers() {
212        let dir = temp_dir("existing");
213        fs::write(dir.join(WORKSPACE_FILE_NAME), "agents: []\n").unwrap();
214        let registry = RecordingRegistry::default();
215
216        let rows = init(&dir, &registry).unwrap();
217
218        assert!(registry.saved_workspace.borrow().is_none(), "no overwrite");
219        assert!(
220            registry.saved_projects.borrow().is_some(),
221            "still registered"
222        );
223        assert!(rows.iter().any(|row| row.detail().contains(EXISTS_NOTE)));
224        fs::remove_dir_all(dir).unwrap();
225    }
226
227    /// A symlinked folder keeps its alias name, matching the alias the
228    /// registry stores, so two aliases of one target stay distinguishable.
229    #[cfg(unix)]
230    #[test]
231    fn project_name_keeps_the_symlink_alias() {
232        use std::os::unix::fs::symlink;
233
234        let base = temp_dir("alias-name");
235        let target = base.join("real-target");
236        let alias = base.join("nice-alias");
237        fs::create_dir_all(&target).unwrap();
238        symlink(&target, &alias).unwrap();
239
240        assert_eq!(project_name(&alias).unwrap().as_ref(), "nice-alias");
241        fs::remove_dir_all(base).unwrap();
242    }
243
244    /// A non-UTF-8 folder fails before anything is written, not after the
245    /// workspace exists. The directory is never created: APFS rejects
246    /// non-UTF-8 names entirely, and init's validation must fire before any
247    /// filesystem access anyway.
248    #[cfg(unix)]
249    #[test]
250    fn init_rejects_a_non_utf8_folder_before_writing() {
251        use std::{ffi::OsString, os::unix::ffi::OsStringExt};
252
253        let mut raw = std::env::temp_dir().into_os_string().into_vec();
254        raw.extend_from_slice(b"/muster-bad-\xff");
255        let dir = PathBuf::from(OsString::from_vec(raw));
256        let registry = RecordingRegistry::default();
257
258        let result = init(&dir, &registry);
259
260        assert!(
261            matches!(
262                result,
263                Err(CliError::InvalidProjectFolder(_) | CliError::UnrepresentablePath(_))
264            ),
265            "unexpected: {result:?}"
266        );
267        assert!(
268            registry.saved_workspace.borrow().is_none(),
269            "no workspace file was created"
270        );
271        assert!(
272            registry.saved_projects.borrow().is_none(),
273            "no registration"
274        );
275    }
276
277    /// A directory with no usable name fails before anything is written.
278    #[test]
279    fn init_at_a_root_creates_nothing() {
280        let registry = RecordingRegistry::default();
281
282        let result = init(Path::new("/"), &registry);
283
284        assert!(matches!(result, Err(CliError::InvalidProjectFolder(_))));
285        assert!(
286            registry.saved_workspace.borrow().is_none(),
287            "no workspace file was created"
288        );
289        assert!(
290            registry.saved_projects.borrow().is_none(),
291            "no registration"
292        );
293    }
294
295    /// An already registered project is reported, not duplicated.
296    #[test]
297    fn re_init_is_a_registration_no_op() {
298        let dir = temp_dir("registered");
299        fs::write(dir.join(WORKSPACE_FILE_NAME), "agents: []\n").unwrap();
300        let config = crate::adapter::path::absolutize(&dir.join(WORKSPACE_FILE_NAME));
301        let registry = RecordingRegistry {
302            projects: vec![
303                Project::builder()
304                    .name(ProjectName::try_new("here").unwrap())
305                    .config(config)
306                    .build(),
307            ],
308            ..RecordingRegistry::default()
309        };
310
311        let rows = init(&dir, &registry).unwrap();
312
313        // The list is written back unchanged: still exactly the seeded entry.
314        let saved = registry.saved_projects.borrow();
315        let written = saved.as_ref().expect("list written back");
316        assert_eq!(written.len(), 1, "one project remains");
317        assert_eq!(written[0].name().as_ref(), "here", "the seeded entry kept");
318        assert!(
319            rows.iter()
320                .any(|row| row.detail().contains(REGISTERED_NOTE))
321        );
322        fs::remove_dir_all(dir).unwrap();
323    }
324
325    /// `register_folder` routes through `update_projects`, not a bare
326    /// `projects()` + `save()` pair, so the check-and-insert is atomic.
327    #[test]
328    fn register_folder_goes_through_update_projects() {
329        /// Fake that records whether `update_projects` was called.
330        #[derive(Default)]
331        struct TrackingRegistry {
332            update_projects_called: RefCell<bool>,
333            saved_projects: RefCell<Option<Vec<Project>>>,
334        }
335
336        impl ProjectRegistry for TrackingRegistry {
337            fn projects(&self) -> Result<Vec<Project>, ConfigError> {
338                Ok(Vec::new())
339            }
340
341            fn workspace(&self, _config_path: &Path) -> Result<WorkspaceConfig, ConfigError> {
342                unreachable!()
343            }
344
345            fn workspace_exists(&self, _config_path: &Path) -> bool {
346                false
347            }
348
349            fn save(&self, projects: &[Project]) -> Result<(), ConfigError> {
350                *self.saved_projects.borrow_mut() = Some(projects.to_vec());
351                Ok(())
352            }
353
354            fn save_workspace(
355                &self,
356                _config_path: &Path,
357                _config: &WorkspaceConfig,
358            ) -> Result<(), ConfigError> {
359                unreachable!()
360            }
361
362            fn update_projects(
363                &self,
364                update: &mut dyn FnMut(Vec<Project>) -> Vec<Project>,
365            ) -> Result<(), ConfigError> {
366                *self.update_projects_called.borrow_mut() = true;
367                // Delegate to default logic so the insertion actually runs.
368                let projects = self.projects()?;
369                self.save(&update(projects))
370            }
371        }
372
373        let dir = temp_dir("atomic");
374        let config_path = crate::adapter::path::absolutize(&dir.join(WORKSPACE_FILE_NAME));
375        let registry = TrackingRegistry::default();
376
377        register_folder(project_name(&dir).unwrap(), &config_path, &registry).unwrap();
378
379        assert!(
380            *registry.update_projects_called.borrow(),
381            "register_folder must call update_projects"
382        );
383        assert!(
384            registry.saved_projects.borrow().is_some(),
385            "project was saved"
386        );
387        fs::remove_dir_all(dir).unwrap();
388    }
389}