Skip to main content

oneharness_core/io/
sync.rs

1//! Reading and writing harness config files for `oneharness sync`. This is an
2//! I/O boundary: it touches the project's filesystem. The merge itself is pure
3//! (`src/domain/sync.rs`); this layer only locates, reads, compares, and
4//! (atomically) writes.
5
6use std::path::{Path, PathBuf};
7
8use serde_json::Value;
9
10use crate::domain::harness::SyncSpec;
11use crate::domain::sync::deep_merge;
12use crate::errors::OneharnessError;
13
14/// What applying a fragment did (or, under `check`, would do) to one file.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum FileStatus {
17    /// The file did not exist and was (or would be) created.
18    Created,
19    /// The file existed and its content changed (or would change).
20    Updated,
21    /// The file already contains everything the fragment asks for.
22    Unchanged,
23}
24
25/// Merge `fragment` into the harness's config file under `project_dir`.
26///
27/// The target is the registry's `file`, unless one of the higher-precedence
28/// `alt_files` already exists — merging into the file the harness actually
29/// reads, rather than creating a second, shadowed one. A file that exists but
30/// cannot be parsed as JSON is a loud error and is left untouched: oneharness
31/// only rewrites files it can round-trip (a JSONC file with comments would
32/// lose them). Under `check`, nothing is written.
33pub fn apply(
34    project_dir: &Path,
35    spec: &SyncSpec,
36    fragment: &Value,
37    check: bool,
38) -> Result<(PathBuf, FileStatus), OneharnessError> {
39    let target = spec
40        .alt_files
41        .iter()
42        .map(|name| project_dir.join(name))
43        .find(|path| path.is_file())
44        .unwrap_or_else(|| project_dir.join(spec.file));
45
46    let existing: Option<Value> = match std::fs::read_to_string(&target) {
47        Ok(text) => Some(serde_json::from_str(&text).map_err(|e| {
48            OneharnessError::HarnessConfigUnmergeable {
49                path: target.display().to_string(),
50                message: format!("not valid JSON ({e}); fix or remove it and re-run"),
51            }
52        })?),
53        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
54        Err(source) => {
55            return Err(OneharnessError::HarnessConfigRead {
56                path: target.display().to_string(),
57                source,
58            })
59        }
60    };
61
62    let (merged, status) = match &existing {
63        Some(existing) => {
64            let merged = deep_merge(existing, fragment);
65            let status = if &merged == existing {
66                FileStatus::Unchanged
67            } else {
68                FileStatus::Updated
69            };
70            (merged, status)
71        }
72        None => (fragment.clone(), FileStatus::Created),
73    };
74
75    if !check && status != FileStatus::Unchanged {
76        write_atomically(&target, &merged)?;
77    }
78    Ok((target, status))
79}
80
81/// Pretty-print and write via a temp file + rename, so a crash mid-write can
82/// never leave a harness with a truncated config file.
83pub(crate) fn write_atomically(target: &Path, value: &Value) -> Result<(), OneharnessError> {
84    let write_err = |source: std::io::Error| OneharnessError::HarnessConfigWrite {
85        path: target.display().to_string(),
86        source,
87    };
88    if let Some(parent) = target.parent() {
89        std::fs::create_dir_all(parent).map_err(write_err)?;
90    }
91    let mut text = serde_json::to_string_pretty(value)?;
92    text.push('\n');
93    let tmp = target.with_extension("oneharness.tmp");
94    std::fs::write(&tmp, &text).map_err(write_err)?;
95    std::fs::rename(&tmp, target).map_err(write_err)
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use serde_json::json;
102
103    fn temp_project(tag: &str) -> PathBuf {
104        let dir =
105            std::env::temp_dir().join(format!("oneharness-sync-{tag}-{}", std::process::id()));
106        let _ = std::fs::remove_dir_all(&dir);
107        std::fs::create_dir_all(&dir).unwrap();
108        dir
109    }
110
111    const SPEC: SyncSpec = SyncSpec {
112        file: "sub/config.json",
113        alt_files: &[".alt.json"],
114        allow_path: None,
115        deny_path: None,
116        hooks_path: None,
117        schema_seed: None,
118    };
119
120    #[test]
121    fn creates_with_parent_dirs_then_reports_unchanged() {
122        let dir = temp_project("create");
123        let fragment = json!({"a": 1});
124        let (path, status) = apply(&dir, &SPEC, &fragment, false).unwrap();
125        assert_eq!(status, FileStatus::Created);
126        assert_eq!(path, dir.join("sub/config.json"));
127        let on_disk: Value =
128            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
129        assert_eq!(on_disk, fragment);
130        // Idempotent: a second apply changes nothing.
131        let (_, status) = apply(&dir, &SPEC, &fragment, false).unwrap();
132        assert_eq!(status, FileStatus::Unchanged);
133        let _ = std::fs::remove_dir_all(&dir);
134    }
135
136    #[test]
137    fn existing_alt_file_is_merged_into_instead() {
138        let dir = temp_project("alt");
139        std::fs::write(dir.join(".alt.json"), "{\"keep\": true}").unwrap();
140        let (path, status) = apply(&dir, &SPEC, &json!({"a": 1}), false).unwrap();
141        assert_eq!(status, FileStatus::Updated);
142        assert_eq!(path, dir.join(".alt.json"));
143        let on_disk: Value =
144            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
145        assert_eq!(on_disk, json!({"keep": true, "a": 1}));
146        assert!(!dir.join("sub/config.json").exists(), "no shadow file");
147        let _ = std::fs::remove_dir_all(&dir);
148    }
149
150    #[test]
151    fn check_mode_writes_nothing() {
152        let dir = temp_project("check");
153        let (_, status) = apply(&dir, &SPEC, &json!({"a": 1}), true).unwrap();
154        assert_eq!(status, FileStatus::Created);
155        assert!(!dir.join("sub/config.json").exists());
156        let _ = std::fs::remove_dir_all(&dir);
157    }
158
159    #[test]
160    fn unparseable_existing_file_is_a_loud_error_and_untouched() {
161        let dir = temp_project("jsonc");
162        let path = dir.join(".alt.json");
163        std::fs::write(&path, "{ // a comment\n  \"a\": 1 }").unwrap();
164        let err = apply(&dir, &SPEC, &json!({"b": 2}), false).unwrap_err();
165        assert!(err.to_string().contains("not valid JSON"), "{err}");
166        assert_eq!(
167            std::fs::read_to_string(&path).unwrap(),
168            "{ // a comment\n  \"a\": 1 }",
169            "file must be left untouched"
170        );
171        let _ = std::fs::remove_dir_all(&dir);
172    }
173}