1pub const SCC_SECTION_OPEN: &str = "<!-- SCC-SECTION";
12pub const SCC_SECTION_CLOSE: &str = "<!-- /SCC-SECTION -->";
14
15pub const SCC_OMP_SECTION_OPEN: &str = "<!-- SCC-OMP-SECTION -->";
20pub const SCC_OMP_SECTION_CLOSE: &str = "<!-- /SCC-OMP-SECTION -->";
21
22fn is_open_tag_boundary(rest: &str) -> bool {
24 matches!(
25 rest.chars().next(),
26 Some(' ') | Some('\t') | Some('\n') | Some('\r')
27 ) || rest.starts_with("-->")
28}
29
30fn find_open_markers(existing: &str, open: &str) -> Vec<usize> {
35 let exact = open.ends_with("-->");
36 let mut from = 0;
37 let mut out = Vec::new();
38 while let Some(rel) = existing[from..].find(open) {
39 let start = from + rel;
40 if exact {
41 out.push(start);
42 from = start + open.len();
43 continue;
44 }
45 let rest = &existing[start + open.len()..];
46 if is_open_tag_boundary(rest) {
47 out.push(start);
48 }
49 from = start + open.len();
50 }
51 out
52}
53
54fn find_complete_pair(existing: &str, open: &str, close: &str) -> Option<(usize, usize)> {
59 let opens = find_open_markers(existing, open);
60 for (i, start) in opens.iter().copied().enumerate() {
61 let search_from = start + open.len();
62 let Some(rel) = existing[search_from..].find(close) else {
63 continue;
64 };
65 let end = search_from + rel;
66 let next_open = opens.get(i + 1).copied();
67 if next_open.map(|n| end < n).unwrap_or(true) {
68 return Some((start, end));
69 }
70 }
71 None
72}
73
74pub fn replace_scc_section(existing: &str, new_section: &str) -> String {
78 replace_scc_section_in(existing, new_section, SCC_SECTION_OPEN, SCC_SECTION_CLOSE)
79}
80
81pub fn replace_scc_section_in(
88 existing: &str,
89 new_section: &str,
90 open: &str,
91 close: &str,
92) -> String {
93 let (before, after) = match find_complete_pair(existing, open, close) {
94 Some((start, end)) => {
95 let before = existing[..start].trim_end();
96 let after = existing[end + close.len()..].trim_start();
97 (before, after)
98 }
99 None => (existing.trim(), ""),
102 };
103 join_parts(before, new_section, after)
104}
105
106fn join_parts(before: &str, section: &str, after: &str) -> String {
108 let mut out = String::new();
109 if !before.is_empty() {
110 out.push_str(before);
111 out.push_str("\n\n");
112 }
113 out.push_str(section);
114 if !section.ends_with('\n') {
115 out.push('\n');
116 }
117 if !after.is_empty() {
118 if !out.ends_with('\n') {
119 out.push('\n');
120 }
121 out.push('\n');
122 out.push_str(after);
123 if !after.ends_with('\n') {
124 out.push('\n');
125 }
126 }
127 out
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum AgentsTarget {
142 Omp,
143 Root,
144}
145
146#[derive(Debug, Clone)]
147pub struct AgentsResolution {
149 pub target: AgentsTarget,
150 pub both_exist: bool,
151}
152
153pub fn resolve_omp_agents_path(root: &std::path::Path) -> AgentsResolution {
155 let omp = root.join(".omp").join("AGENTS.md");
156 let root_agents = root.join("AGENTS.md");
157 let omp_exists = omp.is_file();
158 let root_exists = root_agents.is_file();
159 if omp_exists {
160 AgentsResolution {
161 target: AgentsTarget::Omp,
162 both_exist: root_exists,
163 }
164 } else {
165 AgentsResolution {
168 target: AgentsTarget::Root,
169 both_exist: false,
170 }
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn replace_preserves_text_after_closing_marker() {
181 let existing = "BEFORE\n<!-- SCC-SECTION -->\nold\n<!-- /SCC-SECTION -->\nAFTER USER\n";
182 let out = replace_scc_section(existing, "<!-- SCC-SECTION -->\nNEW\n<!-- /SCC-SECTION -->\n");
183 assert!(out.starts_with("BEFORE"), "{out}");
184 assert!(out.contains("NEW"), "{out}");
185 assert!(out.contains("AFTER USER"), "later user text must survive: {out}");
186 assert!(!out.contains("old"), "{out}");
187 }
188
189 #[test]
190 fn replace_appends_when_no_markers() {
192 let out = replace_scc_section("USER\n", "<!-- SCC-SECTION -->\nNEW\n<!-- /SCC-SECTION -->\n");
193 assert!(out.starts_with("USER"), "{out}");
194 assert!(out.contains("NEW"), "{out}");
195 }
196
197 #[test]
198 fn replace_ignores_lookalike_open_prefix_and_preserves_later_text() {
200 let existing = "USER\n<!-- SCC-SECTION-NOTES -->\nkeep later\n";
201 let out = replace_scc_section(existing, "<!-- SCC-SECTION -->\nNEW\n<!-- /SCC-SECTION -->\n");
202 assert!(out.contains("<!-- SCC-SECTION-NOTES -->"), "{out}");
203 assert!(out.contains("keep later"), "later user text must survive a lookalike tag: {out}");
204 assert!(out.contains("NEW"), "{out}");
205 }
206
207 #[test]
208 fn replace_unpaired_open_preserves_later_user_content() {
210 let existing = "BEFORE\n<!-- SCC-SECTION -->\nbroken managed without close\nKEEP AFTER\n";
211 let out = replace_scc_section(existing, "<!-- SCC-SECTION -->\nNEW\n<!-- /SCC-SECTION -->\n");
212 assert!(out.contains("KEEP AFTER"), "unpaired open must not drop the tail: {out}");
213 assert!(out.contains("NEW"), "{out}");
214 }
215
216 #[test]
217 fn replace_twice_on_unpaired_open_still_preserves_tail() {
219 let existing = "BEFORE\n<!-- SCC-SECTION -->\nbroken managed without close\nKEEP AFTER\n";
220 let section = "<!-- SCC-SECTION -->\nNEW\n<!-- /SCC-SECTION -->\n";
221 let once = replace_scc_section(existing, section);
222 let twice = replace_scc_section(&once, "<!-- SCC-SECTION -->\nNEWER\n<!-- /SCC-SECTION -->\n");
223 assert!(
224 twice.contains("KEEP AFTER"),
225 "second replace must not pair the orphan open with the later close: {twice}"
226 );
227 assert!(twice.contains("NEWER"), "{twice}");
228 assert!(!twice.contains("\nNEW\n"), "replaced body must not keep the first NEW: {twice}");
229 }
230}