Skip to main content

lean_ctx/
marked_block.rs

1use std::path::Path;
2
3/// Byte span (start, end-exclusive) of the first line whose *trimmed* content
4/// equals `marker` (GL #1158). Marker detection must be line-based: a prose
5/// mention like "(see the `<!-- lean-ctx -->` block below)" matched the old
6/// substring search first, so block surgery deleted everything between the
7/// mention and the real end marker — silent user-content loss.
8pub(crate) fn marker_line_span(content: &str, marker: &str) -> Option<(usize, usize)> {
9    let mut offset = 0;
10    for line in content.split_inclusive('\n') {
11        if line.trim() == marker {
12            return Some((offset, offset + line.len()));
13        }
14        offset += line.len();
15    }
16    None
17}
18
19/// True when `content` carries `marker` as a whole (trimmed) line — the only
20/// form the writers emit. Use instead of `content.contains(marker)` wherever
21/// "this file has the block" is meant.
22pub fn contains_marker_line(content: &str, marker: &str) -> bool {
23    marker_line_span(content, marker).is_some()
24}
25
26pub fn upsert(path: &Path, start: &str, end: &str, block: &str, quiet: bool, label: &str) {
27    let existing = std::fs::read_to_string(path).unwrap_or_default();
28
29    if contains_marker_line(&existing, start) {
30        let cleaned = remove_content(&existing, start, end);
31        let mut out = cleaned.trim_end().to_string();
32        if !out.is_empty() {
33            out.push('\n');
34        }
35        out.push('\n');
36        out.push_str(block);
37        out.push('\n');
38        std::fs::write(path, &out).ok();
39        if !quiet {
40            println!("  Updated {label}");
41        }
42    } else {
43        let mut out = existing;
44        if !out.is_empty() && !out.ends_with('\n') {
45            out.push('\n');
46        }
47        if !out.is_empty() {
48            out.push('\n');
49        }
50        out.push_str(block);
51        out.push('\n');
52        std::fs::write(path, &out).ok();
53        if !quiet {
54            eprintln!("  Installed {label}");
55        }
56    }
57}
58
59pub fn remove_from_file(path: &Path, start: &str, end: &str, quiet: bool, label: &str) {
60    let Ok(existing) = std::fs::read_to_string(path) else {
61        return;
62    };
63    if !contains_marker_line(&existing, start) {
64        return;
65    }
66    let cleaned = remove_content(&existing, start, end);
67    std::fs::write(path, cleaned.trim_end().to_owned() + "\n").ok();
68    if !quiet {
69        println!("  Removed {label}");
70    }
71}
72
73pub fn remove_content(content: &str, start: &str, end: &str) -> String {
74    let s = marker_line_span(content, start);
75    let e = s.and_then(|(si, _)| {
76        marker_line_span(&content[si..], end).map(|(es, ee)| (si + es, si + ee))
77    });
78    match (s, e) {
79        (Some((si, _)), Some((_, end_after))) => {
80            let before = content[..si].trim_end_matches('\n');
81            let after = content[end_after..].trim_start_matches('\n');
82            let mut out = before.to_string();
83            if !after.is_empty() {
84                out.push('\n');
85                out.push_str(after);
86            }
87            out
88        }
89        _ => content.to_string(),
90    }
91}
92
93/// Replace the region between `start` and `end` markers with `replacement`
94/// (trim-aware newlines). If markers are missing or invalid, returns `content` unchanged.
95pub fn replace_marked_block(content: &str, start: &str, end: &str, replacement: &str) -> String {
96    let s = marker_line_span(content, start);
97    let e = s.and_then(|(si, _)| {
98        marker_line_span(&content[si..], end).map(|(es, ee)| (si + es, si + ee))
99    });
100    match (s, e) {
101        (Some((si, _)), Some((_, end_after))) => {
102            let before = &content[..si];
103            let after = &content[end_after..];
104            let mut out = String::new();
105            out.push_str(before.trim_end_matches('\n'));
106            out.push('\n');
107            out.push('\n');
108            out.push_str(replacement.trim_end_matches('\n'));
109            out.push('\n');
110            out.push_str(after.trim_start_matches('\n'));
111            out
112        }
113        _ => content.to_string(),
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn remove_content_works() {
123        let content = "before\n# >>> start >>>\nhook content\n# <<< end <<<\nafter\n";
124        let cleaned = remove_content(content, "# >>> start >>>", "# <<< end <<<");
125        assert!(!cleaned.contains("hook content"));
126        assert!(cleaned.contains("before"));
127        assert!(cleaned.contains("after"));
128    }
129
130    #[test]
131    fn remove_content_preserves_when_missing() {
132        let content = "no hook here\n";
133        let cleaned = remove_content(content, "# >>> start >>>", "# <<< end <<<");
134        assert_eq!(cleaned, content);
135    }
136
137    // --- GL #1158: markers match as whole lines only ---
138
139    const START: &str = "<!-- lean-ctx -->";
140    const END: &str = "<!-- /lean-ctx -->";
141
142    /// The exact live-repro shape: the project AGENTS.md mentions the marker
143    /// in prose ("see the `<!-- lean-ctx -->` block below") ABOVE dozens of
144    /// lines of user content, followed by the real block. The old substring
145    /// match anchored at the prose mention and deleted everything in between.
146    fn agents_md_with_prose_mention() -> String {
147        format!(
148            "# Context Layer\n\n\
149             The table is auto-injected (see the `{START}` block below) — it is\n\
150             deliberately not duplicated here.\n\n\
151             ## Development Workflow\n\n\
152             1. build\n2. test\n\n\
153             {START}\n## lean-ctx\nold pointer\n{END}\n"
154        )
155    }
156
157    #[test]
158    fn prose_marker_mention_is_not_a_block() {
159        let prose_only = format!("docs: cite `{START}` and `{END}` in text\n");
160        assert!(!contains_marker_line(&prose_only, START));
161        let real = format!("{START}\nbody\n{END}\n");
162        assert!(contains_marker_line(&real, START));
163    }
164
165    #[test]
166    fn replace_marked_block_survives_prose_mention() {
167        let content = agents_md_with_prose_mention();
168        let updated = replace_marked_block(&content, START, END, &format!("{START}\nnew\n{END}"));
169        assert!(
170            updated.contains("## Development Workflow") && updated.contains("2. test"),
171            "user content between prose mention and real block must survive:\n{updated}"
172        );
173        assert!(updated.contains("see the `<!-- lean-ctx -->` block below"));
174        assert!(updated.contains("new"), "block itself must be replaced");
175        assert!(!updated.contains("old pointer"));
176    }
177
178    #[test]
179    fn remove_content_survives_prose_mention() {
180        let content = agents_md_with_prose_mention();
181        let cleaned = remove_content(&content, START, END);
182        assert!(cleaned.contains("## Development Workflow"));
183        assert!(cleaned.contains("see the `<!-- lean-ctx -->` block below"));
184        assert!(!cleaned.contains("old pointer"));
185    }
186
187    #[test]
188    fn end_marker_before_start_is_ignored() {
189        // A stray end marker above the real block must not create a bogus span.
190        let content = format!("{END}\nuser\n{START}\nbody\n{END}\n");
191        let cleaned = remove_content(&content, START, END);
192        assert!(cleaned.contains("user"));
193        assert!(!cleaned.contains("body"));
194    }
195}