1use std::process::Stdio;
12use std::time::Duration;
13
14use serde::Deserialize;
15use tokio::io::AsyncWriteExt;
16use tokio::process::Command;
17use tokio::time::timeout;
18
19use crate::now;
20
21pub const SYSTEM_PROMPT: &str = concat!(
30 "You are a precise text-merging assistant for a personal notes-sync tool. ",
31 "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. ",
32 "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); ",
33 "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. ",
34 "Output ONLY the merged file content — no preamble, no explanation, no code fences, nothing else."
35);
36
37const DEFAULT_TIMEOUT: Duration = Duration::from_secs(45);
38const STATUS_TIMEOUT: Duration = Duration::from_secs(15);
39
40const DETAIL_LIMIT: usize = 500;
42
43#[derive(Debug, thiserror::Error)]
46pub enum Error {
47 #[error("claude CLI unavailable: {0}")]
49 Unavailable(String),
50 #[error("claude merge timed out after {0:?}")]
52 TimedOut(Duration),
53 #[error("claude failed: {0}")]
55 Failed(String),
56 #[error("claude returned non-JSON output: {0}")]
58 NonJson(String),
59 #[error("claude merge failed: {0}")]
61 Rejected(String),
62 #[error("claude returned an empty merge of two non-empty versions")]
69 EmptyResult,
70}
71
72#[derive(Debug, Clone)]
74pub struct Merger {
75 pub bin: String,
77 pub timeout: Duration,
79}
80
81impl Default for Merger {
82 fn default() -> Self {
83 Self {
84 bin: "claude".to_string(),
85 timeout: DEFAULT_TIMEOUT,
86 }
87 }
88}
89
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct Status {
95 pub checked_at: String,
97 pub available: bool,
99 pub logged_in: bool,
101 pub error: String,
103}
104
105#[derive(Debug, Deserialize)]
107struct CliResult {
108 #[serde(default)]
109 is_error: bool,
110 #[serde(default)]
111 result: String,
112}
113
114#[derive(Debug, Deserialize)]
116struct AuthStatus {
117 #[serde(default, rename = "loggedIn")]
118 logged_in: bool,
119}
120
121pub fn prompt(old_content: &str, new_content: &str) -> String {
123 format!("--- VERSION A (currently stored) ---\n{old_content}\n\n--- VERSION B (incoming) ---\n{new_content}")
124}
125
126impl Merger {
127 pub fn new(bin: impl Into<String>, timeout: Duration) -> Self {
129 Self {
130 bin: bin.into(),
131 timeout: if timeout.is_zero() {
132 DEFAULT_TIMEOUT
133 } else {
134 timeout
135 },
136 }
137 }
138
139 pub async fn merge(&self, old_content: &str, new_content: &str) -> Result<String, Error> {
141 let limit = if self.timeout.is_zero() {
142 DEFAULT_TIMEOUT
143 } else {
144 self.timeout
145 };
146
147 let mut cmd = Command::new(&self.bin);
148 cmd.arg("-p")
149 .args(["--output-format", "json"])
150 .args(["--input-format", "text"])
151 .args(["--system-prompt", SYSTEM_PROMPT])
152 .arg("--exclude-dynamic-system-prompt-sections")
153 .arg("--strict-mcp-config")
154 .current_dir(std::env::temp_dir())
159 .stdin(Stdio::piped())
160 .stdout(Stdio::piped())
161 .stderr(Stdio::piped())
162 .kill_on_drop(true);
165
166 let mut child = cmd.spawn().map_err(|e| match e.kind() {
167 std::io::ErrorKind::NotFound => {
168 Error::Unavailable(format!("{} not found on PATH", self.bin))
169 }
170 _ => Error::Failed(e.to_string()),
171 })?;
172
173 let mut stdin = child.stdin.take().expect("stdin was piped");
174 let body = prompt(old_content, new_content);
175 let run = async {
178 let write = async move {
179 stdin.write_all(body.as_bytes()).await?;
180 stdin.shutdown().await
181 };
182 let (written, out) = tokio::join!(write, child.wait_with_output());
183 if let Err(e) = written {
187 if e.kind() != std::io::ErrorKind::BrokenPipe {
188 return Err(e);
189 }
190 }
191 out.map(|o| (o.status, o.stdout, o.stderr))
192 };
193
194 let (status, stdout, stderr) = match timeout(limit, run).await {
195 Err(_) => return Err(Error::TimedOut(limit)),
196 Ok(Err(e)) => return Err(Error::Failed(e.to_string())),
197 Ok(Ok(v)) => v,
198 };
199
200 if !status.success() {
201 let detail = String::from_utf8_lossy(&stderr).trim().to_string();
202 let detail = if detail.is_empty() {
203 "(no stderr)".to_string()
204 } else {
205 detail
206 };
207 return Err(Error::Failed(format!(
208 "exit {}: {}",
209 status
210 .code()
211 .map_or_else(|| "signal".into(), |c| c.to_string()),
212 truncate(&detail, DETAIL_LIMIT)
213 )));
214 }
215
216 let out = String::from_utf8_lossy(&stdout);
217 let parsed: CliResult = serde_json::from_str(&out)
218 .map_err(|_| Error::NonJson(truncate(&out, DETAIL_LIMIT).to_string()))?;
219 if parsed.is_error {
220 return Err(Error::Rejected(
221 truncate(&parsed.result, DETAIL_LIMIT).to_string(),
222 ));
223 }
224
225 if parsed.result.trim().is_empty()
235 && !(old_content.trim().is_empty() && new_content.trim().is_empty())
236 {
237 return Err(Error::EmptyResult);
238 }
239 Ok(parsed.result)
240 }
241
242 pub async fn check_status(&self) -> Status {
245 let checked_at = now();
246 let run = Command::new(&self.bin)
247 .args(["auth", "status"])
248 .stdin(Stdio::null())
249 .kill_on_drop(true)
250 .output();
251
252 let out = match timeout(STATUS_TIMEOUT, run).await {
253 Err(_) => {
254 return Status {
255 checked_at,
256 error: format!("`{} auth status` timed out", self.bin),
257 ..Status::default()
258 }
259 }
260 Ok(Err(e)) => {
261 let error = if e.kind() == std::io::ErrorKind::NotFound {
262 "claude CLI not found on PATH".to_string()
263 } else {
264 e.to_string()
265 };
266 return Status {
267 checked_at,
268 error,
269 ..Status::default()
270 };
271 }
272 Ok(Ok(out)) => out,
273 };
274
275 if !out.status.success() {
276 let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
277 return Status {
278 checked_at,
279 available: true,
280 logged_in: false,
281 error: if detail.is_empty() {
282 "`claude auth status` failed".to_string()
283 } else {
284 truncate(&detail, DETAIL_LIMIT).to_string()
285 },
286 };
287 }
288
289 match serde_json::from_slice::<AuthStatus>(&out.stdout) {
290 Ok(a) => Status {
291 checked_at,
292 available: true,
293 logged_in: a.logged_in,
294 error: String::new(),
295 },
296 Err(_) => Status {
297 checked_at,
298 available: true,
299 logged_in: false,
300 error: "could not parse `claude auth status` output".to_string(),
301 },
302 }
303 }
304}
305
306fn truncate(s: &str, n: usize) -> &str {
307 if s.len() <= n {
308 return s;
309 }
310 let mut end = n;
311 while end > 0 && !s.is_char_boundary(end) {
312 end -= 1;
313 }
314 &s[..end]
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn prompt_labels_both_versions() {
323 let p = prompt("old", "new");
324 assert!(p.contains("--- VERSION A (currently stored) ---\nold"));
325 assert!(p.contains("--- VERSION B (incoming) ---\nnew"));
326 }
327
328 #[test]
330 fn the_system_prompt_is_verbatim() {
331 assert!(SYSTEM_PROMPT.starts_with("You are a precise text-merging assistant"));
332 assert!(SYSTEM_PROMPT.ends_with("no code fences, nothing else."));
333 }
334
335 #[tokio::test]
336 async fn a_missing_binary_is_unavailable_not_a_panic() {
337 let m = Merger::new("definitely-not-a-real-binary", Duration::from_secs(1));
338 let st = m.check_status().await;
339 assert!(!st.available);
340 assert!(!st.logged_in);
341 assert!(!st.error.is_empty());
342 assert!(!st.checked_at.is_empty());
343
344 let err = m.merge("a", "b").await.unwrap_err();
345 assert!(matches!(err, Error::Unavailable(_)), "got {err:?}");
346 }
347
348 #[tokio::test]
351 async fn parses_the_json_envelope_and_honors_is_error() {
352 let (_ok_dir, bin) = fake_claude(r#"{"is_error":false,"result":"merged!"}"#);
353 assert_eq!(
354 Merger::new(bin, Duration::from_secs(10))
355 .merge("a", "b")
356 .await
357 .unwrap(),
358 "merged!"
359 );
360
361 let (_bad_dir, bin) = fake_claude(r#"{"is_error":true,"result":"nope"}"#);
362 let err = Merger::new(bin, Duration::from_secs(10))
363 .merge("a", "b")
364 .await;
365 assert!(matches!(err, Err(Error::Rejected(_))), "got {err:?}");
366
367 let (_junk_dir, bin) = fake_claude("not json");
368 let err = Merger::new(bin, Duration::from_secs(10))
369 .merge("a", "b")
370 .await;
371 assert!(matches!(err, Err(Error::NonJson(_))), "got {err:?}");
372 }
373
374 #[tokio::test]
375 async fn reads_logged_in_from_auth_status() {
376 let (_dir, bin) = fake_claude(r#"{"loggedIn":true}"#);
377 let st = Merger::new(bin, Duration::from_secs(10))
378 .check_status()
379 .await;
380 assert!(st.available && st.logged_in, "got {st:?}");
381 }
382
383 fn fake_claude(out: &str) -> (tempfile::TempDir, String) {
387 use std::io::Write;
388 use std::os::unix::fs::PermissionsExt;
389
390 let dir = tempfile::tempdir().unwrap();
391 let path = dir.path().join("claude");
392 let mut f = std::fs::File::create(&path).unwrap();
393 writeln!(f, "#!/bin/sh").unwrap();
394 writeln!(f, "cat > /dev/null").unwrap();
395 writeln!(f, "printf '%s' '{out}'").unwrap();
396 drop(f);
397 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
398 settle(&path);
399 let bin = path.to_str().unwrap().to_string();
400 (dir, bin)
401 }
402
403 fn settle(path: &std::path::Path) {
418 use std::process::Stdio;
419
420 for _ in 0..100 {
421 match std::process::Command::new(path)
422 .stdin(Stdio::null())
423 .stdout(Stdio::null())
424 .stderr(Stdio::null())
425 .status()
426 {
427 Ok(_) => return,
430 Err(e) if e.raw_os_error() == Some(libc_etxtbsy()) => {
431 std::thread::sleep(Duration::from_millis(5));
432 }
433 Err(_) => return,
435 }
436 }
437 panic!("{} stayed ETXTBSY for half a second", path.display());
438 }
439
440 fn libc_etxtbsy() -> i32 {
442 26
443 }
444}