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
12/// (Re)write the canonical lean-ctx rules block into every detected agent config.
13///
14/// The source of truth is `rules_canonical` (via `rules_inject::inject_all_rules`),
15/// **not** `.lean-ctx/rules.toml`. Sync regenerates the canonical block and
16/// preserves the user's own text around the `<!-- lean-ctx-rules -->` markers;
17/// `rules.toml` is consumed only by `lint` and produced by `init` (see
18/// [`super::config::RulesConfig`]). Stating this explicitly resolves the
19/// "sync doesn't honor rules.toml" ambiguity — by design it does not (#548).
20pub fn sync_all(home: &Path) -> SyncReport {
21    let inject_result = crate::rules_inject::inject_all_rules(home);
22
23    let mut synced = Vec::new();
24    synced.extend(inject_result.injected.iter().cloned());
25    synced.extend(inject_result.updated.iter().cloned());
26
27    SyncReport {
28        synced,
29        skipped: inject_result.already,
30        errors: inject_result.errors,
31    }
32}
33
34/// (Re)write the canonical rules block into a single agent's config.
35///
36/// Same canonical-source contract as [`sync_all`]: regenerates from
37/// `rules_canonical` and never reads `.lean-ctx/rules.toml` (#548).
38pub fn sync_agent(home: &Path, agent: &str) -> SyncReport {
39    let inject_result = crate::rules_inject::inject_rules_for_agent(home, agent);
40
41    let mut synced = Vec::new();
42    synced.extend(inject_result.injected.iter().cloned());
43    synced.extend(inject_result.updated.iter().cloned());
44
45    SyncReport {
46        synced,
47        skipped: inject_result.already,
48        errors: inject_result.errors,
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    /// A unique temp home that is removed on drop, so a test leaves no trace even
57    /// if an assertion panics. Each instance uses a fresh path (pid + nanos) so a
58    /// run can never be polluted by leftovers from a previous run — the old tests
59    /// reused a fixed `/tmp` path and broke the moment any agent directory was
60    /// created under it.
61    struct TempHome {
62        path: std::path::PathBuf,
63    }
64
65    impl TempHome {
66        fn new(tag: &str) -> Self {
67            let nanos = std::time::SystemTime::now()
68                .duration_since(std::time::UNIX_EPOCH)
69                .map_or(0, |d| d.as_nanos());
70            let path = std::env::temp_dir()
71                .join(format!("leanctx_sync_{tag}_{}_{nanos}", std::process::id()));
72            Self { path }
73        }
74    }
75
76    impl Drop for TempHome {
77        fn drop(&mut self) {
78            let _ = std::fs::remove_dir_all(&self.path);
79        }
80    }
81
82    /// Scope Claude's config dir into the sandbox so a test can never write into
83    /// the developer's real `~/.claude` — `CLAUDE_CONFIG_DIR` is the only agent
84    /// detection path that escapes `home`.
85    fn scope_claude_into(home: &std::path::Path) -> crate::setup::EnvVarGuard {
86        let claude_dir = home.join(".claude").to_string_lossy().into_owned();
87        crate::setup::EnvVarGuard::set("CLAUDE_CONFIG_DIR", &claude_dir)
88    }
89
90    // `sync_all` is idempotent and side-effect-scoped: a second sync over the same
91    // home injects nothing new and never errors. We assert *idempotency* rather
92    // than "nothing is ever synced", because agents such as Codex/Pi are detected
93    // via `$PATH` (`which`), so what gets injected on the first pass is host-
94    // dependent — but re-running must always be a clean no-op.
95    // Serialized against every other test that reads or writes the global
96    // `CLAUDE_CONFIG_DIR` (e.g. doctor `claude_instructions_check`): this test
97    // sets it process-wide via `scope_claude_into`, so a concurrent reader
98    // would otherwise resolve Claude's state dir to *this* sandbox (#401 CI).
99    #[test]
100    #[serial_test::serial(claude_config_dir)]
101    fn sync_all_is_idempotent_and_error_free() {
102        let home = TempHome::new("all");
103        let _claude = scope_claude_into(&home.path);
104
105        let first = sync_all(&home.path);
106        assert!(
107            first.errors.is_empty(),
108            "first sync reported errors: {:?}",
109            first.errors
110        );
111
112        let second = sync_all(&home.path);
113        assert!(
114            second.synced.is_empty(),
115            "second sync re-injected rules (not idempotent): {:?}",
116            second.synced
117        );
118        assert!(
119            second.errors.is_empty(),
120            "second sync reported errors: {:?}",
121            second.errors
122        );
123    }
124
125    // #548 criterion 5 (setup/init/sync consistency): `sync` and `diff` share one
126    // canonical source of truth, so immediately after a `sync_all` the on-disk
127    // blocks must read back as in-sync — `detect_drift` may never report `Drifted`
128    // for a target we just wrote. This pins sync↔diff agreement and would catch a
129    // future divergence between the inject and drift comparison paths.
130    #[test]
131    #[serial_test::serial(claude_config_dir)]
132    fn sync_then_diff_reports_no_drift() {
133        use crate::core::contextops::drift::{DriftStatus, detect_drift};
134
135        let home = TempHome::new("syncdiff");
136        let _claude = scope_claude_into(&home.path);
137
138        let report = sync_all(&home.path);
139        assert!(
140            report.errors.is_empty(),
141            "sync reported errors: {:?}",
142            report.errors
143        );
144
145        let drifted: Vec<String> = detect_drift(&home.path)
146            .into_iter()
147            .filter(|r| r.status == DriftStatus::Drifted)
148            .map(|r| r.target)
149            .collect();
150        assert!(
151            drifted.is_empty(),
152            "sync left targets drifted vs the canonical source: {drifted:?}"
153        );
154    }
155
156    #[test]
157    #[serial_test::serial(claude_config_dir)]
158    fn sync_agent_unknown_is_a_noop() {
159        let home = TempHome::new("agent");
160        let _claude = scope_claude_into(&home.path);
161
162        // An unknown agent key matches no target, so nothing is injected,
163        // regardless of which agent CLIs happen to be installed on the host.
164        let report = sync_agent(&home.path, "unknown_xyz");
165        assert!(
166            report.synced.is_empty(),
167            "unknown agent injected rules: {:?}",
168            report.synced
169        );
170        assert!(
171            report.errors.is_empty(),
172            "unknown agent reported errors: {:?}",
173            report.errors
174        );
175    }
176}