lean_ctx/core/contextops/
sync.rs1use 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 {
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
34pub 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 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 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 #[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 #[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 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}