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
16const CURRENT_MARKER: &str = "* ";
18const OTHER_INDENT: &str = " ";
20const EMPTY_NOTE: &str = "no registered projects; run `muster init` in a project folder";
22
23pub 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
52fn 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
61fn resolved_identity(path: &Path) -> PathBuf {
63 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
64}
65
66pub 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
89enum RemoveOutcome {
91 Removed,
92 Unknown { known: String },
93 Ambiguous { count: usize, paths: String },
94}
95
96pub 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 #[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 #[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(®istry, 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 assert!(!rows[1].detail().contains("muster.yml"));
236 }
237
238 #[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(®istry, &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 #[test]
272 fn list_reports_an_empty_registry() {
273 let registry = RecordingRegistry::default();
274 let rows = list(®istry, Path::new("/")).unwrap();
275 assert_eq!(rows[0].detail(), EMPTY_NOTE);
276 }
277
278 #[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, ®istry),
285 Err(CliError::MissingWorkspaceFile(_))
286 ));
287 fs::remove_dir_all(dir).unwrap();
288 }
289
290 #[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, ®istry),
298 Err(CliError::MissingWorkspaceFile(_))
299 ));
300 fs::remove_dir_all(dir).unwrap();
301 }
302
303 #[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, ®istry).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 {
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 #[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 #[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", ®istry) {
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 #[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", ®istry) {
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 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 #[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", ®istry).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}