use std::{
fs,
io::{ErrorKind, Write},
path::{Path, PathBuf},
};
use directories::ProjectDirs;
use getset::Getters;
use serde::Serialize;
use typed_builder::TypedBuilder;
use crate::domain::{
config::{ConfigError, WorkspaceConfig},
port::ConfigSource,
};
const APP_DIR: &str = "muster";
pub(crate) fn config_dir_path(filename: &str) -> Option<PathBuf> {
ProjectDirs::from("", "", APP_DIR).map(|dirs| dirs.config_dir().join(filename))
}
pub(crate) fn state_dir_path(filename: &str) -> Option<PathBuf> {
ProjectDirs::from("", "", APP_DIR).map(|dirs| {
dirs.state_dir()
.unwrap_or_else(|| dirs.data_local_dir())
.join(filename)
})
}
pub(crate) fn load_workspace(path: &Path) -> Result<WorkspaceConfig, ConfigError> {
let raw = fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
let config: WorkspaceConfig = serde_yaml_ng::from_str(&raw)?;
config.validate()?;
Ok(config)
}
pub(crate) fn create_config_new<T: Serialize>(path: &Path, value: &T) -> Result<bool, ConfigError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
path: parent.to_path_buf(),
source,
})?;
}
let raw = serde_yaml_ng::to_string(value)?;
let staging = write_staging(path, &raw)?;
let published = publish_new(&staging, path, &raw);
let _ = fs::remove_file(&staging);
published
}
fn publish_new(staging: &Path, path: &Path, raw: &str) -> Result<bool, ConfigError> {
match fs::hard_link(staging, path) {
Ok(()) => return Ok(true),
Err(source) if source.kind() == ErrorKind::AlreadyExists => return Ok(false),
Err(_) => {},
}
let mut file = match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
{
Ok(file) => file,
Err(source) if source.kind() == ErrorKind::AlreadyExists => return Ok(false),
Err(source) => {
return Err(ConfigError::Write {
path: path.to_path_buf(),
source,
});
},
};
file.write_all(raw.as_bytes()).map_err(|source| {
let _ = fs::remove_file(path);
ConfigError::Write {
path: path.to_path_buf(),
source,
}
})?;
Ok(true)
}
fn write_staging(path: &Path, raw: &str) -> Result<PathBuf, ConfigError> {
let staging = staging_path(path);
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&staging)
.map_err(|source| ConfigError::Write {
path: staging.clone(),
source,
})?;
file.write_all(raw.as_bytes()).map_err(|source| {
let _ = fs::remove_file(&staging);
ConfigError::Write {
path: staging.clone(),
source,
}
})?;
Ok(staging)
}
fn staging_path(path: &Path) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(format!(".{}{STAGING_SUFFIX}", uuid::Uuid::new_v4()));
path.with_file_name(name)
}
pub(crate) fn write_config<T: Serialize>(path: &Path, value: &T) -> Result<(), ConfigError> {
let dest = resolve_destination(path);
if let Some(parent) = dest.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
path: parent.to_path_buf(),
source,
})?;
}
let raw = serde_yaml_ng::to_string(value)?;
let staging = write_staging(&dest, &raw)?;
if let Ok(existing) = fs::metadata(&dest) {
let _ = fs::set_permissions(&staging, existing.permissions());
}
fs::rename(&staging, &dest).map_err(|source| {
let _ = fs::remove_file(&staging);
ConfigError::Write {
path: dest.clone(),
source,
}
})
}
const MAX_CONFIG_SYMLINKS: usize = 40;
fn resolve_destination(path: &Path) -> PathBuf {
let mut dest = path.to_path_buf();
for _ in 0..MAX_CONFIG_SYMLINKS {
match fs::read_link(&dest) {
Ok(target) if target.is_absolute() => dest = target,
Ok(target) => {
dest = dest
.parent()
.map(|parent| parent.join(&target))
.unwrap_or(target);
},
Err(_) => break,
}
}
dest.canonicalize().unwrap_or(dest)
}
const STAGING_SUFFIX: &str = ".tmp";
#[derive(Clone, Debug, Getters, TypedBuilder)]
#[getset(get = "pub")]
pub struct YamlConfigSource {
path: PathBuf,
}
impl ConfigSource for YamlConfigSource {
fn load(&self) -> Result<WorkspaceConfig, ConfigError> {
load_workspace(&self.path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_config_new_refuses_to_overwrite() {
let dir = std::env::temp_dir().join(format!("muster-create-{}", std::process::id()));
let path = dir.join("muster.yml");
std::fs::create_dir_all(&dir).unwrap();
assert!(create_config_new(&path, &"first").unwrap(), "first create");
assert!(
!create_config_new(&path, &"second").unwrap(),
"existing file wins"
);
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "first");
let residue = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
.count();
assert_eq!(residue, 0, "no temporary files survive either outcome");
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn create_config_new_refuses_a_dangling_symlink_destination() {
use std::os::unix::fs::symlink;
let dir = std::env::temp_dir().join(format!("muster-create-link-{}", std::process::id()));
let path = dir.join("muster.yml");
std::fs::create_dir_all(&dir).unwrap();
symlink(dir.join("missing.yml"), &path).unwrap();
assert!(
!create_config_new(&path, &"content").unwrap(),
"an occupied path, even a dangling symlink, is never replaced"
);
assert!(
!dir.join("missing.yml").exists(),
"nothing was written through the symlink"
);
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn write_config_preserves_a_dangling_symlink_destination() {
use std::os::unix::fs::symlink;
let dir = std::env::temp_dir().join(format!("muster-dangle-{}", std::process::id()));
let target = dir.join("dotfiles").join("projects.yml");
let link = dir.join("projects.yml");
std::fs::create_dir_all(dir.join("dotfiles")).unwrap();
symlink(&target, &link).unwrap();
write_config(&link, &"content").unwrap();
assert!(
std::fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink(),
"the link survives"
);
assert_eq!(
std::fs::read_to_string(&target).unwrap().trim(),
"content",
"the write landed at the link target"
);
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn write_config_preserves_restrictive_permissions() {
use std::os::unix::fs::PermissionsExt;
const PRIVATE_MODE: u32 = 0o600;
let dir = std::env::temp_dir().join(format!("muster-mode-{}", std::process::id()));
let path = dir.join("projects.yml");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(&path, "old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(PRIVATE_MODE)).unwrap();
write_config(&path, &"new").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, PRIVATE_MODE, "mode survives the rewrite");
std::fs::remove_dir_all(dir).unwrap();
}
fn example_config() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/muster.yml")
}
#[test]
fn loads_the_example_config() {
let source = YamlConfigSource::builder().path(example_config()).build();
let config = source.load().unwrap();
assert!(!config.to_processes().is_empty());
}
#[test]
fn missing_file_is_a_read_error() {
let source = YamlConfigSource::builder()
.path(PathBuf::from("/nonexistent/muster.yml"))
.build();
assert!(matches!(source.load(), Err(ConfigError::Read { .. })));
}
}