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
128fn failure_detail(stdout: &[u8], stderr: &[u8]) -> String {
136 let out = String::from_utf8_lossy(stdout);
137 let said = serde_json::from_str::<CliResult>(out.trim())
138 .ok()
139 .map(|r| r.result.trim().to_string())
140 .filter(|r| !r.is_empty());
141 let err = String::from_utf8_lossy(stderr).trim().to_string();
142 match (said, err.is_empty(), out.trim().is_empty()) {
143 (Some(said), true, _) => said,
144 (Some(said), false, _) => format!("{said} ({err})"),
145 (None, false, _) => err,
146 (None, true, false) => out.trim().to_string(),
147 (None, true, true) => "no output on stdout or stderr".to_string(),
148 }
149}
150
151pub fn prompt(old_content: &str, new_content: &str) -> String {
153 format!("--- VERSION A (currently stored) ---\n{old_content}\n\n--- VERSION B (incoming) ---\n{new_content}")
154}
155
156impl Merger {
157 pub fn new(bin: impl Into<String>, timeout: Duration) -> Self {
159 Self {
160 bin: bin.into(),
161 timeout: if timeout.is_zero() {
162 DEFAULT_TIMEOUT
163 } else {
164 timeout
165 },
166 }
167 }
168
169 pub async fn merge(&self, old_content: &str, new_content: &str) -> Result<String, Error> {
171 let result = self
172 .ask(SYSTEM_PROMPT, &prompt(old_content, new_content))
173 .await?;
174
175 if result.trim().is_empty()
185 && !(old_content.trim().is_empty() && new_content.trim().is_empty())
186 {
187 return Err(Error::EmptyResult);
188 }
189 Ok(result)
190 }
191
192 pub async fn ask(&self, system_prompt: &str, body: &str) -> Result<String, Error> {
198 let limit = if self.timeout.is_zero() {
199 DEFAULT_TIMEOUT
200 } else {
201 self.timeout
202 };
203
204 let mut cmd = Command::new(&self.bin);
205 cmd.arg("-p")
206 .args(["--output-format", "json"])
207 .args(["--input-format", "text"])
208 .args(["--system-prompt", system_prompt])
209 .arg("--exclude-dynamic-system-prompt-sections")
210 .arg("--strict-mcp-config")
211 .args(["--tools", ""])
218 .args(["--max-turns", "1"])
219 .arg("--no-session-persistence")
223 .current_dir(std::env::temp_dir())
228 .stdin(Stdio::piped())
229 .stdout(Stdio::piped())
230 .stderr(Stdio::piped())
231 .kill_on_drop(true);
234
235 let mut child = cmd.spawn().map_err(|e| match e.kind() {
236 std::io::ErrorKind::NotFound => {
237 Error::Unavailable(format!("{} not found on PATH", self.bin))
238 }
239 _ => Error::Failed(e.to_string()),
240 })?;
241
242 let mut stdin = child.stdin.take().expect("stdin was piped");
243 let body = body.to_string();
244 let run = async {
247 let write = async move {
248 stdin.write_all(body.as_bytes()).await?;
249 stdin.shutdown().await
250 };
251 let (written, out) = tokio::join!(write, child.wait_with_output());
252 if let Err(e) = written {
256 if e.kind() != std::io::ErrorKind::BrokenPipe {
257 return Err(e);
258 }
259 }
260 out.map(|o| (o.status, o.stdout, o.stderr))
261 };
262
263 let (status, stdout, stderr) = match timeout(limit, run).await {
264 Err(_) => return Err(Error::TimedOut(limit)),
265 Ok(Err(e)) => return Err(Error::Failed(e.to_string())),
266 Ok(Ok(v)) => v,
267 };
268
269 if !status.success() {
270 let detail = failure_detail(&stdout, &stderr);
271 return Err(Error::Failed(format!(
272 "exit {}: {}",
273 status
274 .code()
275 .map_or_else(|| "signal".into(), |c| c.to_string()),
276 truncate(&detail, DETAIL_LIMIT)
277 )));
278 }
279
280 let out = String::from_utf8_lossy(&stdout);
281 let parsed: CliResult = serde_json::from_str(&out)
282 .map_err(|_| Error::NonJson(truncate(&out, DETAIL_LIMIT).to_string()))?;
283 if parsed.is_error {
284 return Err(Error::Rejected(
285 truncate(&parsed.result, DETAIL_LIMIT).to_string(),
286 ));
287 }
288 Ok(parsed.result)
289 }
290
291 pub async fn check_status(&self) -> Status {
294 let checked_at = now();
295 let run = Command::new(&self.bin)
296 .args(["auth", "status"])
297 .stdin(Stdio::null())
298 .kill_on_drop(true)
299 .output();
300
301 let out = match timeout(STATUS_TIMEOUT, run).await {
302 Err(_) => {
303 return Status {
304 checked_at,
305 error: format!("`{} auth status` timed out", self.bin),
306 ..Status::default()
307 }
308 }
309 Ok(Err(e)) => {
310 let error = if e.kind() == std::io::ErrorKind::NotFound {
311 "claude CLI not found on PATH".to_string()
312 } else {
313 e.to_string()
314 };
315 return Status {
316 checked_at,
317 error,
318 ..Status::default()
319 };
320 }
321 Ok(Ok(out)) => out,
322 };
323
324 if !out.status.success() {
325 if let Ok(a) = serde_json::from_slice::<AuthStatus>(&out.stdout) {
328 return Status {
329 checked_at,
330 available: true,
331 logged_in: a.logged_in,
332 error: String::new(),
333 };
334 }
335 let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
336 return Status {
337 checked_at,
338 available: true,
339 logged_in: false,
340 error: if detail.is_empty() {
341 "`claude auth status` failed".to_string()
342 } else {
343 truncate(&detail, DETAIL_LIMIT).to_string()
344 },
345 };
346 }
347
348 match serde_json::from_slice::<AuthStatus>(&out.stdout) {
349 Ok(a) => Status {
350 checked_at,
351 available: true,
352 logged_in: a.logged_in,
353 error: String::new(),
354 },
355 Err(_) => Status {
356 checked_at,
357 available: true,
358 logged_in: false,
359 error: "could not parse `claude auth status` output".to_string(),
360 },
361 }
362 }
363}
364
365fn truncate(s: &str, n: usize) -> &str {
366 if s.len() <= n {
367 return s;
368 }
369 let mut end = n;
370 while end > 0 && !s.is_char_boundary(end) {
371 end -= 1;
372 }
373 &s[..end]
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn prompt_labels_both_versions() {
382 let p = prompt("old", "new");
383 assert!(p.contains("--- VERSION A (currently stored) ---\nold"));
384 assert!(p.contains("--- VERSION B (incoming) ---\nnew"));
385 }
386
387 #[test]
389 fn the_system_prompt_is_verbatim() {
390 assert!(SYSTEM_PROMPT.starts_with("You are a precise text-merging assistant"));
391 assert!(SYSTEM_PROMPT.ends_with("no code fences, nothing else."));
392 }
393
394 #[tokio::test]
395 async fn a_missing_binary_is_unavailable_not_a_panic() {
396 let m = Merger::new("definitely-not-a-real-binary", Duration::from_secs(1));
397 let st = m.check_status().await;
398 assert!(!st.available);
399 assert!(!st.logged_in);
400 assert!(!st.error.is_empty());
401 assert!(!st.checked_at.is_empty());
402
403 let err = m.merge("a", "b").await.unwrap_err();
404 assert!(matches!(err, Error::Unavailable(_)), "got {err:?}");
405 }
406
407 #[tokio::test]
410 async fn parses_the_json_envelope_and_honors_is_error() {
411 let (_ok_dir, bin) = fake_claude(r#"{"is_error":false,"result":"merged!"}"#);
412 assert_eq!(
413 Merger::new(bin, Duration::from_secs(10))
414 .merge("a", "b")
415 .await
416 .unwrap(),
417 "merged!"
418 );
419
420 let (_bad_dir, bin) = fake_claude(r#"{"is_error":true,"result":"nope"}"#);
421 let err = Merger::new(bin, Duration::from_secs(10))
422 .merge("a", "b")
423 .await;
424 assert!(matches!(err, Err(Error::Rejected(_))), "got {err:?}");
425
426 let (_junk_dir, bin) = fake_claude("not json");
427 let err = Merger::new(bin, Duration::from_secs(10))
428 .merge("a", "b")
429 .await;
430 assert!(matches!(err, Err(Error::NonJson(_))), "got {err:?}");
431 }
432
433 #[tokio::test]
437 async fn the_merge_runs_with_no_tools_and_one_turn() {
438 use std::io::Write;
439 use std::os::unix::fs::PermissionsExt;
440
441 let dir = tempfile::tempdir().unwrap();
442 let args = dir.path().join("args");
443 let path = dir.path().join("claude");
444 let mut f = std::fs::File::create(&path).unwrap();
445 writeln!(f, "#!/bin/sh").unwrap();
446 writeln!(
447 f,
448 "for a in \"$@\"; do printf '%s\\n' \"$a\"; done > '{}'",
449 args.display()
450 )
451 .unwrap();
452 writeln!(f, "cat > /dev/null").unwrap();
453 writeln!(f, r#"printf '%s' '{{"is_error":false,"result":"merged"}}'"#).unwrap();
454 drop(f);
455 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
456 settle(&path);
457
458 Merger::new(path.to_str().unwrap(), Duration::from_secs(10))
459 .merge("old notes", "new notes")
460 .await
461 .unwrap();
462 let passed = std::fs::read_to_string(&args).unwrap();
463 let passed: Vec<&str> = passed.lines().collect();
464 let after = |flag: &str| {
465 passed
466 .iter()
467 .position(|a| *a == flag)
468 .and_then(|i| passed.get(i + 1).copied())
469 };
470 assert_eq!(after("--tools"), Some(""), "{passed:?}");
471 assert_eq!(after("--max-turns"), Some("1"), "{passed:?}");
472 assert_eq!(after("--output-format"), Some("json"), "{passed:?}");
473 for flag in [
474 "-p",
475 "--no-session-persistence",
476 "--strict-mcp-config",
477 "--exclude-dynamic-system-prompt-sections",
478 ] {
479 assert!(passed.contains(&flag), "{flag} missing from {passed:?}");
480 }
481 assert!(
482 !passed.iter().any(|a| a.contains("old notes")),
483 "the notes go on stdin, not in the arguments"
484 );
485 }
486
487 #[tokio::test]
488 async fn reads_logged_in_from_auth_status() {
489 let (_dir, bin) = fake_claude(r#"{"loggedIn":true}"#);
490 let st = Merger::new(bin, Duration::from_secs(10))
491 .check_status()
492 .await;
493 assert!(st.available && st.logged_in, "got {st:?}");
494 }
495
496 #[test]
499 fn a_failure_says_what_the_cli_said_on_stdout() {
500 let envelope = br#"{"type":"result","is_error":true,"result":"Invalid API key \u00b7 Please run /login"}"#;
501 assert_eq!(
502 failure_detail(envelope, b""),
503 "Invalid API key \u{b7} Please run /login"
504 );
505 assert_eq!(
506 failure_detail(envelope, b"warn: x"),
507 "Invalid API key \u{b7} Please run /login (warn: x)"
508 );
509 assert_eq!(failure_detail(b"", b"boom"), "boom");
510 assert_eq!(failure_detail(b"plain text\n", b""), "plain text");
511 assert_eq!(failure_detail(b"", b""), "no output on stdout or stderr");
512 }
513
514 #[tokio::test]
517 async fn a_not_logged_in_answer_with_a_failing_exit_is_still_an_answer() {
518 use std::os::unix::fs::PermissionsExt;
519 let dir = tempfile::tempdir().unwrap();
520 let path = dir.path().join("claude");
521 std::fs::write(&path, "#!/bin/sh\necho '{\"loggedIn\":false}'\nexit 1\n").unwrap();
522 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
523 settle(&path);
524 let st = Merger::new(path.to_str().unwrap(), Duration::from_secs(10))
525 .check_status()
526 .await;
527 assert!(st.available && !st.logged_in, "{st:?}");
528 assert!(st.error.is_empty(), "{st:?}");
529 }
530
531 fn fake_claude(out: &str) -> (tempfile::TempDir, String) {
535 use std::io::Write;
536 use std::os::unix::fs::PermissionsExt;
537
538 let dir = tempfile::tempdir().unwrap();
539 let path = dir.path().join("claude");
540 let mut f = std::fs::File::create(&path).unwrap();
541 writeln!(f, "#!/bin/sh").unwrap();
542 writeln!(f, "cat > /dev/null").unwrap();
543 writeln!(f, "printf '%s' '{out}'").unwrap();
544 drop(f);
545 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
546 settle(&path);
547 let bin = path.to_str().unwrap().to_string();
548 (dir, bin)
549 }
550
551 fn settle(path: &std::path::Path) {
566 use std::process::Stdio;
567
568 for _ in 0..100 {
569 match std::process::Command::new(path)
570 .stdin(Stdio::null())
571 .stdout(Stdio::null())
572 .stderr(Stdio::null())
573 .status()
574 {
575 Ok(_) => return,
578 Err(e) if e.raw_os_error() == Some(libc_etxtbsy()) => {
579 std::thread::sleep(Duration::from_millis(5));
580 }
581 Err(_) => return,
583 }
584 }
585 panic!("{} stayed ETXTBSY for half a second", path.display());
586 }
587
588 fn libc_etxtbsy() -> i32 {
590 26
591 }
592}