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