use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::domain::harness::SyncSpec;
use crate::domain::sync::deep_merge;
use crate::errors::OneharnessError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileStatus {
Created,
Updated,
Unchanged,
}
pub fn apply(
project_dir: &Path,
spec: &SyncSpec,
fragment: &Value,
check: bool,
) -> Result<(PathBuf, FileStatus), OneharnessError> {
let target = spec
.alt_files
.iter()
.map(|name| project_dir.join(name))
.find(|path| path.is_file())
.unwrap_or_else(|| project_dir.join(spec.file));
let existing: Option<Value> = match std::fs::read_to_string(&target) {
Ok(text) => Some(serde_json::from_str(&text).map_err(|e| {
OneharnessError::HarnessConfigUnmergeable {
path: target.display().to_string(),
message: format!("not valid JSON ({e}); fix or remove it and re-run"),
}
})?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(source) => {
return Err(OneharnessError::HarnessConfigRead {
path: target.display().to_string(),
source,
})
}
};
let (merged, status) = match &existing {
Some(existing) => {
let merged = deep_merge(existing, fragment);
let status = if &merged == existing {
FileStatus::Unchanged
} else {
FileStatus::Updated
};
(merged, status)
}
None => (fragment.clone(), FileStatus::Created),
};
if !check && status != FileStatus::Unchanged {
write_atomically(&target, &merged)?;
}
Ok((target, status))
}
pub(crate) fn write_atomically(target: &Path, value: &Value) -> Result<(), OneharnessError> {
let write_err = |source: std::io::Error| OneharnessError::HarnessConfigWrite {
path: target.display().to_string(),
source,
};
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(write_err)?;
}
let mut text = serde_json::to_string_pretty(value)?;
text.push('\n');
let tmp = target.with_extension("oneharness.tmp");
std::fs::write(&tmp, &text).map_err(write_err)?;
std::fs::rename(&tmp, target).map_err(write_err)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn temp_project(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("oneharness-sync-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
const SPEC: SyncSpec = SyncSpec {
file: "sub/config.json",
alt_files: &[".alt.json"],
allow_path: None,
deny_path: None,
hooks_path: None,
schema_seed: None,
};
#[test]
fn creates_with_parent_dirs_then_reports_unchanged() {
let dir = temp_project("create");
let fragment = json!({"a": 1});
let (path, status) = apply(&dir, &SPEC, &fragment, false).unwrap();
assert_eq!(status, FileStatus::Created);
assert_eq!(path, dir.join("sub/config.json"));
let on_disk: Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(on_disk, fragment);
let (_, status) = apply(&dir, &SPEC, &fragment, false).unwrap();
assert_eq!(status, FileStatus::Unchanged);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn existing_alt_file_is_merged_into_instead() {
let dir = temp_project("alt");
std::fs::write(dir.join(".alt.json"), "{\"keep\": true}").unwrap();
let (path, status) = apply(&dir, &SPEC, &json!({"a": 1}), false).unwrap();
assert_eq!(status, FileStatus::Updated);
assert_eq!(path, dir.join(".alt.json"));
let on_disk: Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(on_disk, json!({"keep": true, "a": 1}));
assert!(!dir.join("sub/config.json").exists(), "no shadow file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn check_mode_writes_nothing() {
let dir = temp_project("check");
let (_, status) = apply(&dir, &SPEC, &json!({"a": 1}), true).unwrap();
assert_eq!(status, FileStatus::Created);
assert!(!dir.join("sub/config.json").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unparseable_existing_file_is_a_loud_error_and_untouched() {
let dir = temp_project("jsonc");
let path = dir.join(".alt.json");
std::fs::write(&path, "{ // a comment\n \"a\": 1 }").unwrap();
let err = apply(&dir, &SPEC, &json!({"b": 2}), false).unwrap_err();
assert!(err.to_string().contains("not valid JSON"), "{err}");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"{ // a comment\n \"a\": 1 }",
"file must be left untouched"
);
let _ = std::fs::remove_dir_all(&dir);
}
}