oneharness_core/io/
sync.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum FileStatus {
17 Created,
19 Updated,
21 Unchanged,
23}
24
25pub 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
81pub(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 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}