lean_ctx/core/contextops/
drift.rs1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5use super::config::RulesConfig;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub enum DriftStatus {
9 InSync,
10 Drifted,
11 Missing,
12 NoMarkers,
13 ReadError,
14 NotDetected,
15}
16
17impl std::fmt::Display for DriftStatus {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 match self {
20 Self::InSync => write!(f, "IN_SYNC"),
21 Self::Drifted => write!(f, "DRIFTED"),
22 Self::Missing => write!(f, "MISSING"),
23 Self::NoMarkers => write!(f, "NO_MARKERS"),
24 Self::ReadError => write!(f, "READ_ERROR"),
25 Self::NotDetected => write!(f, "NOT_DETECTED"),
26 }
27 }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct DriftReport {
32 pub target: String,
33 pub path: String,
34 pub status: DriftStatus,
35 pub diff: Option<String>,
36}
37
38pub fn detect_drift(home: &Path, _config: &RulesConfig) -> Vec<DriftReport> {
39 let statuses = crate::rules_inject::collect_rules_status(home);
40 let source_shared = crate::rules_inject::rules_shared_content();
41 let source_dedicated = crate::rules_inject::rules_dedicated_markdown();
42
43 let marker = crate::rules_inject::RULES_MARKER;
44 let end_marker = "<!-- /lean-ctx -->";
45
46 statuses
47 .into_iter()
48 .map(|status| {
49 if !status.detected {
50 return DriftReport {
51 target: status.name,
52 path: status.path,
53 status: DriftStatus::NotDetected,
54 diff: None,
55 };
56 }
57
58 let path = Path::new(&status.path);
59 if !path.exists() {
60 return DriftReport {
61 target: status.name,
62 path: status.path,
63 status: DriftStatus::Missing,
64 diff: None,
65 };
66 }
67
68 let Ok(content) = std::fs::read_to_string(path) else {
69 return DriftReport {
70 target: status.name,
71 path: status.path,
72 status: DriftStatus::ReadError,
73 diff: None,
74 };
75 };
76
77 if !content.contains(marker) {
78 return DriftReport {
79 target: status.name,
80 path: status.path,
81 status: DriftStatus::NoMarkers,
82 diff: None,
83 };
84 }
85
86 let section = extract_section(&content, marker, end_marker);
87 let is_dedicated =
88 status.state == "up_to_date" && !content.contains("existing user rules");
89
90 let expected_section = if is_dedicated {
91 extract_section(source_dedicated, marker, end_marker)
92 } else {
93 extract_section(source_shared, marker, end_marker)
94 };
95
96 let section_trimmed = section.trim();
97 let expected_trimmed = expected_section.trim();
98
99 if section_trimmed == expected_trimmed {
100 DriftReport {
101 target: status.name,
102 path: status.path,
103 status: DriftStatus::InSync,
104 diff: None,
105 }
106 } else {
107 let diff = compute_diff(expected_trimmed, section_trimmed);
108 DriftReport {
109 target: status.name,
110 path: status.path,
111 status: DriftStatus::Drifted,
112 diff: Some(diff),
113 }
114 }
115 })
116 .collect()
117}
118
119fn extract_section(content: &str, marker: &str, end_marker: &str) -> String {
120 let Some(start) = content.find(marker) else {
121 return String::new();
122 };
123 let end = content[start..]
124 .find(end_marker)
125 .map_or(content.len(), |e| start + e + end_marker.len());
126
127 content[start..end].to_string()
128}
129
130fn compute_diff(expected: &str, actual: &str) -> String {
131 let expected_lines: Vec<&str> = expected.lines().collect();
132 let actual_lines: Vec<&str> = actual.lines().collect();
133
134 let mut diff_lines = Vec::new();
135 let max_len = expected_lines.len().max(actual_lines.len());
136
137 for i in 0..max_len {
138 match (expected_lines.get(i), actual_lines.get(i)) {
139 (Some(exp), Some(act)) if exp != act => {
140 diff_lines.push(format!("- {exp}"));
141 diff_lines.push(format!("+ {act}"));
142 }
143 (Some(exp), None) => {
144 diff_lines.push(format!("- {exp}"));
145 }
146 (None, Some(act)) => {
147 diff_lines.push(format!("+ {act}"));
148 }
149 _ => {}
150 }
151 }
152
153 diff_lines.join("\n")
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn drift_status_display() {
162 assert_eq!(DriftStatus::InSync.to_string(), "IN_SYNC");
163 assert_eq!(DriftStatus::Drifted.to_string(), "DRIFTED");
164 assert_eq!(DriftStatus::Missing.to_string(), "MISSING");
165 assert_eq!(DriftStatus::NoMarkers.to_string(), "NO_MARKERS");
166 assert_eq!(DriftStatus::ReadError.to_string(), "READ_ERROR");
167 assert_eq!(DriftStatus::NotDetected.to_string(), "NOT_DETECTED");
168 }
169
170 #[test]
171 fn extract_section_with_markers() {
172 let content =
173 "before\n# lean-ctx — Context Engineering Layer\nrules\n<!-- /lean-ctx -->\nafter";
174 let section = extract_section(
175 content,
176 "# lean-ctx — Context Engineering Layer",
177 "<!-- /lean-ctx -->",
178 );
179 assert!(section.contains("rules"));
180 assert!(section.contains("# lean-ctx"));
181 assert!(section.contains("<!-- /lean-ctx -->"));
182 assert!(!section.contains("before"));
183 assert!(!section.contains("after"));
184 }
185
186 #[test]
187 fn extract_section_no_marker() {
188 let section = extract_section("no markers here", "MARKER", "END");
189 assert!(section.is_empty());
190 }
191
192 #[test]
193 fn compute_diff_identical() {
194 let diff = compute_diff("line1\nline2", "line1\nline2");
195 assert!(diff.is_empty());
196 }
197
198 #[test]
199 fn compute_diff_changed() {
200 let diff = compute_diff("line1\nline2", "line1\nline3");
201 assert!(diff.contains("- line2"));
202 assert!(diff.contains("+ line3"));
203 }
204
205 #[test]
206 fn compute_diff_added_line() {
207 let diff = compute_diff("line1", "line1\nline2");
208 assert!(diff.contains("+ line2"));
209 }
210
211 #[test]
212 fn compute_diff_removed_line() {
213 let diff = compute_diff("line1\nline2", "line1");
214 assert!(diff.contains("- line2"));
215 }
216}