1use 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
30pub 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
44fn 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
56struct AgentRulesEntry {
58 identifier: String,
59 title: String,
60 category: Option<String>,
61}
62
63pub 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
76pub struct AgentRulesResult {
78 pub mode: &'static str,
80 pub digest: String,
81 pub root: String,
83 pub files: Vec<AgentRulesFileResult>,
84}
85
86impl AgentRulesResult {
87 pub fn drifted(&self) -> bool {
89 self.files
90 .iter()
91 .any(|f| f.state == STATE_STALE || f.state == STATE_MISSING)
92 }
93}
94
95pub 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
113fn 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
122pub 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
147fn 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
156fn 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
169fn 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
181fn 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 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
221fn 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
253fn 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
266fn 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 let body = existing.trim_end_matches('\n');
289 format!("{body}\n\n{block}\n")
290}
291
292pub 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 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
350pub 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}