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
13const EXISTS_NOTE: &str = "already exists, left unchanged";
15const REGISTERED_NOTE: &str = "already registered";
17const RUN_HINT: &str = "run `muster` here to start";
19
20pub fn init(directory: &Path, registry: &dyn ProjectRegistry) -> Result<Vec<Row>, CliError> {
27 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 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
55pub(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 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 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
106pub(super) fn project_name(directory: &Path) -> Result<ProjectName, CliError> {
112 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
124pub(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 #[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 #[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, ®istry).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 #[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, ®istry).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 #[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 #[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, ®istry);
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 #[test]
279 fn init_at_a_root_creates_nothing() {
280 let registry = RecordingRegistry::default();
281
282 let result = init(Path::new("/"), ®istry);
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 #[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, ®istry).unwrap();
312
313 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 #[test]
328 fn register_folder_goes_through_update_projects() {
329 #[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 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, ®istry).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}