Skip to main content

scc_cli/
agents_md.rs

1//! Marker-aware replacement of the `<!-- SCC-SECTION -->` managed block
2//! in AGENTS.md (and equivalent instruction files).
3//!
4//! Replacement is `before-opening + new section + after-closing`. User
5//! text after the closing marker is preserved. A rewrite that kept only
6//! the prefix before `<!-- SCC-SECTION` deleted later user edits.
7
8/// Opening marker prefix. A valid tag is this prefix followed by a tag
9/// boundary (whitespace or `-->`), not a longer token such as
10/// `<!-- SCC-SECTION-NOTES -->`.
11pub const SCC_SECTION_OPEN: &str = "<!-- SCC-SECTION";
12/// Closing marker (exact).
13pub const SCC_SECTION_CLOSE: &str = "<!-- /SCC-SECTION -->";
14
15/// OMP-setup managed section markers. The OMP and Codex installers share
16/// instruction files (both may patch the root AGENTS.md), so each owns a
17/// DISTINCT marker pair — one shared pair would make the second installer
18/// delete the first installer's section (last-writer-wins data loss).
19pub const SCC_OMP_SECTION_OPEN: &str = "<!-- SCC-OMP-SECTION -->";
20pub const SCC_OMP_SECTION_CLOSE: &str = "<!-- /SCC-OMP-SECTION -->";
21
22// trace:exempt reason=internal-helper
23fn 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
30/// Byte indices of opening markers. Prefix opens (no trailing `-->`)
31/// require a tag boundary so `<!-- SCC-SECTION-NOTES -->` is not a hit.
32/// Exact opens that already end with `-->` use literal `find`.
33// trace:exempt reason=internal-helper
34fn 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
54/// First complete `(open, close)` pair whose close is before the next
55/// opening marker. An orphan open is never paired with a later section's
56/// close — that would delete the user tail this rewrite exists to keep.
57// trace:exempt reason=internal-helper
58fn 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
74/// Replace a managed section delimited by an explicit marker pair.
75/// `replace_scc_section` is this function with the legacy shared markers.
76// trace:v1 id=impl.scc.agents-md.replace-section work=WORK-SCC-001 satisfies=REQ-SCC-API,REQ-implement-resolve-merge-conflicts-with-origin-main-and-address-remaini
77pub 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
81/// Replace a managed section delimited by an explicit marker pair,
82/// keeping everything before the opening marker and everything after the
83/// closing marker. Pairing never spans a second opening marker. If no
84/// complete pair is present, the new section is appended and later user
85/// text (including an orphaned open) is preserved.
86// trace:v1 id=impl.scc.agents-md.replace-section-in work=WORK-SCC-001 satisfies=REQ-SCC-API,REQ-implement-resolve-merge-conflicts-with-origin-main-and-address-remaini
87pub 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        // No complete pair (lookalike only, unpaired open, or unmarked):
100        // keep the whole document (including later user text) and append.
101        None => (existing.trim(), ""),
102    };
103    join_parts(before, new_section, after)
104}
105
106// trace:exempt reason=internal-helper
107fn 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/// Resolve the instruction file `scc setup omp` should patch.
131///
132/// OMP treats `.omp/AGENTS.md` as higher-priority same-scope context than
133/// the root `AGENTS.md`. Required installer behavior:
134/// - existing `.omp/AGENTS.md` → patch that (OMP's winning source)
135/// - else existing root `AGENTS.md` → patch root, do NOT create a
136///   shadowing `.omp/AGENTS.md`
137/// - else create the canonical root `AGENTS.md`
138/// - if both exist: patch `.omp/AGENTS.md` and surface a warning
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140// trace:exempt reason=const-data
141pub enum AgentsTarget {
142    Omp,
143    Root,
144}
145
146#[derive(Debug, Clone)]
147// trace:exempt reason=const-data
148pub struct AgentsResolution {
149    pub target: AgentsTarget,
150    pub both_exist: bool,
151}
152
153// trace:v1 id=impl.scc.agents-md.resolve-omp work=WORK-SCC-001 satisfies=REQ-SCC-API
154pub 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        // Existing root AGENTS.md, or neither file: patch/create root.
166        // Never introduce a shadowing `.omp/AGENTS.md`.
167        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    // trace:v1 id=test.scc-cli-agents-md.preserves-after-close work=WORK-SCC-001 verifies=REQ-SCC-API,REQ-implement-p0-omp-integration-correctness-and-writable-benchmark-scient
180    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    // trace:v1 id=test.scc-cli-agents-md.append-when-unmarked work=WORK-SCC-001 verifies=REQ-SCC-API
191    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    // trace:v1 id=test.scc-cli-agents-md.lookalike-open-is-not-a-marker work=WORK-SCC-001 verifies=REQ-SCC-API,REQ-implement-p0-omp-integration-correctness-and-writable-benchmark-scient
199    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    // trace:v1 id=test.scc-cli-agents-md.unpaired-open-preserves-tail work=WORK-SCC-001 verifies=REQ-SCC-API,REQ-implement-p0-omp-integration-correctness-and-writable-benchmark-scient
209    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    // trace:v1 id=test.scc-cli-agents-md.unpaired-open-replace-twice-preserves-tail work=WORK-resolve-merge-conflicts-with-origin-main-and-address-remaining-code-rabbi verifies=REQ-implement-resolve-merge-conflicts-with-origin-main-and-address-remaini,REQ-implement-p0-omp-integration-correctness-and-writable-benchmark-scient exercises=impl.scc.agents-md.replace-section-in
218    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}