Skip to main content

nexus_core/app/
export.rs

1//! `/export`: write a research session's latest report + bibliography to a
2//! markdown file in the active space's files dir, overwritten on every run
3//! so it stays a living document.
4
5use anyhow::Result;
6use std::fmt::Write as _;
7
8/// Append a numbered `## Sources` bibliography built from `citations`
9/// (`(report_file, url, title)` rows, as returned by `Db::search_citations`)
10/// to `report_body`. No section at all when `citations` is empty — an
11/// export with nothing to cite shouldn't show an empty heading.
12pub 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    /// `/export`: write the active session's latest research report (the
30    /// most recent `assistant` message) plus its citations to
31    /// `<space>/files/reports/<session-slug>.md`, overwriting any earlier
32    /// export of the same session. No-op with a status message if the
33    /// session has no research report yet.
34    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}