Skip to main content

rac_engine/
agent_rules.rs

1//! Agent-rules projection (`decided.services.agent_rules`) — `decided export
2//! --agent-rules [--check]`, per PORT-CONTRACT.d/17 §3.
3//!
4//! Distils the live corpus (Accepted, non-retired decisions) into a
5//! drift-guarded managed block spliced into per-client agent-context files.
6//! The provenance digest is sha256 over the canonical JSON serialization of
7//! the ordered entries (`sort_keys`, separators `(",", ":")`,
8//! `ensure_ascii=False`) — any byte deviation cascades into `--check` drift,
9//! so the canonical dump must match CPython exactly (`pyjson::dumps_canonical_sorted`).
10
11use std::path::Path;
12
13use serde_json::{json, Map, Value};
14
15use crate::identity::artifact_identifier;
16use crate::pycompat::{first_nonempty_line, py_casefold, read_text_universal};
17use crate::relationships::corpus_items;
18use crate::spec::spec_for;
19
20const DECISION_TYPE: &str = "decision";
21const LIVE_STATUS: &str = "accepted";
22
23const BEGIN_PREFIX: &str = "<!-- BEGIN RAC MANAGED BLOCK (digest: ";
24const BEGIN_SUFFIX: &str = ") -->";
25const END_MARKER: &str = "<!-- END RAC MANAGED BLOCK -->";
26
27const GENERATED_HEADER: &str = "<!-- Managed by `decided export --agent-rules`. \
28     Edit decisions in decisions/, not here; content outside this block is preserved. -->";
29
30/// One per-client target file (selector + root-relative path), in the
31/// oracle's fixed `TARGETS` order.
32pub struct AgentRulesTarget {
33    pub client: &'static str,
34    pub path: &'static str,
35}
36
37pub const TARGETS: [AgentRulesTarget; 4] = [
38    AgentRulesTarget { client: "agents", path: "AGENTS.md" },
39    AgentRulesTarget { client: "claude", path: "CLAUDE.md" },
40    AgentRulesTarget { client: "copilot", path: ".github/copilot-instructions.md" },
41    AgentRulesTarget { client: "cursor", path: ".cursor/rules" },
42];
43
44/// `targets_for(clients)` — always `TARGETS` order regardless of selector
45/// order (and duplicates collapse via set membership).
46fn targets_for(clients: &[String]) -> Vec<&'static AgentRulesTarget> {
47    if clients.is_empty() {
48        return TARGETS.iter().collect();
49    }
50    TARGETS
51        .iter()
52        .filter(|t| clients.iter().any(|c| c == t.client))
53        .collect()
54}
55
56/// One distilled live-decision pointer.
57struct AgentRulesEntry {
58    identifier: String,
59    title: String,
60    category: Option<String>,
61}
62
63// Per-file outcome states (stable JSON contract, ADR-007).
64pub const STATE_WRITTEN: &str = "written";
65pub const STATE_UPDATED: &str = "updated";
66pub const STATE_IN_SYNC: &str = "in-sync";
67pub const STATE_STALE: &str = "stale";
68pub const STATE_MISSING: &str = "missing";
69
70pub struct AgentRulesFileResult {
71    pub client: &'static str,
72    pub path: &'static str,
73    pub state: &'static str,
74}
75
76/// The outcome of a generate or check run (mirrors `AgentRulesResult`).
77pub struct AgentRulesResult {
78    /// `"generate"` or `"check"`.
79    pub mode: &'static str,
80    pub digest: String,
81    /// `str(root_path)` — the PurePosixPath-normalized output root.
82    pub root: String,
83    pub files: Vec<AgentRulesFileResult>,
84}
85
86impl AgentRulesResult {
87    /// True when any checked file is stale or missing its block.
88    pub fn drifted(&self) -> bool {
89        self.files
90            .iter()
91            .any(|f| f.state == STATE_STALE || f.state == STATE_MISSING)
92    }
93}
94
95/// `str(PurePosixPath(p))`: duplicate slashes and `.` components collapse,
96/// a trailing slash drops, `""` becomes `"."`; a leading `//` (exactly two
97/// slashes) is preserved, `/`+ otherwise collapses to one.
98pub fn py_path_str(p: &str) -> String {
99    let double_root = p.starts_with("//") && !p.starts_with("///");
100    let absolute = p.starts_with('/');
101    let comps: Vec<&str> = p.split('/').filter(|c| !c.is_empty() && *c != ".").collect();
102    let body = comps.join("/");
103    if absolute {
104        let root = if double_root { "//" } else { "/" };
105        format!("{root}{body}")
106    } else if body.is_empty() {
107        ".".to_string()
108    } else {
109        body
110    }
111}
112
113/// `PurePosixPath(a) / b` for a relative `b`, rendered as `str(...)`.
114fn py_path_join(a: &str, b: &str) -> String {
115    if a == "." {
116        py_path_str(b)
117    } else {
118        py_path_str(&format!("{a}/{b}"))
119    }
120}
121
122/// `_agent_rules_root(directory, out)` — explicit `--out` wins; else the
123/// parent of a `decisions/`-named directory (or `.` when that parent is `.`),
124/// else the directory itself.
125pub fn agent_rules_root(directory: &str, out: Option<&str>) -> String {
126    if let Some(o) = out {
127        return py_path_str(o);
128    }
129    let path = py_path_str(directory.trim_end_matches('/'));
130    let name = path.rsplit('/').next().unwrap_or("");
131    if name == "decisions" {
132        let parent = match path.rfind('/') {
133            Some(idx) if idx > 0 => &path[..idx],
134            Some(_) => "/",
135            None => ".",
136        };
137        if parent.is_empty() || parent == "." {
138            ".".to_string()
139        } else {
140            parent.to_string()
141        }
142    } else {
143        path
144    }
145}
146
147/// `artifact_status(product)` — first non-empty line of `## Status`.
148fn artifact_status(artifact: &crate::parse::Artifact) -> String {
149    artifact
150        .section("status")
151        .map(first_nonempty_line)
152        .unwrap_or("")
153        .to_string()
154}
155
156/// `_category(product)` — first non-empty line of `## Category`, or `None`.
157fn category(artifact: &crate::parse::Artifact) -> Option<String> {
158    let line = artifact
159        .section("category")
160        .map(first_nonempty_line)
161        .unwrap_or("");
162    if line.is_empty() {
163        None
164    } else {
165        Some(line.to_string())
166    }
167}
168
169/// `_is_live_decision` — Accepted and not spec-retired (ADR-067, ADR-051).
170fn is_live_decision(artifact: &crate::parse::Artifact) -> bool {
171    let status = py_casefold(&artifact_status(artifact));
172    if status != LIVE_STATUS {
173        return false;
174    }
175    let retired: Vec<String> = spec_for(DECISION_TYPE)
176        .map(|s| s.retired_status.iter().map(|r| py_casefold(r)).collect())
177        .unwrap_or_default();
178    !retired.contains(&status)
179}
180
181/// `build_agent_rules_block(directory)` → ordered entries + digest.
182fn build_projection(directory: &str) -> (Vec<AgentRulesEntry>, String) {
183    let mut entries: Vec<AgentRulesEntry> = Vec::new();
184    for item in corpus_items(directory, true) {
185        let Some(spec) = item.spec else { continue };
186        if spec.name != DECISION_TYPE || !is_live_decision(&item.artifact) {
187            continue;
188        }
189        let identifier = artifact_identifier(&item.artifact, item.spec, &item.path);
190        let title = match &item.artifact.product.title {
191            Some(t) if !t.is_empty() => t.clone(),
192            _ => identifier.clone(),
193        };
194        entries.push(AgentRulesEntry {
195            identifier,
196            title,
197            category: category(&item.artifact),
198        });
199    }
200    // Deterministic order: casefolded identifier, exact identifier tiebreak.
201    entries.sort_by(|a, b| {
202        py_casefold(&a.identifier)
203            .cmp(&py_casefold(&b.identifier))
204            .then_with(|| a.identifier.cmp(&b.identifier))
205    });
206    let payload: Vec<Value> = entries
207        .iter()
208        .map(|e| {
209            let mut m = Map::new();
210            m.insert("identifier".into(), json!(e.identifier));
211            m.insert("title".into(), json!(e.title));
212            m.insert("category".into(), json!(e.category));
213            Value::Object(m)
214        })
215        .collect();
216    let canonical = crate::pyjson::dumps_canonical_sorted(&Value::Array(payload));
217    let digest = crate::sha256::hexdigest(canonical.as_bytes());
218    (entries, digest)
219}
220
221/// `render_managed_block(projection)` — markers + distilled pointers, no
222/// trailing newline (the merge adds it).
223fn render_managed_block(entries: &[AgentRulesEntry], digest: &str) -> String {
224    let mut lines = vec![
225        format!("{BEGIN_PREFIX}{digest}{BEGIN_SUFFIX}"),
226        GENERATED_HEADER.to_string(),
227        "## Settled decisions (AsDecided)".to_string(),
228        String::new(),
229        "These decisions are already accepted. Do not re-open or contradict them; \
230         ask the AsDecided MCP tools (`get_artifact`, `search_artifacts`) for the \
231         full text before proposing a change that touches one."
232            .to_string(),
233        String::new(),
234    ];
235    if entries.is_empty() {
236        lines.push("_No live decisions recorded yet._".to_string());
237    } else {
238        for entry in entries {
239            let suffix = match &entry.category {
240                Some(c) => format!(" _({c})_"),
241                None => String::new(),
242            };
243            lines.push(format!(
244                "- **{}** \u{2014} {}{suffix}",
245                entry.identifier, entry.title
246            ));
247        }
248    }
249    lines.push(END_MARKER.to_string());
250    lines.join("\n")
251}
252
253/// `embedded_digest(file_text)` — the digest in the BEGIN marker, or `None`.
254fn embedded_digest(file_text: &str) -> Option<String> {
255    let start = file_text.find(BEGIN_PREFIX)?;
256    let after = start + BEGIN_PREFIX.len();
257    let end = after + file_text[after..].find(BEGIN_SUFFIX)?;
258    let digest = crate::pycompat::py_strip(&file_text[after..end]);
259    if digest.is_empty() {
260        None
261    } else {
262        Some(digest.to_string())
263    }
264}
265
266/// `merge_managed_block(existing, block)` — splice, preserving everything
267/// outside the markers; always ends with a single trailing newline.
268fn merge_managed_block(existing: Option<&str>, block: &str) -> String {
269    let existing = match existing {
270        None => return format!("{block}\n"),
271        Some(e) if crate::pycompat::py_strip(e).is_empty() => return format!("{block}\n"),
272        Some(e) => e,
273    };
274    let begin = existing.find(BEGIN_PREFIX);
275    let end = existing.find(END_MARKER);
276    if let (Some(begin), Some(end)) = (begin, end) {
277        if end > begin {
278            let end = end + END_MARKER.len();
279            let mut merged =
280                format!("{}{}{}", &existing[..begin], block, &existing[end..]);
281            if !merged.ends_with('\n') {
282                merged.push('\n');
283            }
284            return merged;
285        }
286    }
287    // No managed block yet: append one, separated by a blank line.
288    let body = existing.trim_end_matches('\n');
289    format!("{body}\n\n{block}\n")
290}
291
292/// `generate_agent_rules(directory, root, clients)` — write/update the
293/// managed block in each target under `root`. Writes are skipped when the
294/// embedded digest already matches (idempotent). An io error surfaces as
295/// `Err` for the caller's `cannot write under {root}` usage error.
296pub fn generate_agent_rules(
297    directory: &str,
298    root: &str,
299    clients: &[String],
300) -> Result<AgentRulesResult, String> {
301    let (entries, digest) = build_projection(directory);
302    let block = render_managed_block(&entries, &digest);
303
304    let mut files: Vec<AgentRulesFileResult> = Vec::new();
305    for target in targets_for(clients) {
306        let dest = py_path_join(root, target.path);
307        let dest_path = Path::new(&dest);
308        let existing = if dest_path.exists() {
309            // The oracle's strict-utf8 `read_text` would crash on invalid
310            // bytes; a healthy target file always decodes. An unreadable
311            // file degrades to the OSError path via the write below.
312            read_text_universal(&dest)
313        } else {
314            None
315        };
316
317        if let Some(text) = &existing {
318            if embedded_digest(text).as_deref() == Some(digest.as_str()) {
319                files.push(AgentRulesFileResult {
320                    client: target.client,
321                    path: target.path,
322                    state: STATE_IN_SYNC,
323                });
324                continue;
325            }
326        }
327
328        let merged = merge_managed_block(existing.as_deref(), &block);
329        if let Some(parent) = dest_path.parent() {
330            if !parent.as_os_str().is_empty() {
331                std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
332            }
333        }
334        std::fs::write(dest_path, merged).map_err(|e| e.to_string())?;
335        files.push(AgentRulesFileResult {
336            client: target.client,
337            path: target.path,
338            state: if existing.is_none() { STATE_WRITTEN } else { STATE_UPDATED },
339        });
340    }
341
342    Ok(AgentRulesResult {
343        mode: "generate",
344        digest,
345        root: root.to_string(),
346        files,
347    })
348}
349
350/// `check_agent_rules(directory, root, clients)` — never writes; compares
351/// each present target's embedded digest to the live projection.
352pub fn check_agent_rules(directory: &str, root: &str, clients: &[String]) -> AgentRulesResult {
353    let (_, digest) = build_projection(directory);
354
355    let mut files: Vec<AgentRulesFileResult> = Vec::new();
356    for target in targets_for(clients) {
357        let dest = py_path_join(root, target.path);
358        let state = if !Path::new(&dest).exists() {
359            STATE_MISSING
360        } else {
361            match read_text_universal(&dest).as_deref().and_then(embedded_digest) {
362                None => STATE_MISSING,
363                Some(d) if d == digest => STATE_IN_SYNC,
364                Some(_) => STATE_STALE,
365            }
366        };
367        files.push(AgentRulesFileResult {
368            client: target.client,
369            path: target.path,
370            state,
371        });
372    }
373
374    AgentRulesResult {
375        mode: "check",
376        digest,
377        root: root.to_string(),
378        files,
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn path_str_normalization() {
388        assert_eq!(py_path_str(""), ".");
389        assert_eq!(py_path_str("."), ".");
390        assert_eq!(py_path_str("./x"), "x");
391        assert_eq!(py_path_str("a//b/./c/"), "a/b/c");
392        assert_eq!(py_path_str("/a/b"), "/a/b");
393        assert_eq!(py_path_str("//a"), "//a");
394        assert_eq!(py_path_str("///a"), "/a");
395    }
396
397    #[test]
398    fn root_resolution() {
399        assert_eq!(agent_rules_root("decisions", None), ".");
400        assert_eq!(agent_rules_root("decisions/", None), ".");
401        assert_eq!(agent_rules_root("./decisions", None), ".");
402        assert_eq!(agent_rules_root("proj/decisions", None), "proj");
403        assert_eq!(agent_rules_root("proj/sub/decisions", None), "proj/sub");
404        assert_eq!(agent_rules_root("/abs/decisions", None), "/abs");
405        assert_eq!(agent_rules_root("corpus", None), "corpus");
406        assert_eq!(agent_rules_root("proj/decisions", Some("custom")), "custom");
407    }
408
409    #[test]
410    fn merge_rules() {
411        let block = "<!-- BEGIN RAC MANAGED BLOCK (digest: d) -->\nB\n<!-- END RAC MANAGED BLOCK -->";
412        assert_eq!(merge_managed_block(None, block), format!("{block}\n"));
413        assert_eq!(merge_managed_block(Some(""), block), format!("{block}\n"));
414        assert_eq!(
415            merge_managed_block(Some("prose\n"), block),
416            format!("prose\n\n{block}\n")
417        );
418        assert_eq!(
419            merge_managed_block(Some("prose without newline"), block),
420            format!("prose without newline\n\n{block}\n")
421        );
422        let seeded = format!("above\n\n{block}\nafter\n");
423        let updated = merge_managed_block(Some(&seeded), block);
424        assert_eq!(updated, seeded);
425    }
426
427    #[test]
428    fn embedded_digest_extraction() {
429        assert_eq!(embedded_digest("no block"), None);
430        assert_eq!(
431            embedded_digest("<!-- BEGIN RAC MANAGED BLOCK (digest: abc123) -->"),
432            Some("abc123".to_string())
433        );
434        assert_eq!(
435            embedded_digest("<!-- BEGIN RAC MANAGED BLOCK (digest:  ) -->"),
436            None
437        );
438    }
439}