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
18const REGISTRY_FILE: &str = "projects.yml";
20
21#[derive(Serialize, Deserialize)]
23struct RegistryFile {
24 projects: Vec<Project>,
25}
26
27#[derive(Default)]
31pub struct YamlProjectRegistry;
32
33impl YamlProjectRegistry {
34 fn registry_path() -> Option<PathBuf> {
36 config_dir_path(REGISTRY_FILE)
37 }
38
39 fn validate_projects(projects: &[Project]) -> Result<(), ConfigError> {
46 for project in projects {
50 registered_config_path(project)?;
51 }
52 Ok(())
53 }
54
55 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 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 let expanded = expand_home(config_path);
145 let dest = expanded.canonicalize().unwrap_or(expanded);
146 let _guard = lock_workspace(&dest)?;
150 let config = load_workspace(&dest)?;
151 write_config(&dest, &update(config))
152 }
153}
154
155#[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#[cfg(not(unix))]
196fn lock_workspace(_dest: &Path) -> Result<(), ConfigError> {
197 Ok(())
198}
199
200fn 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 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 #[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 #[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 #[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 #[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 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}