Skip to main content

muster/adapter/config/
registry.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4};
5
6use serde::{Deserialize, Serialize};
7
8use super::yaml::{config_dir_path, create_config_new, load_workspace, write_config};
9use crate::{
10    adapter::path::{expand_home, registered_config_path},
11    domain::{
12        config::{ConfigError, WorkspaceConfig},
13        port::ProjectRegistry,
14        project::Project,
15    },
16};
17
18/// Registry file name under the muster config directory.
19const REGISTRY_FILE: &str = "projects.yml";
20
21/// On-disk registry shape: a top-level `projects:` list.
22#[derive(Serialize, Deserialize)]
23struct RegistryFile {
24    projects: Vec<Project>,
25}
26
27/// A [`ProjectRegistry`] backed by `projects.yml` in the user's config directory
28/// (`~/.config/muster/projects.yml` on Linux). A project's `config` path may
29/// begin with `~`; ambiguous legacy relative paths are rejected.
30#[derive(Default)]
31pub struct YamlProjectRegistry;
32
33impl YamlProjectRegistry {
34    /// Path to the registry file, when a config directory can be resolved.
35    fn registry_path() -> Option<PathBuf> {
36        config_dir_path(REGISTRY_FILE)
37    }
38
39    /// Validates that every registry entry has a location-independent config
40    /// path.
41    ///
42    /// # Errors
43    /// Returns [`ConfigError::RelativeProjectConfig`] for an unsupported legacy
44    /// relative entry.
45    fn validate_projects(projects: &[Project]) -> Result<(), ConfigError> {
46        // Muster is beta, so rejecting this legacy registry shape is an
47        // intentional compatibility break; guessing paths could launch the
48        // wrong workspace.
49        for project in projects {
50            registered_config_path(project)?;
51        }
52        Ok(())
53    }
54
55    /// Copies projects into their persisted form with absolute config paths.
56    ///
57    /// # Errors
58    /// Returns [`ConfigError::RelativeProjectConfig`] rather than guessing the
59    /// original directory of a legacy relative entry.
60    fn persistable_projects(projects: &[Project]) -> Result<Vec<Project>, ConfigError> {
61        projects
62            .iter()
63            .map(|project| {
64                Ok(Project::builder()
65                    .name(project.name().clone())
66                    .config(registered_config_path(project)?)
67                    .build())
68            })
69            .collect()
70    }
71}
72
73impl ProjectRegistry for YamlProjectRegistry {
74    fn projects(&self) -> Result<Vec<Project>, ConfigError> {
75        let Some(path) = Self::registry_path() else {
76            return Ok(Vec::new());
77        };
78        if !path.exists() {
79            return Ok(Vec::new());
80        }
81        let raw = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
82            path: path.clone(),
83            source,
84        })?;
85        let file: RegistryFile = serde_yaml_ng::from_str(&raw)?;
86        Self::validate_projects(&file.projects)?;
87        Ok(file.projects)
88    }
89
90    fn workspace(&self, config_path: &Path) -> Result<WorkspaceConfig, ConfigError> {
91        load_workspace(&expand_home(config_path))
92    }
93
94    fn workspace_exists(&self, config_path: &Path) -> bool {
95        expand_home(config_path).exists()
96    }
97
98    fn save(&self, projects: &[Project]) -> Result<(), ConfigError> {
99        let path = Self::registry_path().ok_or(ConfigError::NoConfigDir)?;
100        let file = RegistryFile {
101            projects: Self::persistable_projects(projects)?,
102        };
103        write_config(&path, &file)
104    }
105
106    fn save_workspace(
107        &self,
108        config_path: &Path,
109        config: &WorkspaceConfig,
110    ) -> Result<(), ConfigError> {
111        write_config(&expand_home(config_path), config)
112    }
113
114    fn create_workspace(
115        &self,
116        config_path: &Path,
117        config: &WorkspaceConfig,
118    ) -> Result<bool, ConfigError> {
119        create_config_new(&expand_home(config_path), config)
120    }
121
122    fn update_projects(
123        &self,
124        update: &mut dyn FnMut(Vec<Project>) -> Vec<Project>,
125    ) -> Result<(), ConfigError> {
126        // Canonicalize so the same registry file always takes the same lock,
127        // even when the registry does not exist yet (fall back to the raw path
128        // so a missing registry can still be created under the lock).
129        let path = Self::registry_path().ok_or(ConfigError::NoConfigDir)?;
130        let dest = path.canonicalize().unwrap_or_else(|_| path.clone());
131        let _guard = lock_workspace(&dest)?;
132        let projects = self.projects()?;
133        self.save(&update(projects))
134    }
135
136    fn update_workspace(
137        &self,
138        config_path: &Path,
139        update: &mut dyn FnMut(WorkspaceConfig) -> WorkspaceConfig,
140    ) -> Result<(), ConfigError> {
141        // Canonicalize first so a symlink and its real path resolve to one lock
142        // file; `write_config` canonicalizes the destination the same way, so
143        // otherwise the two addressings would take different locks and race.
144        let expanded = expand_home(config_path);
145        let dest = expanded.canonicalize().unwrap_or(expanded);
146        // Hold an exclusive lock across the read-modify-write so two concurrent
147        // `muster run` invocations serialize instead of clobbering each other. A
148        // lock failure aborts the update rather than risking a silent lost write.
149        let _guard = lock_workspace(&dest)?;
150        let config = load_workspace(&dest)?;
151        write_config(&dest, &update(config))
152    }
153}
154
155/// Acquires an exclusive advisory lock for the workspace at `dest`, on a stable
156/// sibling `.lock` file (never renamed, unlike the config itself). The lock
157/// releases when the returned handle is dropped.
158///
159/// # Errors
160/// Returns a `ConfigError` if the lock file cannot be opened or locked, so a
161/// caller never proceeds believing it holds a lock it does not.
162#[cfg(unix)]
163fn lock_workspace(dest: &Path) -> Result<fs::File, ConfigError> {
164    use std::os::unix::io::AsRawFd;
165
166    let lock_path = lock_path_of(dest);
167    if let Some(parent) = lock_path.parent()
168        && !parent.as_os_str().is_empty()
169    {
170        fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
171            path: parent.to_path_buf(),
172            source,
173        })?;
174    }
175    let file = fs::OpenOptions::new()
176        .create(true)
177        .append(true)
178        .open(&lock_path)
179        .map_err(|source| ConfigError::Write {
180            path: lock_path.clone(),
181            source,
182        })?;
183    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == -1 {
184        return Err(ConfigError::Write {
185            path: lock_path,
186            source: std::io::Error::last_os_error(),
187        });
188    }
189    Ok(file)
190}
191
192/// No advisory lock off Unix. Known limitation: concurrent registry mutations
193/// (two `muster init` / `projects add` invocations) can still race there;
194/// closing it would need a dedicated file-locking crate for Windows.
195#[cfg(not(unix))]
196fn lock_workspace(_dest: &Path) -> Result<(), ConfigError> {
197    Ok(())
198}
199
200/// The sibling `<name>.lock` path for a config file.
201fn lock_path_of(dest: &Path) -> PathBuf {
202    let mut name = dest.file_name().unwrap_or_default().to_os_string();
203    name.push(".lock");
204    dest.with_file_name(name)
205}
206
207#[cfg(test)]
208mod tests {
209    use std::time::Duration;
210
211    use super::*;
212    use crate::{
213        adapter::path::absolutize,
214        domain::{
215            config::ProcessSpec,
216            process::{StopPolicy, StopSignal},
217            value::{CommandLine, ProcessName, ProjectName},
218        },
219    };
220
221    /// Grace period used to verify workspace serialization.
222    const ROUND_TRIP_STOP_GRACE: Duration = Duration::from_secs(5);
223
224    #[test]
225    fn registry_file_parses_a_projects_list() {
226        let raw = "projects:\n  - name: muster\n    config: ~/Projects/muster/muster.yml\n";
227        let file: RegistryFile = serde_yaml_ng::from_str(raw).unwrap();
228        assert_eq!(file.projects.len(), 1);
229        assert_eq!(file.projects[0].name().as_ref(), "muster");
230    }
231
232    /// Verifies one relative entry invalidates the legacy registry rather than
233    /// leaving UI mutations in a partially supported state.
234    #[test]
235    fn registry_validation_rejects_legacy_relative_entries() {
236        let projects = vec![
237            Project::builder()
238                .name(ProjectName::try_new("first").unwrap())
239                .config(PathBuf::from("first.yml"))
240                .build(),
241            Project::builder()
242                .name(ProjectName::try_new("second").unwrap())
243                .config(PathBuf::from("second.yml"))
244                .build(),
245        ];
246
247        let error = YamlProjectRegistry::validate_projects(&projects).unwrap_err();
248
249        assert!(matches!(error, ConfigError::RelativeProjectConfig { .. }));
250    }
251
252    /// Verifies registry serialization expands a home-relative path into its
253    /// stable absolute form.
254    #[test]
255    fn persistable_projects_expand_home_config_paths() {
256        let project = Project::builder()
257            .name(ProjectName::try_new("muster").unwrap())
258            .config(PathBuf::from("~/Projects/muster/muster.yml"))
259            .build();
260
261        let projects = YamlProjectRegistry::persistable_projects(&[project]).unwrap();
262
263        assert_eq!(
264            projects[0].config(),
265            &absolutize(Path::new("~/Projects/muster/muster.yml"))
266        );
267        assert!(projects[0].config().is_absolute());
268    }
269
270    /// Verifies registry serialization refuses to guess the original directory
271    /// of a relative path written by an older version.
272    #[test]
273    fn persistable_projects_reject_legacy_relative_config_paths() {
274        let project = Project::builder()
275            .name(ProjectName::try_new("legacy").unwrap())
276            .config(PathBuf::from("muster.yml"))
277            .build();
278
279        let error = YamlProjectRegistry::persistable_projects(&[project]).unwrap_err();
280
281        assert!(matches!(error, ConfigError::RelativeProjectConfig { .. }));
282    }
283
284    #[cfg(unix)]
285    /// Verifies registry persistence retains an existing symlink alias instead
286    /// of replacing it with the canonical target path.
287    #[test]
288    fn persistable_projects_preserve_symlink_config_paths() {
289        use std::os::unix::fs::symlink;
290
291        let dir = std::env::temp_dir().join(format!("muster-registry-link-{}", std::process::id()));
292        let target = dir.join("shared.yml");
293        let link = dir.join("muster.yml");
294        fs::create_dir_all(&dir).unwrap();
295        fs::write(&target, "").unwrap();
296        symlink(&target, &link).unwrap();
297        let project = Project::builder()
298            .name(ProjectName::try_new("muster").unwrap())
299            .config(link.clone())
300            .build();
301
302        let projects = YamlProjectRegistry::persistable_projects(&[project]).unwrap();
303
304        assert_eq!(projects[0].config(), &link);
305        fs::remove_dir_all(dir).unwrap();
306    }
307
308    #[test]
309    fn save_workspace_creates_the_file_and_round_trips_through_load() {
310        let dir = std::env::temp_dir().join(format!("muster-save-{}", std::process::id()));
311        let path = dir.join("nested").join("muster.yml");
312        let config = WorkspaceConfig::builder()
313            .agents(vec![])
314            .terminals(vec![
315                ProcessSpec::builder()
316                    .name(ProcessName::try_new("Shell").unwrap())
317                    .build(),
318            ])
319            .commands(vec![
320                ProcessSpec::builder()
321                    .name(ProcessName::try_new("Server").unwrap())
322                    .command(Some(CommandLine::try_new("serve").unwrap()))
323                    .stop(Some(
324                        StopPolicy::builder()
325                            .signal(StopSignal::Terminate)
326                            .grace_period(ROUND_TRIP_STOP_GRACE)
327                            .build(),
328                    ))
329                    .build(),
330            ])
331            .build();
332
333        let registry = YamlProjectRegistry;
334        registry.save_workspace(&path, &config).unwrap();
335        assert!(
336            path.exists(),
337            "the workspace file and its parents were created"
338        );
339
340        let loaded = registry.workspace(&path).unwrap();
341        assert_eq!(loaded.terminals()[0].name().as_ref(), "Shell");
342        let stop = loaded.commands()[0].stop().as_ref().unwrap();
343        assert_eq!(*stop.signal(), StopSignal::Terminate);
344        assert_eq!(*stop.grace_period(), ROUND_TRIP_STOP_GRACE);
345
346        let leftover_temps = fs::read_dir(path.parent().unwrap())
347            .unwrap()
348            .filter_map(Result::ok)
349            .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp"))
350            .count();
351        assert_eq!(
352            leftover_temps, 0,
353            "the atomic write leaves no temp file behind"
354        );
355
356        let _ = fs::remove_dir_all(&dir);
357    }
358
359    #[test]
360    fn save_workspace_handles_a_parentless_relative_path() {
361        // A bare filename's parent is the empty path; create_dir_all("") would
362        // fail, which broke `--config muster.yml`. It must write into the cwd.
363        let name = format!("muster-parentless-{}.yml", std::process::id());
364        let path = PathBuf::from(&name);
365        let config = WorkspaceConfig::builder()
366            .agents(vec![])
367            .terminals(vec![])
368            .commands(vec![])
369            .build();
370
371        let result = YamlProjectRegistry.save_workspace(&path, &config);
372        let existed = path.exists();
373        let _ = fs::remove_file(&path);
374
375        result.unwrap();
376        assert!(existed, "the config is written into the current directory");
377    }
378
379    #[cfg(unix)]
380    #[test]
381    fn save_workspace_rewrites_a_symlink_target_in_place() {
382        use std::os::unix::fs::symlink;
383
384        let dir = std::env::temp_dir().join(format!("muster-symlink-{}", std::process::id()));
385        fs::create_dir_all(&dir).unwrap();
386        let target = dir.join("real.yml");
387        let link = dir.join("muster.yml");
388        fs::write(&target, "agents: []\nterminals: []\ncommands: []\n").unwrap();
389        symlink(&target, &link).unwrap();
390
391        let config = WorkspaceConfig::builder()
392            .agents(vec![])
393            .terminals(vec![
394                ProcessSpec::builder()
395                    .name(ProcessName::try_new("Shell").unwrap())
396                    .build(),
397            ])
398            .commands(vec![])
399            .build();
400        YamlProjectRegistry.save_workspace(&link, &config).unwrap();
401
402        assert!(
403            fs::symlink_metadata(&link)
404                .unwrap()
405                .file_type()
406                .is_symlink(),
407            "the symlink is preserved, not replaced with a regular file"
408        );
409        let loaded = YamlProjectRegistry.workspace(&target).unwrap();
410        assert_eq!(
411            loaded.terminals()[0].name().as_ref(),
412            "Shell",
413            "the symlink's target received the update"
414        );
415
416        let _ = fs::remove_dir_all(&dir);
417    }
418}