1use std::path::Path;
2
3pub(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
19pub 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
93pub 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 const START: &str = "<!-- lean-ctx -->";
140 const END: &str = "<!-- /lean-ctx -->";
141
142 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 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}