1use std::{
2 fs,
3 io::{ErrorKind, Write},
4 path::{Path, PathBuf},
5};
6
7use directories::ProjectDirs;
8use getset::Getters;
9use serde::Serialize;
10use typed_builder::TypedBuilder;
11
12use crate::domain::{
13 config::{ConfigError, WorkspaceConfig},
14 port::ConfigSource,
15};
16
17const APP_DIR: &str = "muster";
19
20pub(crate) fn config_dir_path(filename: &str) -> Option<PathBuf> {
24 ProjectDirs::from("", "", APP_DIR).map(|dirs| dirs.config_dir().join(filename))
25}
26
27pub(crate) fn state_dir_path(filename: &str) -> Option<PathBuf> {
30 ProjectDirs::from("", "", APP_DIR).map(|dirs| {
31 dirs.state_dir()
32 .unwrap_or_else(|| dirs.data_local_dir())
33 .join(filename)
34 })
35}
36
37pub(crate) fn load_workspace(path: &Path) -> Result<WorkspaceConfig, ConfigError> {
43 let raw = fs::read_to_string(path).map_err(|source| ConfigError::Read {
44 path: path.to_path_buf(),
45 source,
46 })?;
47 let config: WorkspaceConfig = serde_yaml_ng::from_str(&raw)?;
48 config.validate()?;
49 Ok(config)
50}
51
52pub(crate) fn create_config_new<T: Serialize>(path: &Path, value: &T) -> Result<bool, ConfigError> {
64 if let Some(parent) = path.parent()
65 && !parent.as_os_str().is_empty()
66 {
67 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
68 path: parent.to_path_buf(),
69 source,
70 })?;
71 }
72 let raw = serde_yaml_ng::to_string(value)?;
73 let staging = write_staging(path, &raw)?;
74 let published = publish_new(&staging, path, &raw);
75 let _ = fs::remove_file(&staging);
76 published
77}
78
79fn publish_new(staging: &Path, path: &Path, raw: &str) -> Result<bool, ConfigError> {
84 match fs::hard_link(staging, path) {
85 Ok(()) => return Ok(true),
86 Err(source) if source.kind() == ErrorKind::AlreadyExists => return Ok(false),
87 Err(_) => {},
90 }
91 let mut file = match fs::OpenOptions::new()
92 .write(true)
93 .create_new(true)
94 .open(path)
95 {
96 Ok(file) => file,
97 Err(source) if source.kind() == ErrorKind::AlreadyExists => return Ok(false),
98 Err(source) => {
99 return Err(ConfigError::Write {
100 path: path.to_path_buf(),
101 source,
102 });
103 },
104 };
105 file.write_all(raw.as_bytes()).map_err(|source| {
106 let _ = fs::remove_file(path);
107 ConfigError::Write {
108 path: path.to_path_buf(),
109 source,
110 }
111 })?;
112 Ok(true)
113}
114
115fn write_staging(path: &Path, raw: &str) -> Result<PathBuf, ConfigError> {
119 let staging = staging_path(path);
120 let mut file = fs::OpenOptions::new()
121 .write(true)
122 .create_new(true)
123 .open(&staging)
124 .map_err(|source| ConfigError::Write {
125 path: staging.clone(),
126 source,
127 })?;
128 file.write_all(raw.as_bytes()).map_err(|source| {
129 let _ = fs::remove_file(&staging);
130 ConfigError::Write {
131 path: staging.clone(),
132 source,
133 }
134 })?;
135 Ok(staging)
136}
137
138fn staging_path(path: &Path) -> PathBuf {
140 let mut name = path.file_name().unwrap_or_default().to_os_string();
141 name.push(format!(".{}{STAGING_SUFFIX}", uuid::Uuid::new_v4()));
142 path.with_file_name(name)
143}
144
145pub(crate) fn write_config<T: Serialize>(path: &Path, value: &T) -> Result<(), ConfigError> {
157 let dest = resolve_destination(path);
158 if let Some(parent) = dest.parent()
159 && !parent.as_os_str().is_empty()
160 {
161 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
162 path: parent.to_path_buf(),
163 source,
164 })?;
165 }
166 let raw = serde_yaml_ng::to_string(value)?;
167 let staging = write_staging(&dest, &raw)?;
168 if let Ok(existing) = fs::metadata(&dest) {
171 let _ = fs::set_permissions(&staging, existing.permissions());
172 }
173 fs::rename(&staging, &dest).map_err(|source| {
174 let _ = fs::remove_file(&staging);
175 ConfigError::Write {
176 path: dest.clone(),
177 source,
178 }
179 })
180}
181
182const MAX_CONFIG_SYMLINKS: usize = 40;
184
185fn resolve_destination(path: &Path) -> PathBuf {
190 let mut dest = path.to_path_buf();
191 for _ in 0..MAX_CONFIG_SYMLINKS {
192 match fs::read_link(&dest) {
193 Ok(target) if target.is_absolute() => dest = target,
194 Ok(target) => {
195 dest = dest
196 .parent()
197 .map(|parent| parent.join(&target))
198 .unwrap_or(target);
199 },
200 Err(_) => break,
201 }
202 }
203 dest.canonicalize().unwrap_or(dest)
204}
205
206const STAGING_SUFFIX: &str = ".tmp";
209
210#[derive(Clone, Debug, Getters, TypedBuilder)]
212#[getset(get = "pub")]
213pub struct YamlConfigSource {
214 path: PathBuf,
216}
217
218impl ConfigSource for YamlConfigSource {
219 fn load(&self) -> Result<WorkspaceConfig, ConfigError> {
220 load_workspace(&self.path)
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
230 fn create_config_new_refuses_to_overwrite() {
231 let dir = std::env::temp_dir().join(format!("muster-create-{}", std::process::id()));
232 let path = dir.join("muster.yml");
233 std::fs::create_dir_all(&dir).unwrap();
234
235 assert!(create_config_new(&path, &"first").unwrap(), "first create");
236 assert!(
237 !create_config_new(&path, &"second").unwrap(),
238 "existing file wins"
239 );
240 assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "first");
241 let residue = std::fs::read_dir(&dir)
242 .unwrap()
243 .filter_map(Result::ok)
244 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
245 .count();
246 assert_eq!(residue, 0, "no temporary files survive either outcome");
247 std::fs::remove_dir_all(dir).unwrap();
248 }
249
250 #[cfg(unix)]
252 #[test]
253 fn create_config_new_refuses_a_dangling_symlink_destination() {
254 use std::os::unix::fs::symlink;
255
256 let dir = std::env::temp_dir().join(format!("muster-create-link-{}", std::process::id()));
257 let path = dir.join("muster.yml");
258 std::fs::create_dir_all(&dir).unwrap();
259 symlink(dir.join("missing.yml"), &path).unwrap();
260
261 assert!(
262 !create_config_new(&path, &"content").unwrap(),
263 "an occupied path, even a dangling symlink, is never replaced"
264 );
265 assert!(
266 !dir.join("missing.yml").exists(),
267 "nothing was written through the symlink"
268 );
269 std::fs::remove_dir_all(dir).unwrap();
270 }
271
272 #[cfg(unix)]
275 #[test]
276 fn write_config_preserves_a_dangling_symlink_destination() {
277 use std::os::unix::fs::symlink;
278
279 let dir = std::env::temp_dir().join(format!("muster-dangle-{}", std::process::id()));
280 let target = dir.join("dotfiles").join("projects.yml");
281 let link = dir.join("projects.yml");
282 std::fs::create_dir_all(dir.join("dotfiles")).unwrap();
283 symlink(&target, &link).unwrap();
284
285 write_config(&link, &"content").unwrap();
286
287 assert!(
288 std::fs::symlink_metadata(&link)
289 .unwrap()
290 .file_type()
291 .is_symlink(),
292 "the link survives"
293 );
294 assert_eq!(
295 std::fs::read_to_string(&target).unwrap().trim(),
296 "content",
297 "the write landed at the link target"
298 );
299 std::fs::remove_dir_all(dir).unwrap();
300 }
301
302 #[cfg(unix)]
304 #[test]
305 fn write_config_preserves_restrictive_permissions() {
306 use std::os::unix::fs::PermissionsExt;
307
308 const PRIVATE_MODE: u32 = 0o600;
310 let dir = std::env::temp_dir().join(format!("muster-mode-{}", std::process::id()));
311 let path = dir.join("projects.yml");
312 std::fs::create_dir_all(&dir).unwrap();
313 std::fs::write(&path, "old").unwrap();
314 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(PRIVATE_MODE)).unwrap();
315
316 write_config(&path, &"new").unwrap();
317
318 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
319 assert_eq!(mode, PRIVATE_MODE, "mode survives the rewrite");
320 std::fs::remove_dir_all(dir).unwrap();
321 }
322
323 fn example_config() -> PathBuf {
324 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/muster.yml")
325 }
326
327 #[test]
328 fn loads_the_example_config() {
329 let source = YamlConfigSource::builder().path(example_config()).build();
330 let config = source.load().unwrap();
331 assert!(!config.to_processes().is_empty());
332 }
333
334 #[test]
335 fn missing_file_is_a_read_error() {
336 let source = YamlConfigSource::builder()
337 .path(PathBuf::from("/nonexistent/muster.yml"))
338 .build();
339 assert!(matches!(source.load(), Err(ConfigError::Read { .. })));
340 }
341}