Skip to main content

lean_ctx/core/
rules_validation.rs

1//! Rules-file parsing, validation, and merge helpers.
2use crate::core::config::CompressionLevel;
3
4use super::rules_canonical::{END_MARK, RULES_VERSION, START_MARK, Wrapper, render};
5
6/// A parsed lean-ctx rules section from a file on disk.
7///
8/// Handles version detection, content boundary discovery, and prefix/suffix
9/// extraction. This is the only place that parses rule markers.
10#[derive(Debug)]
11pub struct RulesFile<'a> {
12    content: &'a str,
13    /// Byte offset of `START_MARK` (or the first old-format marker found).
14    start: Option<usize>,
15    /// Byte offset of `END_MARK`.
16    end: Option<usize>,
17    /// Parsed version number (0 if no version comment found).
18    version: usize,
19}
20
21/// Parse the version number from the first version comment found.
22fn parse_version_number(s: &str) -> Option<usize> {
23    let prefix = "<!-- version: ";
24    let vs = s.find(prefix)?;
25    let num_start = vs + prefix.len();
26    let end = s[num_start..].find(" -->")?;
27    s[num_start..num_start + end].parse().ok()
28}
29
30impl<'a> RulesFile<'a> {
31    /// Parse `content`, scanning for `START_MARK` and version comment.
32    pub fn parse(content: &'a str) -> Self {
33        let start = content.find(START_MARK);
34        let version = start
35            .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
36            .unwrap_or(0);
37        let end = content.find(END_MARK);
38        RulesFile {
39            content,
40            start,
41            end,
42            version,
43        }
44    }
45
46    /// Whether the file carries any lean-ctx rules content.
47    pub fn has_content(&self) -> bool {
48        self.start.is_some()
49    }
50
51    /// The detected version (0 if no version marker).
52    pub fn version(&self) -> usize {
53        self.version
54    }
55
56    /// Whether the file's version is at least `RULES_VERSION`.
57    pub fn is_current(&self) -> bool {
58        self.version >= RULES_VERSION
59    }
60
61    /// Content before the first `START_MARK`.
62    pub fn prefix(&self) -> &'a str {
63        self.start.map_or("", |s| self.content[..s].trim())
64    }
65
66    /// Content after the last `END_MARK`.
67    pub fn suffix(&self) -> &'a str {
68        self.end
69            .map_or("", |e| self.content[e + END_MARK.len()..].trim())
70    }
71
72    /// The lean-ctx block on disk, when both markers are present.
73    fn block(&self) -> Option<&'a str> {
74        match (self.start, self.end) {
75            (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
76            _ => None,
77        }
78    }
79
80    /// Whether the on-disk block matches a fresh `render`.
81    pub fn block_matches_render(
82        &self,
83        shadow: bool,
84        wrapper: Wrapper,
85        level: CompressionLevel,
86        tool_profile: &super::tool_profiles::ToolProfile,
87    ) -> bool {
88        match self.block() {
89            Some(block) => block.trim() == render(shadow, wrapper, level, tool_profile).trim(),
90            None => false,
91        }
92    }
93
94    /// Merge freshly-rendered rules into this file.
95    pub fn merged(
96        &self,
97        shadow: bool,
98        wrapper: Wrapper,
99        level: CompressionLevel,
100        tool_profile: &super::tool_profiles::ToolProfile,
101    ) -> String {
102        let fresh = render(shadow, wrapper, level, tool_profile);
103        if self.start.is_some() {
104            let before = self.prefix();
105            let after = self.suffix();
106            let mut out = String::new();
107            if !before.is_empty() {
108                out.push_str(before);
109                out.push('\n');
110                out.push('\n');
111            }
112            out.push_str(&fresh);
113            if !after.is_empty() {
114                out.push('\n');
115                out.push('\n');
116                out.push_str(after);
117            }
118            if !out.ends_with('\n') {
119                out.push('\n');
120            }
121            out
122        } else {
123            let trimmed = self.content.trim_end();
124            let mut out = trimmed.to_string();
125            if !out.is_empty() {
126                out.push('\n');
127                out.push('\n');
128            }
129            out.push_str(&fresh);
130            out
131        }
132    }
133
134    /// Create initial rules content.
135    pub fn initial(
136        shadow: bool,
137        wrapper: Wrapper,
138        level: CompressionLevel,
139        tool_profile: &super::tool_profiles::ToolProfile,
140    ) -> String {
141        render(shadow, wrapper, level, tool_profile)
142    }
143
144    /// Strip the lean-ctx section, keeping user content before/after.
145    pub fn without_section(&self) -> String {
146        if let Some(start_pos) = self.start {
147            let before = self.content[..start_pos].trim();
148            let after = self.suffix();
149            let mut out = String::new();
150            if !before.is_empty() {
151                out.push_str(before);
152                out.push('\n');
153            }
154            if !after.is_empty() {
155                out.push('\n');
156                out.push_str(after);
157            }
158            out
159        } else {
160            self.content.to_string()
161        }
162    }
163}