Skip to main content

lean_ctx/core/contextops/
sync.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct SyncReport {
7    pub synced: Vec<String>,
8    pub skipped: Vec<String>,
9    pub errors: Vec<String>,
10}
11
12pub fn sync_all(home: &Path) -> SyncReport {
13    let inject_result = crate::rules_inject::inject_all_rules(home);
14
15    let mut synced = Vec::new();
16    synced.extend(inject_result.injected.iter().cloned());
17    synced.extend(inject_result.updated.iter().cloned());
18
19    SyncReport {
20        synced,
21        skipped: inject_result.already,
22        errors: inject_result.errors,
23    }
24}
25
26pub fn sync_agent(home: &Path, agent: &str) -> SyncReport {
27    let inject_result = crate::rules_inject::inject_rules_for_agent(home, agent);
28
29    let mut synced = Vec::new();
30    synced.extend(inject_result.injected.iter().cloned());
31    synced.extend(inject_result.updated.iter().cloned());
32
33    SyncReport {
34        synced,
35        skipped: inject_result.already,
36        errors: inject_result.errors,
37    }
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    /// A unique temp home that is removed on drop, so a test leaves no trace even
45    /// if an assertion panics. Each instance uses a fresh path (pid + nanos) so a
46    /// run can never be polluted by leftovers from a previous run — the old tests
47    /// reused a fixed `/tmp` path and broke the moment any agent directory was
48    /// created under it.
49    struct TempHome {
50        path: std::path::PathBuf,
51    }
52
53    impl TempHome {
54        fn new(tag: &str) -> Self {
55            let nanos = std::time::SystemTime::now()
56                .duration_since(std::time::UNIX_EPOCH)
57                .map_or(0, |d| d.as_nanos());
58            let path = std::env::temp_dir()
59                .join(format!("leanctx_sync_{tag}_{}_{nanos}", std::process::id()));
60            Self { path }
61        }
62    }
63
64    impl Drop for TempHome {
65        fn drop(&mut self) {
66            let _ = std::fs::remove_dir_all(&self.path);
67        }
68    }
69
70    /// Scope Claude's config dir into the sandbox so a test can never write into
71    /// the developer's real `~/.claude` — `CLAUDE_CONFIG_DIR` is the only agent
72    /// detection path that escapes `home`.
73    fn scope_claude_into(home: &std::path::Path) -> crate::setup::EnvVarGuard {
74        let claude_dir = home.join(".claude").to_string_lossy().into_owned();
75        crate::setup::EnvVarGuard::set("CLAUDE_CONFIG_DIR", &claude_dir)
76    }
77
78    // `sync_all` is idempotent and side-effect-scoped: a second sync over the same
79    // home injects nothing new and never errors. We assert *idempotency* rather
80    // than "nothing is ever synced", because agents such as Codex/Pi are detected
81    // via `$PATH` (`which`), so what gets injected on the first pass is host-
82    // dependent — but re-running must always be a clean no-op.
83    // Serialized against every other test that reads or writes the global
84    // `CLAUDE_CONFIG_DIR` (e.g. doctor `claude_instructions_check`): this test
85    // sets it process-wide via `scope_claude_into`, so a concurrent reader
86    // would otherwise resolve Claude's state dir to *this* sandbox (#401 CI).
87    #[test]
88    #[serial_test::serial(claude_config_dir)]
89    fn sync_all_is_idempotent_and_error_free() {
90        let home = TempHome::new("all");
91        let _claude = scope_claude_into(&home.path);
92
93        let first = sync_all(&home.path);
94        assert!(
95            first.errors.is_empty(),
96            "first sync reported errors: {:?}",
97            first.errors
98        );
99
100        let second = sync_all(&home.path);
101        assert!(
102            second.synced.is_empty(),
103            "second sync re-injected rules (not idempotent): {:?}",
104            second.synced
105        );
106        assert!(
107            second.errors.is_empty(),
108            "second sync reported errors: {:?}",
109            second.errors
110        );
111    }
112
113    #[test]
114    #[serial_test::serial(claude_config_dir)]
115    fn sync_agent_unknown_is_a_noop() {
116        let home = TempHome::new("agent");
117        let _claude = scope_claude_into(&home.path);
118
119        // An unknown agent key matches no target, so nothing is injected,
120        // regardless of which agent CLIs happen to be installed on the host.
121        let report = sync_agent(&home.path, "unknown_xyz");
122        assert!(
123            report.synced.is_empty(),
124            "unknown agent injected rules: {:?}",
125            report.synced
126        );
127        assert!(
128            report.errors.is_empty(),
129            "unknown agent reported errors: {:?}",
130            report.errors
131        );
132    }
133}