Skip to main content

rac_engine/
export.rs

1//! Corpus export (`decided.services.export`) — deterministic viewer/graph/documents
2//! projections of a corpus. One walk, shared across projections; no timestamps.
3
4use crate::identity::{artifact_identifier, artifact_identifiers};
5use crate::markdown::split_frontmatter;
6use crate::parse::Artifact;
7use crate::pycompat::py_strip;
8use crate::relationships::{
9    corpus_items, edge_spec, relationships_from_corpus, CorpusItem,
10};
11use crate::spec::ArtifactSpec;
12use crate::validate::load_ticketing_provider;
13
14pub const EDGE_TYPE: &str = "relates-to";
15pub const STATUS_ABSENT: &str = "unknown";
16
17/// `_corpus_name(directory)`.
18fn corpus_name(directory: &str) -> String {
19    let trimmed = directory.trim_end_matches('/');
20    let name = trimmed.rsplit('/').next().unwrap_or("");
21    if name.is_empty() || name == "." || name == ".." {
22        directory.to_string()
23    } else {
24        name.to_string()
25    }
26}
27
28fn first_line(raw: &str) -> String {
29    for line in raw.split('\n') {
30        let s = py_strip(line);
31        if !s.is_empty() {
32            return s.to_string();
33        }
34    }
35    String::new()
36}
37
38/// `canonical_value(raw, allowed)`, on this module's `first_line`.
39fn canonical_value(raw: &str, allowed: &[String]) -> String {
40    crate::spec::canonical_value(&first_line(raw), allowed)
41}
42
43/// `_status(product, spec)`.
44fn status(artifact: &Artifact, spec: &ArtifactSpec) -> String {
45    let body = match artifact.section("status") {
46        Some(b) if !b.is_empty() => b,
47        _ => return STATUS_ABSENT.to_string(),
48    };
49    let allowed: &[String] = spec
50        .metadata
51        .iter()
52        .find(|(k, _)| k == "status")
53        .map(|(_, v)| v.as_slice())
54        .unwrap_or(&[]);
55    let value = canonical_value(body, allowed);
56    if value.is_empty() {
57        STATUS_ABSENT.to_string()
58    } else {
59        value
60    }
61}
62
63/// The Markdown body after the frontmatter envelope, re-read from disk.
64///
65/// The oracle re-reads in TEXT mode (`open(path, encoding="utf-8")`), which
66/// applies universal newlines — `\r\n` and lone `\r` become `\n` — before
67/// `split_frontmatter`. Mirror that here.
68///
69/// The oracle's text-mode read is also STRICT utf-8: a file with invalid
70/// bytes CRASHES the oracle uncaught (`UnicodeDecodeError`) even though the
71/// classification walk decoded it with `errors="replace"`. Per PORT-CONTRACT
72/// decision 3 this port never crashes; export has no per-artifact issue
73/// channel, so the divergence-by-design here is "the Rust export simply
74/// succeeds" (catalogued in rust/fuzz/pinned/oracle-crashes/).
75fn body_markdown(path: &str) -> String {
76    let text = crate::pycompat::read_text_universal(path).unwrap_or_default();
77    split_frontmatter(&text).body
78}
79
80fn tags_of(artifact: &Artifact) -> Vec<String> {
81    artifact
82        .metadata
83        .as_ref()
84        .map(|m| m.tags.clone())
85        .unwrap_or_default()
86}
87
88fn canonical_by_path(items: &[CorpusItem]) -> std::collections::HashMap<String, String> {
89    items
90        .iter()
91        .map(|it| {
92            (
93                it.path.clone(),
94                artifact_identifier(&it.artifact, it.spec, &it.path),
95            )
96        })
97        .collect()
98}
99
100// --- viewer JSON -------------------------------------------------------------
101
102pub struct ExportArtifact {
103    pub id: String,
104    pub aliases: Vec<String>,
105    pub artifact_type: String,
106    pub status: String,
107    pub title: String,
108    pub path: String,
109    pub body_html: String,
110    /// OKF-reserved descriptive labels (ADR-050): carried for the OKF
111    /// bundle projection, deliberately NOT in the viewer JSON (ADR-007).
112    pub tags: Vec<String>,
113}
114
115pub struct ExportRelationship {
116    pub from: String,
117    pub to: String,
118    pub edge_type: String,
119}
120
121pub struct CorpusExport {
122    pub corpus_name: String,
123    pub rac_version: String,
124    pub artifacts: Vec<ExportArtifact>,
125    pub relationships: Vec<ExportRelationship>,
126}
127
128impl CorpusExport {
129    pub fn artifact_count(&self) -> usize {
130        self.artifacts.len()
131    }
132}
133
134fn build_corpus_export_inner(
135    directory: &str,
136    rac_version: String,
137    include_body_html: bool,
138) -> CorpusExport {
139    let items = corpus_items(directory, true);
140    let canonical = canonical_by_path(&items);
141
142    let mut artifacts: Vec<ExportArtifact> = Vec::new();
143    for it in &items {
144        let Some(spec) = it.spec else { continue };
145        let canon = canonical[&it.path].clone();
146        let title = match &it.artifact.product.title {
147            Some(t) if !t.is_empty() => t.clone(),
148            _ => canon.clone(),
149        };
150        artifacts.push(ExportArtifact {
151            id: canon,
152            aliases: artifact_identifiers(&it.artifact, it.spec, &it.path),
153            artifact_type: spec.name.clone(),
154            status: status(&it.artifact, spec),
155            title,
156            path: it.path.clone(),
157            body_html: if include_body_html {
158                crate::mdhtml::render(&body_markdown(&it.path))
159            } else {
160                String::new()
161            },
162            tags: tags_of(&it.artifact),
163        });
164    }
165
166    let mut edges: Vec<ExportRelationship> = relationships_from_corpus(&items)
167        .into_iter()
168        .map(|rel| {
169            let to = match &rel.resolved_path {
170                Some(p) => canonical[p].clone(),
171                None => rel.target.clone(),
172            };
173            ExportRelationship {
174                from: canonical[&rel.source_path].clone(),
175                to,
176                edge_type: EDGE_TYPE.to_string(),
177            }
178        })
179        .collect();
180    edges.sort_by(|a, b| a.from.cmp(&b.from).then(a.to.cmp(&b.to)));
181
182    CorpusExport {
183        corpus_name: corpus_name(directory),
184        rac_version,
185        artifacts,
186        relationships: edges,
187    }
188}
189
190pub fn build_corpus_export(directory: &str, rac_version: String) -> CorpusExport {
191    build_corpus_export_inner(directory, rac_version, true)
192}
193
194/// OKF consumes the source Markdown body directly. Avoid an irrelevant HTML
195/// render over the whole corpus on this path.
196pub fn build_okf_export(directory: &str, rac_version: String) -> CorpusExport {
197    build_corpus_export_inner(directory, rac_version, false)
198}
199
200// --- documents JSONL ---------------------------------------------------------
201
202pub struct ExportDocument {
203    pub id: String,
204    pub artifact_type: String,
205    pub status: String,
206    pub title: String,
207    pub text: String,
208    pub aliases: Vec<String>,
209    pub path: String,
210    pub tags: Vec<String>,
211}
212
213pub struct DocumentsExport {
214    pub corpus_name: String,
215    pub documents: Vec<ExportDocument>,
216}
217
218pub fn build_documents_export(directory: &str) -> DocumentsExport {
219    let items = corpus_items(directory, true);
220    let mut documents: Vec<ExportDocument> = Vec::new();
221    for it in &items {
222        let Some(spec) = it.spec else { continue };
223        let canon = artifact_identifier(&it.artifact, it.spec, &it.path);
224        let title = match &it.artifact.product.title {
225            Some(t) if !t.is_empty() => t.clone(),
226            _ => canon.clone(),
227        };
228        documents.push(ExportDocument {
229            id: canon,
230            artifact_type: spec.name.clone(),
231            status: status(&it.artifact, spec),
232            title,
233            text: body_markdown(&it.path),
234            aliases: artifact_identifiers(&it.artifact, it.spec, &it.path),
235            path: it.path.clone(),
236            tags: tags_of(&it.artifact),
237        });
238    }
239    DocumentsExport {
240        corpus_name: corpus_name(directory),
241        documents,
242    }
243}
244
245// --- graph JSON --------------------------------------------------------------
246
247pub struct GraphNode {
248    pub id: String,
249    pub artifact_type: String,
250    pub status: String,
251    pub title: String,
252}
253
254pub struct GraphEdge {
255    pub source: String,
256    pub target: String,
257    pub edge_type: String,
258    pub directed: bool,
259    pub resolved: bool,
260    pub external: bool,
261    pub provider: Option<String>,
262}
263
264pub struct GraphExport {
265    pub corpus_name: String,
266    pub nodes: Vec<GraphNode>,
267    pub edges: Vec<GraphEdge>,
268}
269
270pub fn build_graph_export(directory: &str) -> GraphExport {
271    let items = corpus_items(directory, true);
272    let provider = load_ticketing_provider(directory);
273    let canonical = canonical_by_path(&items);
274
275    let mut nodes: Vec<GraphNode> = Vec::new();
276    for it in &items {
277        let Some(spec) = it.spec else { continue };
278        let canon = canonical[&it.path].clone();
279        let title = match &it.artifact.product.title {
280            Some(t) if !t.is_empty() => t.clone(),
281            _ => canon.clone(),
282        };
283        nodes.push(GraphNode {
284            id: canon,
285            artifact_type: spec.name.clone(),
286            status: status(&it.artifact, spec),
287            title,
288        });
289    }
290
291    let mut edges: Vec<GraphEdge> = Vec::new();
292    for rel in relationships_from_corpus(&items) {
293        let kind = edge_spec(&rel.relationship);
294        let external = kind.map(|k| k.external).unwrap_or(false);
295        let target = match &rel.resolved_path {
296            Some(p) => canonical[p].clone(),
297            None => rel.target.clone(),
298        };
299        let provider_tag = match kind {
300            Some(k) if k.external_provider => provider.clone(),
301            _ => None,
302        };
303        edges.push(GraphEdge {
304            source: canonical[&rel.source_path].clone(),
305            target,
306            edge_type: rel.relationship.clone(),
307            directed: kind.map(|k| k.directional).unwrap_or(false),
308            resolved: rel.resolved_path.is_some(),
309            external,
310            provider: provider_tag,
311        });
312    }
313    edges.sort_by(|a, b| {
314        a.source
315            .cmp(&b.source)
316            .then(a.edge_type.cmp(&b.edge_type))
317            .then(a.target.cmp(&b.target))
318    });
319
320    GraphExport {
321        corpus_name: corpus_name(directory),
322        nodes,
323        edges,
324    }
325}