1use 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
17fn 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
38fn canonical_value(raw: &str, allowed: &[String]) -> String {
40 crate::spec::canonical_value(&first_line(raw), allowed)
41}
42
43fn 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
63fn 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
100pub 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 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
194pub fn build_okf_export(directory: &str, rac_version: String) -> CorpusExport {
197 build_corpus_export_inner(directory, rac_version, false)
198}
199
200pub 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
245pub 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}