1use 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#[derive(Debug, Clone)]
23pub struct TopicMetadata {
24 pub title: String,
26 pub status: String,
28 pub count: usize,
30 pub sources: usize,
32 pub created: String,
34 pub survived: usize,
36 pub weakened: usize,
38 pub inconclusive: usize,
40 pub falsified: usize,
42 pub dim_bullets: String,
44 pub tags: String,
46 pub purpose: String,
48 pub key_draft: String,
50 pub by_dim_table: String,
52}
53
54fn escape_shell_single_quoted(value: &str) -> String {
55 format!("'{}'", value.replace('\'', "'\\''"))
56}
57
58impl TopicMetadata {
59 #[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
221fn 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
302pub 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}