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.
246    #[cfg(unix)]
247    #[test]
248    fn init_rejects_a_non_utf8_folder_before_writing() {
249        use std::{ffi::OsString, os::unix::ffi::OsStringExt};
250
251        let mut raw = std::env::temp_dir().into_os_string().into_vec();
252        raw.extend_from_slice(b"/muster-bad-\xff");
253        let dir = PathBuf::from(OsString::from_vec(raw));
254        fs::create_dir_all(&dir).unwrap();
255        let registry = RecordingRegistry::default();
256
257        let result = init(&dir, &registry);
258
259        assert!(
260            matches!(
261                result,
262                Err(CliError::InvalidProjectFolder(_) | CliError::UnrepresentablePath(_))
263            ),
264            "unexpected: {result:?}"
265        );
266        assert!(
267            registry.saved_workspace.borrow().is_none(),
268            "no workspace file was created"
269        );
270        assert!(!dir.join(WORKSPACE_FILE_NAME).exists());
271        fs::remove_dir_all(dir).unwrap();
272    }
273
274    /// A directory with no usable name fails before anything is written.
275    #[test]
276    fn init_at_a_root_creates_nothing() {
277        let registry = RecordingRegistry::default();
278
279        let result = init(Path::new("/"), &registry);
280
281        assert!(matches!(result, Err(CliError::InvalidProjectFolder(_))));
282        assert!(
283            registry.saved_workspace.borrow().is_none(),
284            "no workspace file was created"
285        );
286        assert!(
287            registry.saved_projects.borrow().is_none(),
288            "no registration"
289        );
290    }
291
292    /// An already registered project is reported, not duplicated.
293    #[test]
294    fn re_init_is_a_registration_no_op() {
295        let dir = temp_dir("registered");
296        fs::write(dir.join(WORKSPACE_FILE_NAME), "agents: []\n").unwrap();
297        let config = crate::adapter::path::absolutize(&dir.join(WORKSPACE_FILE_NAME));
298        let registry = RecordingRegistry {
299            projects: vec![
300                Project::builder()
301                    .name(ProjectName::try_new("here").unwrap())
302                    .config(config)
303                    .build(),
304            ],
305            ..RecordingRegistry::default()
306        };
307
308        let rows = init(&dir, &registry).unwrap();
309
310        // The list is written back unchanged: still exactly the seeded entry.
311        let saved = registry.saved_projects.borrow();
312        let written = saved.as_ref().expect("list written back");
313        assert_eq!(written.len(), 1, "one project remains");
314        assert_eq!(written[0].name().as_ref(), "here", "the seeded entry kept");
315        assert!(
316            rows.iter()
317                .any(|row| row.detail().contains(REGISTERED_NOTE))
318        );
319        fs::remove_dir_all(dir).unwrap();
320    }
321
322    /// `register_folder` routes through `update_projects`, not a bare
323    /// `projects()` + `save()` pair, so the check-and-insert is atomic.
324    #[test]
325    fn register_folder_goes_through_update_projects() {
326        /// Fake that records whether `update_projects` was called.
327        #[derive(Default)]
328        struct TrackingRegistry {
329            update_projects_called: RefCell<bool>,
330            saved_projects: RefCell<Option<Vec<Project>>>,
331        }
332
333        impl ProjectRegistry for TrackingRegistry {
334            fn projects(&self) -> Result<Vec<Project>, ConfigError> {
335                Ok(Vec::new())
336            }
337
338            fn workspace(&self, _config_path: &Path) -> Result<WorkspaceConfig, ConfigError> {
339                unreachable!()
340            }
341
342            fn workspace_exists(&self, _config_path: &Path) -> bool {
343                false
344            }
345
346            fn save(&self, projects: &[Project]) -> Result<(), ConfigError> {
347                *self.saved_projects.borrow_mut() = Some(projects.to_vec());
348                Ok(())
349            }
350
351            fn save_workspace(
352                &self,
353                _config_path: &Path,
354                _config: &WorkspaceConfig,
355            ) -> Result<(), ConfigError> {
356                unreachable!()
357            }
358
359            fn update_projects(
360                &self,
361                update: &mut dyn FnMut(Vec<Project>) -> Vec<Project>,
362            ) -> Result<(), ConfigError> {
363                *self.update_projects_called.borrow_mut() = true;
364                // Delegate to default logic so the insertion actually runs.
365                let projects = self.projects()?;
366                self.save(&update(projects))
367            }
368        }
369
370        let dir = temp_dir("atomic");
371        let config_path = crate::adapter::path::absolutize(&dir.join(WORKSPACE_FILE_NAME));
372        let registry = TrackingRegistry::default();
373
374        register_folder(project_name(&dir).unwrap(), &config_path, &registry).unwrap();
375
376        assert!(
377            *registry.update_projects_called.borrow(),
378            "register_folder must call update_projects"
379        );
380        assert!(
381            registry.saved_projects.borrow().is_some(),
382            "project was saved"
383        );
384        fs::remove_dir_all(dir).unwrap();
385    }
386}