Skip to main content

lean_ctx/core/contextops/
drift.rs

1use 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}