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::core::rules_canonical::START_MARK;
44 let end_marker = crate::core::rules_canonical::END_MARK;
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 use crate::core::rules_canonical::{END_MARK, START_MARK};
160
161 #[test]
162 fn drift_status_display() {
163 assert_eq!(DriftStatus::InSync.to_string(), "IN_SYNC");
164 assert_eq!(DriftStatus::Drifted.to_string(), "DRIFTED");
165 assert_eq!(DriftStatus::Missing.to_string(), "MISSING");
166 assert_eq!(DriftStatus::NoMarkers.to_string(), "NO_MARKERS");
167 assert_eq!(DriftStatus::ReadError.to_string(), "READ_ERROR");
168 assert_eq!(DriftStatus::NotDetected.to_string(), "NOT_DETECTED");
169 }
170
171 #[test]
172 fn extract_section_with_markers() {
173 let content =
174 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nrules\n{END_MARK}\nafter");
175 let section = extract_section(&content, START_MARK, END_MARK);
176 assert!(section.contains("rules"));
177 assert!(section.contains(START_MARK));
178 assert!(section.contains(END_MARK));
179 assert!(!section.contains("before"));
180 assert!(!section.contains("after"));
181 }
182
183 #[test]
184 fn extract_section_no_marker() {
185 let section = extract_section("no markers here", "MARKER", "END");
186 assert!(section.is_empty());
187 }
188
189 #[test]
190 fn compute_diff_identical() {
191 let diff = compute_diff("line1\nline2", "line1\nline2");
192 assert!(diff.is_empty());
193 }
194
195 #[test]
196 fn compute_diff_changed() {
197 let diff = compute_diff("line1\nline2", "line1\nline3");
198 assert!(diff.contains("- line2"));
199 assert!(diff.contains("+ line3"));
200 }
201
202 #[test]
203 fn compute_diff_added_line() {
204 let diff = compute_diff("line1", "line1\nline2");
205 assert!(diff.contains("+ line2"));
206 }
207
208 #[test]
209 fn compute_diff_removed_line() {
210 let diff = compute_diff("line1\nline2", "line1");
211 assert!(diff.contains("- line2"));
212 }
213}