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 result = self
149 .ask(SYSTEM_PROMPT, &prompt(old_content, new_content))
150 .await?;
151
152 if result.trim().is_empty()
162 && !(old_content.trim().is_empty() && new_content.trim().is_empty())
163 {
164 return Err(Error::EmptyResult);
165 }
166 Ok(result)
167 }
168
169 pub async fn ask(&self, system_prompt: &str, body: &str) -> Result<String, Error> {
175 let limit = if self.timeout.is_zero() {
176 DEFAULT_TIMEOUT
177 } else {
178 self.timeout
179 };
180
181 let mut cmd = Command::new(&self.bin);
182 cmd.arg("-p")
183 .args(["--output-format", "json"])
184 .args(["--input-format", "text"])
185 .args(["--system-prompt", system_prompt])
186 .arg("--exclude-dynamic-system-prompt-sections")
187 .arg("--strict-mcp-config")
188 .args(["--tools", ""])
195 .args(["--max-turns", "1"])
196 .arg("--no-session-persistence")
200 .current_dir(std::env::temp_dir())
205 .stdin(Stdio::piped())
206 .stdout(Stdio::piped())
207 .stderr(Stdio::piped())
208 .kill_on_drop(true);
211
212 let mut child = cmd.spawn().map_err(|e| match e.kind() {
213 std::io::ErrorKind::NotFound => {
214 Error::Unavailable(format!("{} not found on PATH", self.bin))
215 }
216 _ => Error::Failed(e.to_string()),
217 })?;
218
219 let mut stdin = child.stdin.take().expect("stdin was piped");
220 let body = body.to_string();
221 let run = async {
224 let write = async move {
225 stdin.write_all(body.as_bytes()).await?;
226 stdin.shutdown().await
227 };
228 let (written, out) = tokio::join!(write, child.wait_with_output());
229 if let Err(e) = written {
233 if e.kind() != std::io::ErrorKind::BrokenPipe {
234 return Err(e);
235 }
236 }
237 out.map(|o| (o.status, o.stdout, o.stderr))
238 };
239
240 let (status, stdout, stderr) = match timeout(limit, run).await {
241 Err(_) => return Err(Error::TimedOut(limit)),
242 Ok(Err(e)) => return Err(Error::Failed(e.to_string())),
243 Ok(Ok(v)) => v,
244 };
245
246 if !status.success() {
247 let detail = String::from_utf8_lossy(&stderr).trim().to_string();
248 let detail = if detail.is_empty() {
249 "(no stderr)".to_string()
250 } else {
251 detail
252 };
253 return Err(Error::Failed(format!(
254 "exit {}: {}",
255 status
256 .code()
257 .map_or_else(|| "signal".into(), |c| c.to_string()),
258 truncate(&detail, DETAIL_LIMIT)
259 )));
260 }
261
262 let out = String::from_utf8_lossy(&stdout);
263 let parsed: CliResult = serde_json::from_str(&out)
264 .map_err(|_| Error::NonJson(truncate(&out, DETAIL_LIMIT).to_string()))?;
265 if parsed.is_error {
266 return Err(Error::Rejected(
267 truncate(&parsed.result, DETAIL_LIMIT).to_string(),
268 ));
269 }
270 Ok(parsed.result)
271 }
272
273 pub async fn check_status(&self) -> Status {
276 let checked_at = now();
277 let run = Command::new(&self.bin)
278 .args(["auth", "status"])
279 .stdin(Stdio::null())
280 .kill_on_drop(true)
281 .output();
282
283 let out = match timeout(STATUS_TIMEOUT, run).await {
284 Err(_) => {
285 return Status {
286 checked_at,
287 error: format!("`{} auth status` timed out", self.bin),
288 ..Status::default()
289 }
290 }
291 Ok(Err(e)) => {
292 let error = if e.kind() == std::io::ErrorKind::NotFound {
293 "claude CLI not found on PATH".to_string()
294 } else {
295 e.to_string()
296 };
297 return Status {
298 checked_at,
299 error,
300 ..Status::default()
301 };
302 }
303 Ok(Ok(out)) => out,
304 };
305
306 if !out.status.success() {
307 let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
308 return Status {
309 checked_at,
310 available: true,
311 logged_in: false,
312 error: if detail.is_empty() {
313 "`claude auth status` failed".to_string()
314 } else {
315 truncate(&detail, DETAIL_LIMIT).to_string()
316 },
317 };
318 }
319
320 match serde_json::from_slice::<AuthStatus>(&out.stdout) {
321 Ok(a) => Status {
322 checked_at,
323 available: true,
324 logged_in: a.logged_in,
325 error: String::new(),
326 },
327 Err(_) => Status {
328 checked_at,
329 available: true,
330 logged_in: false,
331 error: "could not parse `claude auth status` output".to_string(),
332 },
333 }
334 }
335}
336
337fn truncate(s: &str, n: usize) -> &str {
338 if s.len() <= n {
339 return s;
340 }
341 let mut end = n;
342 while end > 0 && !s.is_char_boundary(end) {
343 end -= 1;
344 }
345 &s[..end]
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn prompt_labels_both_versions() {
354 let p = prompt("old", "new");
355 assert!(p.contains("--- VERSION A (currently stored) ---\nold"));
356 assert!(p.contains("--- VERSION B (incoming) ---\nnew"));
357 }
358
359 #[test]
361 fn the_system_prompt_is_verbatim() {
362 assert!(SYSTEM_PROMPT.starts_with("You are a precise text-merging assistant"));
363 assert!(SYSTEM_PROMPT.ends_with("no code fences, nothing else."));
364 }
365
366 #[tokio::test]
367 async fn a_missing_binary_is_unavailable_not_a_panic() {
368 let m = Merger::new("definitely-not-a-real-binary", Duration::from_secs(1));
369 let st = m.check_status().await;
370 assert!(!st.available);
371 assert!(!st.logged_in);
372 assert!(!st.error.is_empty());
373 assert!(!st.checked_at.is_empty());
374
375 let err = m.merge("a", "b").await.unwrap_err();
376 assert!(matches!(err, Error::Unavailable(_)), "got {err:?}");
377 }
378
379 #[tokio::test]
382 async fn parses_the_json_envelope_and_honors_is_error() {
383 let (_ok_dir, bin) = fake_claude(r#"{"is_error":false,"result":"merged!"}"#);
384 assert_eq!(
385 Merger::new(bin, Duration::from_secs(10))
386 .merge("a", "b")
387 .await
388 .unwrap(),
389 "merged!"
390 );
391
392 let (_bad_dir, bin) = fake_claude(r#"{"is_error":true,"result":"nope"}"#);
393 let err = Merger::new(bin, Duration::from_secs(10))
394 .merge("a", "b")
395 .await;
396 assert!(matches!(err, Err(Error::Rejected(_))), "got {err:?}");
397
398 let (_junk_dir, bin) = fake_claude("not json");
399 let err = Merger::new(bin, Duration::from_secs(10))
400 .merge("a", "b")
401 .await;
402 assert!(matches!(err, Err(Error::NonJson(_))), "got {err:?}");
403 }
404
405 #[tokio::test]
409 async fn the_merge_runs_with_no_tools_and_one_turn() {
410 use std::io::Write;
411 use std::os::unix::fs::PermissionsExt;
412
413 let dir = tempfile::tempdir().unwrap();
414 let args = dir.path().join("args");
415 let path = dir.path().join("claude");
416 let mut f = std::fs::File::create(&path).unwrap();
417 writeln!(f, "#!/bin/sh").unwrap();
418 writeln!(
419 f,
420 "for a in \"$@\"; do printf '%s\\n' \"$a\"; done > '{}'",
421 args.display()
422 )
423 .unwrap();
424 writeln!(f, "cat > /dev/null").unwrap();
425 writeln!(f, r#"printf '%s' '{{"is_error":false,"result":"merged"}}'"#).unwrap();
426 drop(f);
427 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
428 settle(&path);
429
430 Merger::new(path.to_str().unwrap(), Duration::from_secs(10))
431 .merge("old notes", "new notes")
432 .await
433 .unwrap();
434 let passed = std::fs::read_to_string(&args).unwrap();
435 let passed: Vec<&str> = passed.lines().collect();
436 let after = |flag: &str| {
437 passed
438 .iter()
439 .position(|a| *a == flag)
440 .and_then(|i| passed.get(i + 1).copied())
441 };
442 assert_eq!(after("--tools"), Some(""), "{passed:?}");
443 assert_eq!(after("--max-turns"), Some("1"), "{passed:?}");
444 assert_eq!(after("--output-format"), Some("json"), "{passed:?}");
445 for flag in [
446 "-p",
447 "--no-session-persistence",
448 "--strict-mcp-config",
449 "--exclude-dynamic-system-prompt-sections",
450 ] {
451 assert!(passed.contains(&flag), "{flag} missing from {passed:?}");
452 }
453 assert!(
454 !passed.iter().any(|a| a.contains("old notes")),
455 "the notes go on stdin, not in the arguments"
456 );
457 }
458
459 #[tokio::test]
460 async fn reads_logged_in_from_auth_status() {
461 let (_dir, bin) = fake_claude(r#"{"loggedIn":true}"#);
462 let st = Merger::new(bin, Duration::from_secs(10))
463 .check_status()
464 .await;
465 assert!(st.available && st.logged_in, "got {st:?}");
466 }
467
468 fn fake_claude(out: &str) -> (tempfile::TempDir, String) {
472 use std::io::Write;
473 use std::os::unix::fs::PermissionsExt;
474
475 let dir = tempfile::tempdir().unwrap();
476 let path = dir.path().join("claude");
477 let mut f = std::fs::File::create(&path).unwrap();
478 writeln!(f, "#!/bin/sh").unwrap();
479 writeln!(f, "cat > /dev/null").unwrap();
480 writeln!(f, "printf '%s' '{out}'").unwrap();
481 drop(f);
482 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
483 settle(&path);
484 let bin = path.to_str().unwrap().to_string();
485 (dir, bin)
486 }
487
488 fn settle(path: &std::path::Path) {
503 use std::process::Stdio;
504
505 for _ in 0..100 {
506 match std::process::Command::new(path)
507 .stdin(Stdio::null())
508 .stdout(Stdio::null())
509 .stderr(Stdio::null())
510 .status()
511 {
512 Ok(_) => return,
515 Err(e) if e.raw_os_error() == Some(libc_etxtbsy()) => {
516 std::thread::sleep(Duration::from_millis(5));
517 }
518 Err(_) => return,
520 }
521 }
522 panic!("{} stayed ETXTBSY for half a second", path.display());
523 }
524
525 fn libc_etxtbsy() -> i32 {
527 26
528 }
529}