1use anyhow::Result;
6use std::fmt::Write as _;
7
8pub fn assemble_report(report_body: &str, citations: &[(String, String, String)]) -> String {
13 if citations.is_empty() {
14 return report_body.to_string();
15 }
16 let mut out = report_body.trim_end().to_string();
17 out.push_str("\n\n## Sources\n\n");
18 for (i, (_, url, title)) in citations.iter().enumerate() {
19 if title.is_empty() {
20 let _ = writeln!(out, "{}. {url}", i + 1);
21 } else {
22 let _ = writeln!(out, "{}. {title} — {url}", i + 1);
23 }
24 }
25 out
26}
27
28impl super::App {
29 pub fn export_report(&mut self) -> Result<()> {
35 let Some(session) = &self.session else {
36 self.push_status("no active session".to_string());
37 return Ok(());
38 };
39 let Some(report) = self
40 .messages
41 .iter()
42 .rev()
43 .find(|m| m.role == "assistant")
44 .map(|m| m.content.clone())
45 else {
46 self.push_status("nothing to export — no assistant reply yet".to_string());
47 return Ok(());
48 };
49 let citations = self.db.search_citations(&self.active_space.id, None)?;
50 let cited_here: Vec<(String, String, String)> = {
51 let urls_in_report: std::collections::HashSet<String> =
52 crate::citations::parse_citations(&report)
53 .into_iter()
54 .map(|(_, url)| url)
55 .collect();
56 citations
57 .into_iter()
58 .filter(|(_, url, _)| urls_in_report.contains(url))
59 .collect()
60 };
61 let assembled = assemble_report(&report, &cited_here);
62 let dir = self
63 .space
64 .files_dir(&self.active_space.name)
65 .join("reports");
66 std::fs::create_dir_all(&dir)?;
67 let slug = super::sessions::slugify(&session.title);
68 let path = dir.join(format!("{slug}.md"));
69 std::fs::write(&path, assembled)?;
70 self.push_status(format!("exported to {}", path.display()));
71 Ok(())
72 }
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78
79 #[test]
80 fn assemble_report_appends_numbered_bibliography() {
81 let citations = vec![
82 (
83 "report-a.md".to_string(),
84 "https://a.example".to_string(),
85 "Title A".to_string(),
86 ),
87 (
88 "report-a.md".to_string(),
89 "https://b.example".to_string(),
90 String::new(),
91 ),
92 ];
93 let out = assemble_report("# Report\nBody [1] [2].", &citations);
94 assert!(out.contains("## Sources"));
95 assert!(out.contains("1. Title A — https://a.example"));
96 assert!(out.contains("https://b.example"));
97 assert!(out.starts_with("# Report"));
98 }
99
100 #[test]
101 fn assemble_report_with_no_citations_has_no_sources_section() {
102 let out = assemble_report("# Report\nBody.", &[]);
103 assert!(!out.contains("## Sources"));
104 }
105}