use std::process::Stdio;
use std::time::Duration;
use serde::Deserialize;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::timeout;
use crate::now;
pub const SYSTEM_PROMPT: &str = concat!(
"You are a precise text-merging assistant for a personal notes-sync tool. ",
"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. ",
"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); ",
"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. ",
"Output ONLY the merged file content — no preamble, no explanation, no code fences, nothing else."
);
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(45);
const STATUS_TIMEOUT: Duration = Duration::from_secs(15);
const DETAIL_LIMIT: usize = 500;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("claude CLI unavailable: {0}")]
Unavailable(String),
#[error("claude merge timed out after {0:?}")]
TimedOut(Duration),
#[error("claude failed: {0}")]
Failed(String),
#[error("claude returned non-JSON output: {0}")]
NonJson(String),
#[error("claude merge failed: {0}")]
Rejected(String),
#[error("claude returned an empty merge of two non-empty versions")]
EmptyResult,
}
#[derive(Debug, Clone)]
pub struct Merger {
pub bin: String,
pub timeout: Duration,
}
impl Default for Merger {
fn default() -> Self {
Self {
bin: "claude".to_string(),
timeout: DEFAULT_TIMEOUT,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Status {
pub checked_at: String,
pub available: bool,
pub logged_in: bool,
pub error: String,
}
#[derive(Debug, Deserialize)]
struct CliResult {
#[serde(default)]
is_error: bool,
#[serde(default)]
result: String,
}
#[derive(Debug, Deserialize)]
struct AuthStatus {
#[serde(default, rename = "loggedIn")]
logged_in: bool,
}
pub fn prompt(old_content: &str, new_content: &str) -> String {
format!("--- VERSION A (currently stored) ---\n{old_content}\n\n--- VERSION B (incoming) ---\n{new_content}")
}
impl Merger {
pub fn new(bin: impl Into<String>, timeout: Duration) -> Self {
Self {
bin: bin.into(),
timeout: if timeout.is_zero() {
DEFAULT_TIMEOUT
} else {
timeout
},
}
}
pub async fn merge(&self, old_content: &str, new_content: &str) -> Result<String, Error> {
let limit = if self.timeout.is_zero() {
DEFAULT_TIMEOUT
} else {
self.timeout
};
let mut cmd = Command::new(&self.bin);
cmd.arg("-p")
.args(["--output-format", "json"])
.args(["--input-format", "text"])
.args(["--system-prompt", SYSTEM_PROMPT])
.arg("--exclude-dynamic-system-prompt-sections")
.arg("--strict-mcp-config")
.current_dir(std::env::temp_dir())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => {
Error::Unavailable(format!("{} not found on PATH", self.bin))
}
_ => Error::Failed(e.to_string()),
})?;
let mut stdin = child.stdin.take().expect("stdin was piped");
let body = prompt(old_content, new_content);
let run = async {
let write = async move {
stdin.write_all(body.as_bytes()).await?;
stdin.shutdown().await
};
let (written, out) = tokio::join!(write, child.wait_with_output());
if let Err(e) = written {
if e.kind() != std::io::ErrorKind::BrokenPipe {
return Err(e);
}
}
out.map(|o| (o.status, o.stdout, o.stderr))
};
let (status, stdout, stderr) = match timeout(limit, run).await {
Err(_) => return Err(Error::TimedOut(limit)),
Ok(Err(e)) => return Err(Error::Failed(e.to_string())),
Ok(Ok(v)) => v,
};
if !status.success() {
let detail = String::from_utf8_lossy(&stderr).trim().to_string();
let detail = if detail.is_empty() {
"(no stderr)".to_string()
} else {
detail
};
return Err(Error::Failed(format!(
"exit {}: {}",
status
.code()
.map_or_else(|| "signal".into(), |c| c.to_string()),
truncate(&detail, DETAIL_LIMIT)
)));
}
let out = String::from_utf8_lossy(&stdout);
let parsed: CliResult = serde_json::from_str(&out)
.map_err(|_| Error::NonJson(truncate(&out, DETAIL_LIMIT).to_string()))?;
if parsed.is_error {
return Err(Error::Rejected(
truncate(&parsed.result, DETAIL_LIMIT).to_string(),
));
}
if parsed.result.trim().is_empty()
&& !(old_content.trim().is_empty() && new_content.trim().is_empty())
{
return Err(Error::EmptyResult);
}
Ok(parsed.result)
}
pub async fn check_status(&self) -> Status {
let checked_at = now();
let run = Command::new(&self.bin)
.args(["auth", "status"])
.stdin(Stdio::null())
.kill_on_drop(true)
.output();
let out = match timeout(STATUS_TIMEOUT, run).await {
Err(_) => {
return Status {
checked_at,
error: format!("`{} auth status` timed out", self.bin),
..Status::default()
}
}
Ok(Err(e)) => {
let error = if e.kind() == std::io::ErrorKind::NotFound {
"claude CLI not found on PATH".to_string()
} else {
e.to_string()
};
return Status {
checked_at,
error,
..Status::default()
};
}
Ok(Ok(out)) => out,
};
if !out.status.success() {
let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Status {
checked_at,
available: true,
logged_in: false,
error: if detail.is_empty() {
"`claude auth status` failed".to_string()
} else {
truncate(&detail, DETAIL_LIMIT).to_string()
},
};
}
match serde_json::from_slice::<AuthStatus>(&out.stdout) {
Ok(a) => Status {
checked_at,
available: true,
logged_in: a.logged_in,
error: String::new(),
},
Err(_) => Status {
checked_at,
available: true,
logged_in: false,
error: "could not parse `claude auth status` output".to_string(),
},
}
}
}
fn truncate(s: &str, n: usize) -> &str {
if s.len() <= n {
return s;
}
let mut end = n;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prompt_labels_both_versions() {
let p = prompt("old", "new");
assert!(p.contains("--- VERSION A (currently stored) ---\nold"));
assert!(p.contains("--- VERSION B (incoming) ---\nnew"));
}
#[test]
fn the_system_prompt_is_verbatim() {
assert!(SYSTEM_PROMPT.starts_with("You are a precise text-merging assistant"));
assert!(SYSTEM_PROMPT.ends_with("no code fences, nothing else."));
}
#[tokio::test]
async fn a_missing_binary_is_unavailable_not_a_panic() {
let m = Merger::new("definitely-not-a-real-binary", Duration::from_secs(1));
let st = m.check_status().await;
assert!(!st.available);
assert!(!st.logged_in);
assert!(!st.error.is_empty());
assert!(!st.checked_at.is_empty());
let err = m.merge("a", "b").await.unwrap_err();
assert!(matches!(err, Error::Unavailable(_)), "got {err:?}");
}
#[tokio::test]
async fn parses_the_json_envelope_and_honors_is_error() {
let (_ok_dir, bin) = fake_claude(r#"{"is_error":false,"result":"merged!"}"#);
assert_eq!(
Merger::new(bin, Duration::from_secs(10))
.merge("a", "b")
.await
.unwrap(),
"merged!"
);
let (_bad_dir, bin) = fake_claude(r#"{"is_error":true,"result":"nope"}"#);
let err = Merger::new(bin, Duration::from_secs(10))
.merge("a", "b")
.await;
assert!(matches!(err, Err(Error::Rejected(_))), "got {err:?}");
let (_junk_dir, bin) = fake_claude("not json");
let err = Merger::new(bin, Duration::from_secs(10))
.merge("a", "b")
.await;
assert!(matches!(err, Err(Error::NonJson(_))), "got {err:?}");
}
#[tokio::test]
async fn reads_logged_in_from_auth_status() {
let (_dir, bin) = fake_claude(r#"{"loggedIn":true}"#);
let st = Merger::new(bin, Duration::from_secs(10))
.check_status()
.await;
assert!(st.available && st.logged_in, "got {st:?}");
}
fn fake_claude(out: &str) -> (tempfile::TempDir, String) {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("claude");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "#!/bin/sh").unwrap();
writeln!(f, "cat > /dev/null").unwrap();
writeln!(f, "printf '%s' '{out}'").unwrap();
drop(f);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
settle(&path);
let bin = path.to_str().unwrap().to_string();
(dir, bin)
}
fn settle(path: &std::path::Path) {
use std::process::Stdio;
for _ in 0..100 {
match std::process::Command::new(path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
{
Ok(_) => return,
Err(e) if e.raw_os_error() == Some(libc_etxtbsy()) => {
std::thread::sleep(Duration::from_millis(5));
}
Err(_) => return,
}
}
panic!("{} stayed ETXTBSY for half a second", path.display());
}
fn libc_etxtbsy() -> i32 {
26
}
}