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)]
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, ®istry);
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 #[test]
276 fn init_at_a_root_creates_nothing() {
277 let registry = RecordingRegistry::default();
278
279 let result = init(Path::new("/"), ®istry);
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 #[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, ®istry).unwrap();
309
310 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 #[test]
325 fn register_folder_goes_through_update_projects() {
326 #[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 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, ®istry).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}