use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncReport {
pub synced: Vec<String>,
pub skipped: Vec<String>,
pub errors: Vec<String>,
}
pub(crate) fn sync_all(home: &Path) -> SyncReport {
let inject_result = crate::rules_inject::inject_all_rules(home);
let mut synced = Vec::new();
synced.extend(inject_result.injected.iter().cloned());
synced.extend(inject_result.updated.iter().cloned());
SyncReport {
synced,
skipped: inject_result.already,
errors: inject_result.errors,
}
}
pub(crate) fn sync_agent(home: &Path, agent: &str) -> SyncReport {
let inject_result = crate::rules_inject::inject_rules_for_agent(home, agent);
let mut synced = Vec::new();
synced.extend(inject_result.injected.iter().cloned());
synced.extend(inject_result.updated.iter().cloned());
SyncReport {
synced,
skipped: inject_result.already,
errors: inject_result.errors,
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TempHome {
path: std::path::PathBuf,
}
impl TempHome {
fn new(tag: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let path = std::env::temp_dir()
.join(format!("leanctx_sync_{tag}_{}_{nanos}", std::process::id()));
Self { path }
}
}
impl Drop for TempHome {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn scope_claude_into(home: &std::path::Path) -> crate::test_env::EnvVarGuard {
let claude_dir = home.join(".claude").to_string_lossy().into_owned();
crate::test_env::EnvVarGuard::set("CLAUDE_CONFIG_DIR", &claude_dir)
}
#[test]
#[serial_test::serial(claude_config_dir)]
fn sync_all_is_idempotent_and_error_free() {
let home = TempHome::new("all");
let _claude = scope_claude_into(&home.path);
let first = sync_all(&home.path);
assert!(
first.errors.is_empty(),
"first sync reported errors: {:?}",
first.errors
);
let second = sync_all(&home.path);
assert!(
second.synced.is_empty(),
"second sync re-injected rules (not idempotent): {:?}",
second.synced
);
assert!(
second.errors.is_empty(),
"second sync reported errors: {:?}",
second.errors
);
}
#[test]
#[serial_test::serial(claude_config_dir)]
fn sync_then_diff_reports_no_drift() {
use crate::core::contextops::drift::{DriftStatus, detect_drift};
let home = TempHome::new("syncdiff");
let _claude = scope_claude_into(&home.path);
let report = sync_all(&home.path);
assert!(
report.errors.is_empty(),
"sync reported errors: {:?}",
report.errors
);
let drifted: Vec<String> = detect_drift(&home.path)
.into_iter()
.filter(|r| r.status == DriftStatus::Drifted)
.map(|r| r.target)
.collect();
assert!(
drifted.is_empty(),
"sync left targets drifted vs the canonical source: {drifted:?}"
);
}
#[test]
#[serial_test::serial(claude_config_dir)]
fn sync_all_repairs_claude_pointer_and_is_idempotent() {
let _lock = crate::core::data_dir::test_env_lock();
let home = TempHome::new("claude_all");
let _claude = scope_claude_into(&home.path);
let _rules = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let _scope = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
let claude_dir = home.path.join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join(".claude.json"),
r#"{"mcpServers":{"lean-ctx":{"command":"lean-ctx"}}}"#,
)
.unwrap();
let md = claude_dir.join("CLAUDE.md");
std::fs::write(&md, "# user notes\n").unwrap();
let first = sync_all(&home.path);
assert!(first.errors.is_empty(), "{:?}", first.errors);
assert!(first.synced.iter().any(|name| name == "Claude Code"));
let content = std::fs::read_to_string(&md).unwrap();
assert!(content.contains("# user notes"));
assert_eq!(content.matches("<!-- lean-ctx -->").count(), 1);
assert!(!content.contains("<!-- lean-ctx-rules -->"));
let second = sync_all(&home.path);
assert!(second.errors.is_empty(), "{:?}", second.errors);
assert!(second.skipped.iter().any(|name| name == "Claude Code"));
let content = std::fs::read_to_string(&md).unwrap();
assert_eq!(content.matches("<!-- lean-ctx -->").count(), 1);
}
#[test]
#[serial_test::serial(claude_config_dir)]
fn sync_agent_supports_claude_aliases_but_not_codex() {
let _lock = crate::core::data_dir::test_env_lock();
let home = TempHome::new("claude_agent");
let _claude = scope_claude_into(&home.path);
let _rules = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let _scope = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
let md = home.path.join(".claude/CLAUDE.md");
let legacy = home.path.join(".claude/rules/lean-ctx.md");
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
std::fs::write(&legacy, "<!-- lean-ctx-rules -->\nlegacy\n").unwrap();
for non_alias in ["codex", "claudex", "unknown"] {
let report = sync_agent(&home.path, non_alias);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert!(
!md.exists(),
"rules sync {non_alias} must not touch CLAUDE.md"
);
}
for alias in ["claude", "claude-code", "claude_code", "claude code"] {
let report = sync_agent(&home.path, alias);
assert!(report.errors.is_empty(), "{:?}", report.errors);
}
let content = std::fs::read_to_string(&md).unwrap();
assert_eq!(content.matches("<!-- lean-ctx -->").count(), 1);
assert!(!content.contains("<!-- lean-ctx-rules -->"));
assert!(
!legacy.exists(),
"lean-ctx-owned legacy rules must be removed"
);
std::fs::write(&legacy, "<!-- lean-ctx-rules -->\nlegacy\n").unwrap();
let report = sync_agent(&home.path, "claude");
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert!(report.synced.iter().any(|name| name == "Claude Code"));
assert!(
!legacy.exists(),
"legacy-only repair must be reported as a sync"
);
std::fs::write(&legacy, "# user-owned rules\n").unwrap();
let report = sync_agent(&home.path, "claude");
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(
std::fs::read_to_string(&legacy).unwrap(),
"# user-owned rules\n",
"unmarked user rules must be preserved"
);
}
#[test]
#[serial_test::serial(claude_config_dir)]
fn sync_agent_claude_respects_rules_policy() {
let _lock = crate::core::data_dir::test_env_lock();
for (scope, injection) in [
("project", "shared"),
("global", "off"),
("global", "dedicated"),
] {
let home = TempHome::new(&format!("claude_policy_{scope}_{injection}"));
let _claude = scope_claude_into(&home.path);
let _scope = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", scope);
let _rules = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", injection);
let report = sync_agent(&home.path, "claude");
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert!(report.synced.is_empty(), "{:?}", report.synced);
assert!(
!home.path.join(".claude/CLAUDE.md").exists(),
"{scope}/{injection} must not create a shared Claude pointer"
);
}
}
#[test]
#[serial_test::serial(claude_config_dir)]
fn sync_agent_unknown_is_a_noop() {
let home = TempHome::new("agent");
let _claude = scope_claude_into(&home.path);
let report = sync_agent(&home.path, "unknown_xyz");
assert!(
report.synced.is_empty(),
"unknown agent injected rules: {:?}",
report.synced
);
assert!(
report.errors.is_empty(),
"unknown agent reported errors: {:?}",
report.errors
);
}
}