1use std::process::Stdio;
19use std::time::Duration;
20
21use serde::Deserialize;
22use tokio::io::AsyncWriteExt;
23use tokio::process::Command;
24use tokio::time::timeout;
25
26use crate::now;
27
28pub const SYSTEM_PROMPT: &str = concat!(
37 "You are a precise text-merging assistant for a personal notes-sync tool. ",
38 "You merge two versions of a Claude Code auto-memory file that were edited independently on different machines and then synced through a central server. ",
39 "Rules: preserve every distinct fact from both versions; if both state the same fact in different words, keep it once, worded clearly (prefer the more complete wording); ",
40 "if they directly contradict each other, keep both and mark the conflict inline so a human can resolve it later; never invent information that isn't present in either version. ",
41 "Output ONLY the merged file content — no preamble, no explanation, no code fences, nothing else."
42);
43
44const DEFAULT_TIMEOUT: Duration = Duration::from_secs(45);
45const STATUS_TIMEOUT: Duration = Duration::from_secs(15);
46
47const DETAIL_LIMIT: usize = 500;
49
50#[derive(Debug, thiserror::Error)]
53pub enum Error {
54 #[error("claude CLI unavailable: {0}")]
56 Unavailable(String),
57 #[error("claude merge timed out after {0:?}")]
59 TimedOut(Duration),
60 #[error("claude failed: {0}")]
62 Failed(String),
63 #[error("claude returned non-JSON output: {0}")]
65 NonJson(String),
66 #[error("claude merge failed: {0}")]
68 Rejected(String),
69 #[error("claude returned an empty merge of two non-empty versions")]
76 EmptyResult,
77}
78
79#[derive(Debug, Clone)]
81pub struct Merger {
82 pub bin: String,
84 pub timeout: Duration,
86}
87
88impl Default for Merger {
89 fn default() -> Self {
90 Self {
91 bin: "claude".to_string(),
92 timeout: DEFAULT_TIMEOUT,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct Status {
102 pub checked_at: String,
104 pub available: bool,
106 pub logged_in: bool,
108 pub error: String,
110}
111
112#[derive(Debug, Deserialize)]
114struct CliResult {
115 #[serde(default)]
116 is_error: bool,
117 #[serde(default)]
118 result: String,
119}
120
121#[derive(Debug, Deserialize)]
123struct AuthStatus {
124 #[serde(default, rename = "loggedIn")]
125 logged_in: bool,
126}
127
128pub fn prompt(old_content: &str, new_content: &str) -> String {
130 format!("--- VERSION A (currently stored) ---\n{old_content}\n\n--- VERSION B (incoming) ---\n{new_content}")
131}
132
133impl Merger {
134 pub fn new(bin: impl Into<String>, timeout: Duration) -> Self {
136 Self {
137 bin: bin.into(),
138 timeout: if timeout.is_zero() {
139 DEFAULT_TIMEOUT
140 } else {
141 timeout
142 },
143 }
144 }
145
146 pub async fn merge(&self, old_content: &str, new_content: &str) -> Result<String, Error> {
148 let limit = if self.timeout.is_zero() {
149 DEFAULT_TIMEOUT
150 } else {
151 self.timeout
152 };
153
154 let mut cmd = Command::new(&self.bin);
155 cmd.arg("-p")
156 .args(["--output-format", "json"])
157 .args(["--input-format", "text"])
158 .args(["--system-prompt", SYSTEM_PROMPT])
159 .arg("--exclude-dynamic-system-prompt-sections")
160 .arg("--strict-mcp-config")
161 .args(["--tools", ""])
168 .args(["--max-turns", "1"])
169 .arg("--no-session-persistence")
173 .current_dir(std::env::temp_dir())
178 .stdin(Stdio::piped())
179 .stdout(Stdio::piped())
180 .stderr(Stdio::piped())
181 .kill_on_drop(true);
184
185 let mut child = cmd.spawn().map_err(|e| match e.kind() {
186 std::io::ErrorKind::NotFound => {
187 Error::Unavailable(format!("{} not found on PATH", self.bin))
188 }
189 _ => Error::Failed(e.to_string()),
190 })?;
191
192 let mut stdin = child.stdin.take().expect("stdin was piped");
193 let body = prompt(old_content, new_content);
194 let run = async {
197 let write = async move {
198 stdin.write_all(body.as_bytes()).await?;
199 stdin.shutdown().await
200 };
201 let (written, out) = tokio::join!(write, child.wait_with_output());
202 if let Err(e) = written {
206 if e.kind() != std::io::ErrorKind::BrokenPipe {
207 return Err(e);
208 }
209 }
210 out.map(|o| (o.status, o.stdout, o.stderr))
211 };
212
213 let (status, stdout, stderr) = match timeout(limit, run).await {
214 Err(_) => return Err(Error::TimedOut(limit)),
215 Ok(Err(e)) => return Err(Error::Failed(e.to_string())),
216 Ok(Ok(v)) => v,
217 };
218
219 if !status.success() {
220 let detail = String::from_utf8_lossy(&stderr).trim().to_string();
221 let detail = if detail.is_empty() {
222 "(no stderr)".to_string()
223 } else {
224 detail
225 };
226 return Err(Error::Failed(format!(
227 "exit {}: {}",
228 status
229 .code()
230 .map_or_else(|| "signal".into(), |c| c.to_string()),
231 truncate(&detail, DETAIL_LIMIT)
232 )));
233 }
234
235 let out = String::from_utf8_lossy(&stdout);
236 let parsed: CliResult = serde_json::from_str(&out)
237 .map_err(|_| Error::NonJson(truncate(&out, DETAIL_LIMIT).to_string()))?;
238 if parsed.is_error {
239 return Err(Error::Rejected(
240 truncate(&parsed.result, DETAIL_LIMIT).to_string(),
241 ));
242 }
243
244 if parsed.result.trim().is_empty()
254 && !(old_content.trim().is_empty() && new_content.trim().is_empty())
255 {
256 return Err(Error::EmptyResult);
257 }
258 Ok(parsed.result)
259 }
260
261 pub async fn check_status(&self) -> Status {
264 let checked_at = now();
265 let run = Command::new(&self.bin)
266 .args(["auth", "status"])
267 .stdin(Stdio::null())
268 .kill_on_drop(true)
269 .output();
270
271 let out = match timeout(STATUS_TIMEOUT, run).await {
272 Err(_) => {
273 return Status {
274 checked_at,
275 error: format!("`{} auth status` timed out", self.bin),
276 ..Status::default()
277 }
278 }
279 Ok(Err(e)) => {
280 let error = if e.kind() == std::io::ErrorKind::NotFound {
281 "claude CLI not found on PATH".to_string()
282 } else {
283 e.to_string()
284 };
285 return Status {
286 checked_at,
287 error,
288 ..Status::default()
289 };
290 }
291 Ok(Ok(out)) => out,
292 };
293
294 if !out.status.success() {
295 let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
296 return Status {
297 checked_at,
298 available: true,
299 logged_in: false,
300 error: if detail.is_empty() {
301 "`claude auth status` failed".to_string()
302 } else {
303 truncate(&detail, DETAIL_LIMIT).to_string()
304 },
305 };
306 }
307
308 match serde_json::from_slice::<AuthStatus>(&out.stdout) {
309 Ok(a) => Status {
310 checked_at,
311 available: true,
312 logged_in: a.logged_in,
313 error: String::new(),
314 },
315 Err(_) => Status {
316 checked_at,
317 available: true,
318 logged_in: false,
319 error: "could not parse `claude auth status` output".to_string(),
320 },
321 }
322 }
323}
324
325fn truncate(s: &str, n: usize) -> &str {
326 if s.len() <= n {
327 return s;
328 }
329 let mut end = n;
330 while end > 0 && !s.is_char_boundary(end) {
331 end -= 1;
332 }
333 &s[..end]
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn prompt_labels_both_versions() {
342 let p = prompt("old", "new");
343 assert!(p.contains("--- VERSION A (currently stored) ---\nold"));
344 assert!(p.contains("--- VERSION B (incoming) ---\nnew"));
345 }
346
347 #[test]
349 fn the_system_prompt_is_verbatim() {
350 assert!(SYSTEM_PROMPT.starts_with("You are a precise text-merging assistant"));
351 assert!(SYSTEM_PROMPT.ends_with("no code fences, nothing else."));
352 }
353
354 #[tokio::test]
355 async fn a_missing_binary_is_unavailable_not_a_panic() {
356 let m = Merger::new("definitely-not-a-real-binary", Duration::from_secs(1));
357 let st = m.check_status().await;
358 assert!(!st.available);
359 assert!(!st.logged_in);
360 assert!(!st.error.is_empty());
361 assert!(!st.checked_at.is_empty());
362
363 let err = m.merge("a", "b").await.unwrap_err();
364 assert!(matches!(err, Error::Unavailable(_)), "got {err:?}");
365 }
366
367 #[tokio::test]
370 async fn parses_the_json_envelope_and_honors_is_error() {
371 let (_ok_dir, bin) = fake_claude(r#"{"is_error":false,"result":"merged!"}"#);
372 assert_eq!(
373 Merger::new(bin, Duration::from_secs(10))
374 .merge("a", "b")
375 .await
376 .unwrap(),
377 "merged!"
378 );
379
380 let (_bad_dir, bin) = fake_claude(r#"{"is_error":true,"result":"nope"}"#);
381 let err = Merger::new(bin, Duration::from_secs(10))
382 .merge("a", "b")
383 .await;
384 assert!(matches!(err, Err(Error::Rejected(_))), "got {err:?}");
385
386 let (_junk_dir, bin) = fake_claude("not json");
387 let err = Merger::new(bin, Duration::from_secs(10))
388 .merge("a", "b")
389 .await;
390 assert!(matches!(err, Err(Error::NonJson(_))), "got {err:?}");
391 }
392
393 #[tokio::test]
397 async fn the_merge_runs_with_no_tools_and_one_turn() {
398 use std::io::Write;
399 use std::os::unix::fs::PermissionsExt;
400
401 let dir = tempfile::tempdir().unwrap();
402 let args = dir.path().join("args");
403 let path = dir.path().join("claude");
404 let mut f = std::fs::File::create(&path).unwrap();
405 writeln!(f, "#!/bin/sh").unwrap();
406 writeln!(
407 f,
408 "for a in \"$@\"; do printf '%s\\n' \"$a\"; done > '{}'",
409 args.display()
410 )
411 .unwrap();
412 writeln!(f, "cat > /dev/null").unwrap();
413 writeln!(f, r#"printf '%s' '{{"is_error":false,"result":"merged"}}'"#).unwrap();
414 drop(f);
415 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
416 settle(&path);
417
418 Merger::new(path.to_str().unwrap(), Duration::from_secs(10))
419 .merge("old notes", "new notes")
420 .await
421 .unwrap();
422 let passed = std::fs::read_to_string(&args).unwrap();
423 let passed: Vec<&str> = passed.lines().collect();
424 let after = |flag: &str| {
425 passed
426 .iter()
427 .position(|a| *a == flag)
428 .and_then(|i| passed.get(i + 1).copied())
429 };
430 assert_eq!(after("--tools"), Some(""), "{passed:?}");
431 assert_eq!(after("--max-turns"), Some("1"), "{passed:?}");
432 assert_eq!(after("--output-format"), Some("json"), "{passed:?}");
433 for flag in [
434 "-p",
435 "--no-session-persistence",
436 "--strict-mcp-config",
437 "--exclude-dynamic-system-prompt-sections",
438 ] {
439 assert!(passed.contains(&flag), "{flag} missing from {passed:?}");
440 }
441 assert!(
442 !passed.iter().any(|a| a.contains("old notes")),
443 "the notes go on stdin, not in the arguments"
444 );
445 }
446
447 #[tokio::test]
448 async fn reads_logged_in_from_auth_status() {
449 let (_dir, bin) = fake_claude(r#"{"loggedIn":true}"#);
450 let st = Merger::new(bin, Duration::from_secs(10))
451 .check_status()
452 .await;
453 assert!(st.available && st.logged_in, "got {st:?}");
454 }
455
456 fn fake_claude(out: &str) -> (tempfile::TempDir, String) {
460 use std::io::Write;
461 use std::os::unix::fs::PermissionsExt;
462
463 let dir = tempfile::tempdir().unwrap();
464 let path = dir.path().join("claude");
465 let mut f = std::fs::File::create(&path).unwrap();
466 writeln!(f, "#!/bin/sh").unwrap();
467 writeln!(f, "cat > /dev/null").unwrap();
468 writeln!(f, "printf '%s' '{out}'").unwrap();
469 drop(f);
470 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
471 settle(&path);
472 let bin = path.to_str().unwrap().to_string();
473 (dir, bin)
474 }
475
476 fn settle(path: &std::path::Path) {
491 use std::process::Stdio;
492
493 for _ in 0..100 {
494 match std::process::Command::new(path)
495 .stdin(Stdio::null())
496 .stdout(Stdio::null())
497 .stderr(Stdio::null())
498 .status()
499 {
500 Ok(_) => return,
503 Err(e) if e.raw_os_error() == Some(libc_etxtbsy()) => {
504 std::thread::sleep(Duration::from_millis(5));
505 }
506 Err(_) => return,
508 }
509 }
510 panic!("{} stayed ETXTBSY for half a second", path.display());
511 }
512
513 fn libc_etxtbsy() -> i32 {
515 26
516 }
517}