Skip to main content

rac_engine/
okf.rs

1//! OKF bundle export (`decided.output.okf` + the recency join) — `decided export
2//! --okf`, per PORT-CONTRACT.d/17 §4.
3//!
4//! A derived tree of Markdown files: one per typed artifact at its path
5//! relative to the exported corpus root, plus generated `index.md` and
6//! `log.md`. OKF v0.2 `generated.at` derives from the last git commit time
7//! with the committer's stored offset preserved (`%cI`, ADR-045); when git
8//! cannot answer, `generated` is omitted and `log.md` degrades to a placeholder.
9
10use std::collections::BTreeMap;
11use std::collections::HashMap;
12use std::path::Path;
13
14use crate::export::{CorpusExport, ExportArtifact};
15use crate::gitinfo;
16use crate::markdown::split_frontmatter;
17use crate::pycompat::{py_relpath, py_strip, read_text_universal};
18
19/// RAC `type` → OKF `type` (`decided.core.okf.OKF_TYPE`, ADR-048).
20fn okf_type(rac_type: &str) -> &'static str {
21    match rac_type {
22        "requirement" => "Requirement",
23        "decision" => "ADR",
24        "design" => "Design",
25        "roadmap" => "Roadmap",
26        "prompt" => "Prompt",
27        // Unknown-type files are excluded from the export, so every
28        // exported artifact's type resolves (a KeyError would be an
29        // engine bug, not an input condition).
30        other => panic!("no OKF type mapping for {other:?}"),
31    }
32}
33
34/// Human plural headings for the index, in the fixed disclosure order.
35const INDEX_SECTIONS: [(&str, &str); 5] = [
36    ("requirement", "Requirements"),
37    ("decision", "Decisions"),
38    ("design", "Designs"),
39    ("roadmap", "Roadmaps"),
40    ("prompt", "Prompts"),
41];
42
43const INDEX_PATH: &str = "index.md";
44const LOG_PATH: &str = "log.md";
45
46/// One artifact's git-derived authored times as verbatim-offset ISO strings
47/// (already `fromisoformat().isoformat()` round-tripped), or `None` when git
48/// does not know. Mirrors `ArtifactRecency` with `with_creation=True`.
49pub struct ArtifactRecency {
50    pub path: String,
51    pub first_committed: Option<String>,
52    pub last_committed: Option<String>,
53}
54
55/// `_parse_stamp` fidelity gate: the oracle turns an unparseable stamp into
56/// `None`; mirror by validating before round-tripping.
57fn parsed(stamp: Option<String>) -> Option<String> {
58    let s = stamp?;
59    gitinfo::parse_iso8601_epoch(&s)?;
60    Some(gitinfo::isoformat_roundtrip(&s))
61}
62
63/// `artifact_recency(directory, with_creation=True)`, restricted to the
64/// export's artifact set (identical to the oracle's recognised-walk set).
65/// Outside a repository every value is `None` — no error crosses the
66/// boundary (ADR-045).
67pub fn artifact_recency(directory: &str, export: &CorpusExport) -> Vec<ArtifactRecency> {
68    let repo_root = gitinfo::repository_root(Path::new(directory));
69    export
70        .artifacts
71        .iter()
72        .map(|art| {
73            let (first, last) = match &repo_root {
74                None => (None, None),
75                Some(root) => (
76                    parsed(gitinfo::first_committed(root, Path::new(&art.path))),
77                    parsed(gitinfo::last_committed(root, Path::new(&art.path))),
78                ),
79            };
80            ArtifactRecency {
81                path: art.path.clone(),
82                first_committed: first,
83                last_committed: last,
84            }
85        })
86        .collect()
87}
88
89/// `_body(path)` — the Markdown body after the frontmatter envelope,
90/// re-read in text mode, stripped. The oracle's strict-utf8 read would
91/// crash on invalid bytes; this port degrades to an empty body
92/// (PORT-CONTRACT decision 3, same posture as the export body reader).
93fn body(path: &str) -> String {
94    let text = read_text_universal(path).unwrap_or_default();
95    py_strip(&split_frontmatter(&text).body).to_string()
96}
97
98fn yaml_string(value: &str) -> String {
99    serde_json::to_string(value).expect("serializing a Rust string cannot fail")
100}
101
102fn okf_status(status: &str) -> Option<&'static str> {
103    match status.trim().to_ascii_lowercase().as_str() {
104        "" | "unknown" => None,
105        "proposed" | "draft" => Some("draft"),
106        "retired" | "superseded" | "deprecated" | "obsolete" => Some("deprecated"),
107        _ => Some("stable"),
108    }
109}
110
111/// One v0.2 concept file. Structural relationships are navigation links, not
112/// provenance, so they deliberately do not populate `sources`.
113fn artifact_file(
114    art: &ExportArtifact,
115    related: &[(String, String)],
116    updated: Option<&str>,
117) -> String {
118    let mut lines = vec![
119        "---".to_string(),
120        format!("type: {}", okf_type(&art.artifact_type)),
121        format!("id: {}", yaml_string(&art.id)),
122        format!("title: {}", yaml_string(&art.title)),
123    ];
124    if let Some(status) = okf_status(&art.status) {
125        lines.push(format!("status: {status}"));
126    }
127    if let Some(updated) = updated {
128        lines.push("generated:".to_string());
129        lines.push(format!("  by: asdecided/{}", env!("CARGO_PKG_VERSION")));
130        lines.push(format!("  at: {updated}"));
131    }
132    if !art.tags.is_empty() {
133        lines.push(format!(
134            "tags: {}",
135            serde_json::to_string(&art.tags).expect("serializing tags cannot fail")
136        ));
137    }
138    lines.push("---".to_string());
139    lines.push(String::new());
140    lines.push(body(&art.path));
141    if !related.is_empty() {
142        lines.push(String::new());
143        lines.push("# Related concepts".to_string());
144        lines.push(String::new());
145        for (title, path) in related {
146            lines.push(format!("- [{title}]({path})"));
147        }
148    }
149    let mut out = lines.join("\n");
150    out.push('\n');
151    out
152}
153
154/// Resolved outgoing relationships
155/// as `(title, bundle path)` pairs, in relationship order.
156fn related_concepts(
157    art: &ExportArtifact,
158    export: &CorpusExport,
159    by_id: &HashMap<&str, &ExportArtifact>,
160    rel: &HashMap<&str, String>,
161) -> Vec<(String, String)> {
162    let mut pairs = Vec::new();
163    for edge in &export.relationships {
164        if edge.from != art.id {
165            continue;
166        }
167        if let Some(target) = by_id.get(edge.to.as_str()) {
168            pairs.push((target.title.clone(), rel[target.path.as_str()].clone()));
169        }
170    }
171    pairs
172}
173
174/// `_index(export, rel)` — overview line, then artifacts by type in the
175/// fixed section order (artifact order preserved within a section).
176fn index(export: &CorpusExport, rel: &HashMap<&str, String>) -> String {
177    let count = export.artifact_count();
178    let noun = if count == 1 { "artifact" } else { "artifacts" };
179    let mut lines = vec![
180        "---".to_string(),
181        "okf_version: \"0.2\"".to_string(),
182        "---".to_string(),
183        String::new(),
184        format!("# {} \u{2014} Knowledge Index", export.corpus_name),
185        String::new(),
186        format!(
187            "A derived OKF bundle of {count} {noun}. The AsDecided corpus is authoritative; \
188             this index is a generated entry point."
189        ),
190    ];
191    for (type_name, heading) in INDEX_SECTIONS {
192        let members: Vec<&ExportArtifact> = export
193            .artifacts
194            .iter()
195            .filter(|a| a.artifact_type == type_name)
196            .collect();
197        if members.is_empty() {
198            continue;
199        }
200        lines.push(String::new());
201        lines.push(format!("## {heading}"));
202        lines.push(String::new());
203        for art in members {
204            lines.push(format!("- [{}]({})", art.title, rel[art.path.as_str()]));
205        }
206    }
207    let mut out = lines.join("\n");
208    out.push('\n');
209    out
210}
211
212/// `_log(export, recency, rel)` — corpus history grouped by commit date
213/// (the `%cI` civil date, offset preserved), newest first; within a day,
214/// path order. No git history → the placeholder.
215fn log(
216    export: &CorpusExport,
217    recency: &[ArtifactRecency],
218    rel: &HashMap<&str, String>,
219) -> String {
220    let title_by_path: HashMap<&str, &str> = export
221        .artifacts
222        .iter()
223        .map(|a| (a.path.as_str(), a.title.as_str()))
224        .collect();
225    let mut dated: BTreeMap<String, Vec<&str>> = BTreeMap::new();
226    for a in recency {
227        let Some(committed) = &a.last_committed else { continue };
228        if !title_by_path.contains_key(a.path.as_str()) {
229            continue;
230        }
231        // `committed.date().isoformat()` — the stamp's stored civil date.
232        let day = committed.chars().take(10).collect::<String>();
233        dated.entry(day).or_default().push(a.path.as_str());
234    }
235    if dated.is_empty() {
236        return "# Log\n\n_No commit history available._\n".to_string();
237    }
238    let mut lines = vec!["# Log".to_string()];
239    for (day, paths) in dated.iter().rev() {
240        lines.push(String::new());
241        lines.push(format!("## {day}"));
242        lines.push(String::new());
243        let mut paths = paths.clone();
244        paths.sort_unstable();
245        for path in paths {
246            lines.push(format!("- [{}]({})", title_by_path[path], rel[path]));
247        }
248    }
249    let mut out = lines.join("\n");
250    out.push('\n');
251    out
252}
253
254/// `render_okf_bundle(export, recency, root)` — `{relative path: contents}`
255/// in a sorted map (the CLI writes `sorted(bundle.items())`).
256///
257/// `Err` mirrors the oracle's uncaught `ValueError` on an `index.md` /
258/// `log.md` filename collision (a Python traceback, exit 1 — normally
259/// prevented by the okf-reserved-filename validate gate).
260pub fn render_okf_bundle(
261    export: &CorpusExport,
262    recency: &[ArtifactRecency],
263    root: &str,
264) -> Result<BTreeMap<String, String>, String> {
265    let rel: HashMap<&str, String> = export
266        .artifacts
267        .iter()
268        .map(|a| (a.path.as_str(), py_relpath(&a.path, root)))
269        .collect();
270    // Dict-comprehension semantics: a duplicated id keeps the LAST artifact.
271    let mut by_id: HashMap<&str, &ExportArtifact> = HashMap::new();
272    for art in &export.artifacts {
273        by_id.insert(&art.id, art);
274    }
275    let recency_by_path: HashMap<&str, &ArtifactRecency> =
276        recency.iter().map(|a| (a.path.as_str(), a)).collect();
277
278    let mut files: BTreeMap<String, String> = BTreeMap::new();
279    for art in &export.artifacts {
280        let key = rel[art.path.as_str()].clone();
281        if key == INDEX_PATH || key == LOG_PATH {
282            return Err(format!(
283                "artifact path '{key}' collides with a generated bundle file"
284            ));
285        }
286        let record = recency_by_path.get(art.path.as_str());
287        let updated = record.and_then(|r| r.last_committed.as_deref());
288        files.insert(
289            key,
290            artifact_file(art, &related_concepts(art, export, &by_id, &rel), updated),
291        );
292    }
293    files.insert(INDEX_PATH.to_string(), index(export, &rel));
294    files.insert(LOG_PATH.to_string(), log(export, recency, &rel));
295    Ok(files)
296}