Skip to main content

lean_ctx/core/contextops/
drift.rs

1use 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
36/// Compare each detected agent's on-disk `<!-- lean-ctx-rules -->` block against
37/// the **canonical** rule source (`rules_canonical` via
38/// `rules_inject::rules_shared_content` / `rules_dedicated_markdown`).
39///
40/// Drift is measured purely against the canonical single-source-of-truth — it
41/// deliberately does **not** read `.lean-ctx/rules.toml`. `rules.toml` is a
42/// `rules lint` input and a user-facing export from `rules init`; it never
43/// overrides the canonical rule body (see [`super::config::RulesConfig`]). This
44/// is also why `rules diff` works without running `rules init` first (#548).
45pub(crate) fn detect_drift(home: &Path) -> Vec<DriftReport> {
46    let statuses = crate::rules_inject::collect_rules_status(home);
47    // The canonical block lean-ctx would write for each target, keyed by name and
48    // chosen by the target's real `RulesFormat` — see the heuristic note below.
49    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            // Compare against the canonical block for THIS target's format. The
97            // previous content heuristic ("up_to_date and no 'existing user
98            // rules'") misclassified freshly synced shared files (Copilot/Codex
99            // CLI) as dedicated and reported phantom drift after every sync (#548).
100            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                // The comparison needs the file as it is; what is REPORTED is
117                // a rendering of its content, so the diff passes admission as
118                // carrier text before it leaves this function (an agent rule
119                // file lives outside any project and has no rooted reader).
120                // Nothing read here is written anywhere.
121                let diff = compute_diff(expected_trimmed, section_trimmed);
122                let diff = match crate::core::context_admission::carrier::admit_carrier_text(&diff)
123                {
124                    Ok(admitted) => admitted.text,
125                    Err(withheld) => withheld.notice(),
126                };
127                DriftReport {
128                    target: status.name,
129                    path: status.path,
130                    status: DriftStatus::Drifted,
131                    diff: Some(diff),
132                }
133            }
134        })
135        .collect()
136}
137
138fn extract_section(content: &str, marker: &str, end_marker: &str) -> String {
139    let Some(start) = content.find(marker) else {
140        return String::new();
141    };
142    let end = content[start..]
143        .find(end_marker)
144        .map_or(content.len(), |e| start + e + end_marker.len());
145
146    content[start..end].to_string()
147}
148
149fn compute_diff(expected: &str, actual: &str) -> String {
150    let expected_lines: Vec<&str> = expected.lines().collect();
151    let actual_lines: Vec<&str> = actual.lines().collect();
152
153    let mut diff_lines = Vec::new();
154    let max_len = expected_lines.len().max(actual_lines.len());
155
156    for i in 0..max_len {
157        match (expected_lines.get(i), actual_lines.get(i)) {
158            (Some(exp), Some(act)) if exp != act => {
159                diff_lines.push(format!("- {exp}"));
160                diff_lines.push(format!("+ {act}"));
161            }
162            (Some(exp), None) => {
163                diff_lines.push(format!("- {exp}"));
164            }
165            (None, Some(act)) => {
166                diff_lines.push(format!("+ {act}"));
167            }
168            _ => {}
169        }
170    }
171
172    diff_lines.join("\n")
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::core::rules_canonical::{END_MARK, START_MARK};
179
180    #[test]
181    fn drift_status_display() {
182        assert_eq!(DriftStatus::InSync.to_string(), "IN_SYNC");
183        assert_eq!(DriftStatus::Drifted.to_string(), "DRIFTED");
184        assert_eq!(DriftStatus::Missing.to_string(), "MISSING");
185        assert_eq!(DriftStatus::NoMarkers.to_string(), "NO_MARKERS");
186        assert_eq!(DriftStatus::ReadError.to_string(), "READ_ERROR");
187        assert_eq!(DriftStatus::NotDetected.to_string(), "NOT_DETECTED");
188    }
189
190    #[test]
191    fn extract_section_with_markers() {
192        let content =
193            format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nrules\n{END_MARK}\nafter");
194        let section = extract_section(&content, START_MARK, END_MARK);
195        assert!(section.contains("rules"));
196        assert!(section.contains(START_MARK));
197        assert!(section.contains(END_MARK));
198        assert!(!section.contains("before"));
199        assert!(!section.contains("after"));
200    }
201
202    #[test]
203    fn extract_section_no_marker() {
204        let section = extract_section("no markers here", "MARKER", "END");
205        assert!(section.is_empty(), "{section:?}");
206    }
207
208    #[test]
209    fn compute_diff_identical() {
210        let diff = compute_diff("line1\nline2", "line1\nline2");
211        assert!(diff.is_empty(), "{diff:?}");
212    }
213
214    #[test]
215    fn compute_diff_changed() {
216        let diff = compute_diff("line1\nline2", "line1\nline3");
217        assert!(diff.contains("- line2"));
218        assert!(diff.contains("+ line3"));
219    }
220
221    #[test]
222    fn compute_diff_added_line() {
223        let diff = compute_diff("line1", "line1\nline2");
224        assert!(diff.contains("+ line2"));
225    }
226
227    #[test]
228    fn compute_diff_removed_line() {
229        let diff = compute_diff("line1\nline2", "line1");
230        assert!(diff.contains("- line2"));
231    }
232}