use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::Mutex;
use crate::config;
#[derive(Debug, PartialEq, Eq)]
pub enum EditError {
Invalid(String),
Io(String),
}
pub struct ConfigFile {
path: PathBuf,
lock: Mutex<()>,
}
impl ConfigFile {
pub fn new(path: PathBuf) -> Self {
ConfigFile {
path,
lock: Mutex::new(()),
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub async fn edit(&self, keys: &[(&str, &str, serde_json::Value)]) -> Result<(), EditError> {
let _held = self.lock.lock().await;
let previous = read_optional(&self.path).map_err(EditError::Io)?;
let next = with_keys(previous.as_deref(), keys).map_err(EditError::Invalid)?;
config::parse(&self.path, &next).map_err(|e| EditError::Invalid(e.to_string()))?;
write_atomic(&self.path, &next).map_err(|e| EditError::Io(e.to_string()))
}
}
fn with_keys(
raw: Option<&str>,
keys: &[(&str, &str, serde_json::Value)],
) -> Result<String, String> {
let mut document: serde_json::Value = match raw {
Some(raw) => serde_json::from_str(raw).map_err(|e| format!("config.json: {e}"))?,
None => serde_json::json!({}),
};
let root = document
.as_object_mut()
.ok_or("config.json: the top level must be an object")?;
for (block, key, value) in keys {
let block = root
.entry(block.to_string())
.or_insert_with(|| serde_json::json!({}));
if !block.is_object() {
*block = serde_json::json!({});
}
block
.as_object_mut()
.expect("just made an object")
.insert(key.to_string(), value.clone());
}
let text = serde_json::to_string_pretty(&document).map_err(|e| e.to_string())?;
Ok(format!("{text}\n"))
}
pub fn read_optional(path: &Path) -> Result<Option<String>, String> {
match std::fs::read_to_string(path) {
Ok(raw) => Ok(Some(raw)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
static STAGING: AtomicU64 = AtomicU64::new(0);
fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> {
let target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let parent = target
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."))
.to_path_buf();
std::fs::create_dir_all(&parent)?;
let mut staging = target.clone().into_os_string();
staging.push(format!(
".{}.{}.tmp",
std::process::id(),
STAGING.fetch_add(1, Ordering::Relaxed)
));
let staging = PathBuf::from(staging);
let written = stage(&staging, text).and_then(|()| std::fs::rename(&staging, &target));
if written.is_err() {
let _ = std::fs::remove_file(&staging);
}
written?;
std::fs::File::open(&parent)?.sync_all()
}
fn stage(staging: &Path, text: &str) -> std::io::Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(staging)?;
std::fs::set_permissions(staging, std::fs::Permissions::from_mode(0o600))?;
file.write_all(text.as_bytes())?;
file.sync_all()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[tokio::test]
async fn concurrent_edits_to_different_blocks_both_land() {
let dir = tempfile::tempdir().unwrap();
let file = Arc::new(ConfigFile::new(dir.path().join(config::CONFIG_FILE_NAME)));
let edits: Vec<_> = (0..40u16)
.map(|n| {
let file = file.clone();
tokio::spawn(async move {
let edit = match n % 2 {
0 => ("mcp", "port", serde_json::json!(9000 + n)),
_ => ("files", "listing", serde_json::json!(true)),
};
file.edit(&[edit]).await.unwrap();
})
})
.collect();
for edit in edits {
edit.await.unwrap();
}
let written = config::load_from(file.path()).unwrap();
assert!(written.files.listing);
assert_ne!(written.mcp.port, 0);
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name())
.filter(|name| name != config::CONFIG_FILE_NAME)
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
}
}