1use 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
19fn 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 other => panic!("no OKF type mapping for {other:?}"),
31 }
32}
33
34const 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
46pub struct ArtifactRecency {
50 pub path: String,
51 pub first_committed: Option<String>,
52 pub last_committed: Option<String>,
53}
54
55fn parsed(stamp: Option<String>) -> Option<String> {
58 let s = stamp?;
59 gitinfo::parse_iso8601_epoch(&s)?;
60 Some(gitinfo::isoformat_roundtrip(&s))
61}
62
63pub 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
89fn 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
111fn 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
154fn 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
174fn 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
212fn 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 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
254pub 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 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}