Skip to main content

mif_rh/
harness_topic_metadata.rs

1//! Topic README metadata rollup (rht Category B, Story #282).
2//!
3//! Ports the jq-dependent data computation from
4//! `scripts/build-topic-readme.sh`: the topic's registered title/status, a
5//! findings rollup (counts, verdicts, sources, dimensions, tags,
6//! earliest-created date, per-dimension counts, a draft "Key Findings"
7//! excerpt), a dimension bullet list, and a purpose line. Everything else
8//! in that script (file scanning, title/genre/version extraction from
9//! rendered deliverables, markdown table assembly, the structural check
10//! gate, the atomic write) stays pure bash — it never used jq.
11
12use std::collections::BTreeMap;
13use std::path::Path;
14
15use serde_json::Value;
16
17use crate::error::MifRhError;
18use crate::harness_project::read_json;
19
20/// Everything `build-topic-readme.sh` previously computed via `jq`, ready
21/// to be emitted as shell variable assignments.
22#[derive(Debug, Clone)]
23pub struct TopicMetadata {
24    /// The topic's registered title (falls back to the topic id).
25    pub title: String,
26    /// The topic's registered status (falls back to `"active"`).
27    pub status: String,
28    /// Total finding count.
29    pub count: usize,
30    /// Unique non-null citation URLs across every finding.
31    pub sources: usize,
32    /// The earliest non-null `created` value across findings, or empty.
33    pub created: String,
34    /// Count of findings with verdict `survived`.
35    pub survived: usize,
36    /// Count of findings with verdict `weakened`.
37    pub weakened: usize,
38    /// Count of findings with verdict `inconclusive`.
39    pub inconclusive: usize,
40    /// Count of findings with verdict `falsified`.
41    pub falsified: usize,
42    /// Pre-rendered `- **dim** — desc` bullet list (or `"—"` if empty).
43    pub dim_bullets: String,
44    /// Pre-rendered backtick-quoted tag list (or `"—"` if empty).
45    pub tags: String,
46    /// The goal statement, or a generic fallback naming the title.
47    pub purpose: String,
48    /// Pre-rendered `- <summary>` draft bullets (up to 8), survived-first.
49    pub key_draft: String,
50    /// Pre-rendered `| dim | count |` markdown table rows.
51    pub by_dim_table: String,
52}
53
54fn escape_shell_single_quoted(value: &str) -> String {
55    format!("'{}'", value.replace('\'', "'\\''"))
56}
57
58impl TopicMetadata {
59    /// Renders every field as a `NAME='value'` shell assignment, one per
60    /// line, suitable for `source <(...)` from bash.
61    #[must_use]
62    pub fn to_shell_script(&self) -> String {
63        let mut lines = Vec::new();
64        lines.push(format!("TITLE={}", escape_shell_single_quoted(&self.title)));
65        lines.push(format!(
66            "STATUS={}",
67            escape_shell_single_quoted(&self.status)
68        ));
69        lines.push(format!("COUNT={}", self.count));
70        lines.push(format!("SOURCES={}", self.sources));
71        lines.push(format!(
72            "CREATED={}",
73            escape_shell_single_quoted(&self.created)
74        ));
75        lines.push(format!("SURV={}", self.survived));
76        lines.push(format!("WEAK={}", self.weakened));
77        lines.push(format!("INC={}", self.inconclusive));
78        lines.push(format!("FALS={}", self.falsified));
79        lines.push(format!(
80            "DIM_BULLETS={}",
81            escape_shell_single_quoted(&self.dim_bullets)
82        ));
83        lines.push(format!("TAGS={}", escape_shell_single_quoted(&self.tags)));
84        lines.push(format!(
85            "PURPOSE={}",
86            escape_shell_single_quoted(&self.purpose)
87        ));
88        lines.push(format!(
89            "KEY_DRAFT={}",
90            escape_shell_single_quoted(&self.key_draft)
91        ));
92        lines.push(format!(
93            "BY_DIM_TABLE={}",
94            escape_shell_single_quoted(&self.by_dim_table)
95        ));
96        lines.join("\n")
97    }
98}
99
100fn unique_sorted_strings(values: impl Iterator<Item = Option<String>>) -> Vec<String> {
101    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
102    for value in values.flatten() {
103        set.insert(value);
104    }
105    set.into_iter().collect()
106}
107
108struct Roll {
109    count: usize,
110    sources: usize,
111    dimensions: Vec<String>,
112    tags: Vec<String>,
113    created: Option<String>,
114    verdicts: BTreeMap<String, usize>,
115    by_dim: Vec<(String, usize)>,
116    key: Vec<String>,
117}
118
119fn compute_roll(findings: &[Value]) -> Roll {
120    if findings.is_empty() {
121        return Roll {
122            count: 0,
123            sources: 0,
124            dimensions: Vec::new(),
125            tags: Vec::new(),
126            created: None,
127            verdicts: BTreeMap::new(),
128            by_dim: Vec::new(),
129            key: Vec::new(),
130        };
131    }
132
133    let sources = unique_sorted_strings(
134        findings
135            .iter()
136            .flat_map(|f| {
137                f.get("citations")
138                    .and_then(Value::as_array)
139                    .into_iter()
140                    .flatten()
141            })
142            .map(|c| c.get("url").and_then(Value::as_str).map(str::to_string)),
143    )
144    .len();
145
146    let dimensions = unique_sorted_strings(findings.iter().map(|f| {
147        f.pointer("/extensions/harness/dimension")
148            .and_then(Value::as_str)
149            .map(str::to_string)
150    }));
151
152    let tags = unique_sorted_strings(
153        findings
154            .iter()
155            .flat_map(|f| {
156                f.get("tags")
157                    .and_then(Value::as_array)
158                    .into_iter()
159                    .flatten()
160            })
161            .map(|t| t.as_str().map(str::to_string)),
162    );
163
164    let mut created_values: Vec<String> = findings
165        .iter()
166        .filter_map(|f| f.get("created").and_then(Value::as_str).map(str::to_string))
167        .collect();
168    created_values.sort();
169    let created = created_values.into_iter().next();
170
171    let verdicts = count_verdicts(findings);
172    let by_dim = count_by_dimension(findings);
173    let key = draft_key_findings(findings);
174
175    Roll {
176        count: findings.len(),
177        sources,
178        dimensions,
179        tags,
180        created,
181        verdicts,
182        by_dim,
183        key,
184    }
185}
186
187fn verdict_of(finding: &Value) -> &str {
188    finding
189        .pointer("/extensions/harness/verification/verdict")
190        .and_then(Value::as_str)
191        .unwrap_or("")
192}
193
194fn count_verdicts(findings: &[Value]) -> BTreeMap<String, usize> {
195    let mut verdicts: BTreeMap<String, usize> = BTreeMap::new();
196    for finding in findings {
197        let verdict = match verdict_of(finding) {
198            "" => "none",
199            v => v,
200        };
201        *verdicts.entry(verdict.to_string()).or_insert(0) += 1;
202    }
203    verdicts
204}
205
206fn count_by_dimension(findings: &[Value]) -> Vec<(String, usize)> {
207    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
208    for finding in findings {
209        let dim = finding
210            .pointer("/extensions/harness/dimension")
211            .and_then(Value::as_str)
212            .unwrap_or("unspecified")
213            .to_string();
214        *counts.entry(dim).or_insert(0) += 1;
215    }
216    let mut by_dim: Vec<(String, usize)> = counts.into_iter().collect();
217    by_dim.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
218    by_dim
219}
220
221/// The up-to-8 survived/weakened finding summaries seeding the "Key
222/// Findings" draft, survived-first (stable — ties preserve the findings'
223/// own sorted-filename order).
224fn draft_key_findings(findings: &[Value]) -> Vec<String> {
225    let mut candidates: Vec<&Value> = findings
226        .iter()
227        .filter(|f| matches!(verdict_of(f), "survived" | "weakened"))
228        .collect();
229    candidates.sort_by_key(|f| verdict_of(f) != "survived");
230    candidates
231        .into_iter()
232        .filter_map(|f| {
233            f.get("summary")
234                .and_then(Value::as_str)
235                .or_else(|| f.get("title").and_then(Value::as_str))
236        })
237        .map(str::to_string)
238        .take(8)
239        .collect()
240}
241
242fn render_dim_bullets(roll_dimensions: &[String], goal: &Value, config: &Value) -> String {
243    let goal_dimensions = goal
244        .get("dimensions")
245        .and_then(Value::as_array)
246        .into_iter()
247        .flatten()
248        .filter_map(|v| v.as_str().map(str::to_string));
249    let dims = unique_sorted_strings(
250        goal_dimensions
251            .chain(roll_dimensions.iter().cloned())
252            .map(Some),
253    );
254    if dims.is_empty() {
255        return "—".to_string();
256    }
257    let config_dims: &[Value] = config
258        .get("dimensions")
259        .and_then(Value::as_array)
260        .map_or(&[], Vec::as_slice);
261    dims.iter()
262        .map(|dim| {
263            let description = config_dims
264                .iter()
265                .find(|entry| {
266                    entry.get("id").and_then(Value::as_str) == Some(dim.as_str())
267                        || entry.get("name").and_then(Value::as_str) == Some(dim.as_str())
268                })
269                .and_then(|entry| entry.get("description"))
270                .and_then(Value::as_str);
271            match description {
272                Some(desc) if !desc.is_empty() => format!("- **{dim}** — {desc}"),
273                _ => format!("- **{dim}**"),
274            }
275        })
276        .collect::<Vec<_>>()
277        .join("\n")
278}
279
280fn render_tags(tags: &[String]) -> String {
281    if tags.is_empty() {
282        "—".to_string()
283    } else {
284        tags.iter()
285            .map(|t| format!("`{t}`"))
286            .collect::<Vec<_>>()
287            .join(" ")
288    }
289}
290
291fn render_purpose(goal: &Value, title: &str) -> String {
292    for key in ["goal_statement", "research_question", "goal", "question"] {
293        if let Some(value) = goal.get(key).and_then(Value::as_str)
294            && !value.is_empty()
295        {
296            return value.to_string();
297        }
298    }
299    format!("Research session for {title}.")
300}
301
302/// Computes a topic's README metadata rollup.
303///
304/// # Errors
305///
306/// Returns [`MifRhError::TopicNotRegistered`] if `topic` has no entry in
307/// `config_path`'s `topics[]`, or [`MifRhError::Io`]/[`MifRhError::Json`] if
308/// a finding file under `findings_dir` cannot be read or parsed.
309pub fn topic_metadata(
310    topic: &str,
311    config_path: &Path,
312    findings_dir: &Path,
313    goal_path: &Path,
314) -> Result<TopicMetadata, MifRhError> {
315    let config = read_json(config_path)?;
316    let topic_entry = config
317        .get("topics")
318        .and_then(Value::as_array)
319        .into_iter()
320        .flatten()
321        .find(|t| t.get("id").and_then(Value::as_str) == Some(topic))
322        .cloned()
323        .ok_or_else(|| MifRhError::TopicNotRegistered {
324            topic: topic.to_string(),
325            config_path: config_path.display().to_string(),
326        })?;
327    let title = topic_entry
328        .get("title")
329        .and_then(Value::as_str)
330        .map_or_else(|| topic.to_string(), str::to_string);
331    let status = topic_entry
332        .get("status")
333        .and_then(Value::as_str)
334        .unwrap_or("active")
335        .to_string();
336
337    let mut paths: Vec<_> = std::fs::read_dir(findings_dir)
338        .into_iter()
339        .flatten()
340        .flatten()
341        .map(|entry| entry.path())
342        .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
343        .collect();
344    paths.sort();
345    let findings: Vec<Value> = paths
346        .iter()
347        .map(|path| read_json(path))
348        .collect::<Result<Vec<_>, _>>()?;
349
350    let roll = compute_roll(&findings);
351    let goal = read_json(goal_path).unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
352
353    let dim_bullets = render_dim_bullets(&roll.dimensions, &goal, &config);
354    let tags = render_tags(&roll.tags);
355    let purpose = render_purpose(&goal, &title);
356    let key_draft = roll
357        .key
358        .iter()
359        .map(|line| format!("- {line}"))
360        .collect::<Vec<_>>()
361        .join("\n");
362    let by_dim_table = roll
363        .by_dim
364        .iter()
365        .map(|(dim, count)| format!("| {dim} | {count} |"))
366        .collect::<Vec<_>>()
367        .join("\n");
368
369    Ok(TopicMetadata {
370        title,
371        status,
372        count: roll.count,
373        sources: roll.sources,
374        created: roll.created.unwrap_or_default(),
375        survived: roll.verdicts.get("survived").copied().unwrap_or(0),
376        weakened: roll.verdicts.get("weakened").copied().unwrap_or(0),
377        inconclusive: roll.verdicts.get("inconclusive").copied().unwrap_or(0),
378        falsified: roll.verdicts.get("falsified").copied().unwrap_or(0),
379        dim_bullets,
380        tags,
381        purpose,
382        key_draft,
383        by_dim_table,
384    })
385}
386
387#[cfg(test)]
388mod tests {
389    use super::topic_metadata;
390    use std::fs;
391
392    fn setup(
393        dir: &std::path::Path,
394    ) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) {
395        let config_path = dir.join("harness.config.json");
396        fs::write(
397            &config_path,
398            r#"{"topics": [{"id": "t1", "title": "Topic One", "status": "active"}],
399                "dimensions": [{"id": "landscape", "description": "Market landscape"}]}"#,
400        )
401        .unwrap();
402        let findings_dir = dir.join("findings");
403        fs::create_dir_all(&findings_dir).unwrap();
404        let goal_path = dir.join("goal.json");
405        (config_path, findings_dir, goal_path)
406    }
407
408    #[test]
409    fn errors_when_the_topic_is_not_registered() {
410        let dir = tempfile::tempdir().unwrap();
411        let (config_path, findings_dir, goal_path) = setup(dir.path());
412
413        let error = topic_metadata("nope", &config_path, &findings_dir, &goal_path).unwrap_err();
414        assert!(matches!(
415            error,
416            super::MifRhError::TopicNotRegistered { .. }
417        ));
418    }
419
420    #[test]
421    fn computes_counts_and_falls_back_title_purpose_with_no_findings() {
422        let dir = tempfile::tempdir().unwrap();
423        let (config_path, findings_dir, goal_path) = setup(dir.path());
424
425        let meta = topic_metadata("t1", &config_path, &findings_dir, &goal_path).unwrap();
426        assert_eq!(meta.title, "Topic One");
427        assert_eq!(meta.status, "active");
428        assert_eq!(meta.count, 0);
429        assert_eq!(meta.sources, 0);
430        assert_eq!(meta.dim_bullets, "—");
431        assert_eq!(meta.tags, "—");
432        assert_eq!(meta.purpose, "Research session for Topic One.");
433        assert_eq!(meta.key_draft, "");
434    }
435
436    #[test]
437    fn rolls_up_verdicts_sources_and_key_findings() {
438        let dir = tempfile::tempdir().unwrap();
439        let (config_path, findings_dir, goal_path) = setup(dir.path());
440        fs::write(
441            findings_dir.join("f1.json"),
442            r#"{"summary": "Finding one survives", "created": "2026-01-02",
443                "citations": [{"url": "https://a"}, {"url": "https://a"}],
444                "tags": ["alpha", "beta"],
445                "extensions": {"harness": {"dimension": "landscape",
446                    "verification": {"verdict": "survived"}}}}"#,
447        )
448        .unwrap();
449        fs::write(
450            findings_dir.join("f2.json"),
451            r#"{"summary": "Finding two is falsified", "created": "2026-01-01",
452                "extensions": {"harness": {"dimension": "market",
453                    "verification": {"verdict": "falsified"}}}}"#,
454        )
455        .unwrap();
456
457        let meta = topic_metadata("t1", &config_path, &findings_dir, &goal_path).unwrap();
458        assert_eq!(meta.count, 2);
459        assert_eq!(meta.sources, 1);
460        assert_eq!(meta.survived, 1);
461        assert_eq!(meta.falsified, 1);
462        assert_eq!(meta.created, "2026-01-01");
463        assert_eq!(meta.tags, "`alpha` `beta`");
464        assert_eq!(
465            meta.dim_bullets,
466            "- **landscape** — Market landscape\n- **market**"
467        );
468        assert_eq!(meta.key_draft, "- Finding one survives");
469    }
470
471    #[test]
472    fn purpose_prefers_the_goal_statement_over_the_fallback() {
473        let dir = tempfile::tempdir().unwrap();
474        let (config_path, findings_dir, goal_path) = setup(dir.path());
475        fs::write(
476            &goal_path,
477            r#"{"goal_statement": "Understand the market."}"#,
478        )
479        .unwrap();
480
481        let meta = topic_metadata("t1", &config_path, &findings_dir, &goal_path).unwrap();
482        assert_eq!(meta.purpose, "Understand the market.");
483    }
484
485    #[test]
486    fn shell_script_escapes_embedded_single_quotes() {
487        let dir = tempfile::tempdir().unwrap();
488        fs::write(
489            dir.path().join("harness.config.json"),
490            r#"{"topics": [{"id": "t1", "title": "Bob's Topic"}]}"#,
491        )
492        .unwrap();
493        let findings_dir = dir.path().join("findings");
494        fs::create_dir_all(&findings_dir).unwrap();
495
496        let meta = topic_metadata(
497            "t1",
498            &dir.path().join("harness.config.json"),
499            &findings_dir,
500            &dir.path().join("goal.json"),
501        )
502        .unwrap();
503        let script = meta.to_shell_script();
504        assert!(script.contains(r"TITLE='Bob'\''s Topic'"));
505    }
506}