Skip to main content

pounce_cli/
citations.rs

1//! Citation lister for `pounce --cite` (pounce#…): tells a user which
2//! papers / software to cite when they publish results obtained with
3//! pounce.
4//!
5//! Two tiers, per the agreed design:
6//!   * **static core** — always: pounce itself + Wächter-Biegler.
7//!   * **solve-aware extras** — when a solve-report JSON is supplied,
8//!     add papers for features the run actually used. In v1 the only
9//!     such signal carried by the report schema is the restoration
10//!     phase (`statistics.restoration_calls > 0`).
11//!
12//! Citation data is reused from `pounce_studio_core::glossary` (the same
13//! curated table that backs `pounce-studio citations` and the MCP tool),
14//! so there is a single source of truth rather than a CLI-local copy.
15
16use crate::solve_report::SolveReport;
17use pounce_studio_core::glossary::{citation_by_key, solve_feature_keys, Citation};
18
19/// A selected citation together with the short reason it was included,
20/// shown to the user as a "why cited" note in the human renderer.
21pub struct Selected {
22    pub citation: &'static Citation,
23    pub reason: &'static str,
24}
25
26/// Build the ordered, de-duplicated list of citations for this run.
27///
28/// `report` is the parsed `--cite <report.json>` if one was given.
29/// The static core is always first; solve-aware extras follow in the
30/// order their features were detected.
31pub fn select(report: Option<&SolveReport>) -> Vec<Selected> {
32    let mut out: Vec<Selected> = Vec::new();
33    let mut seen: Vec<&'static str> = Vec::new();
34
35    let push = |feature: &str,
36                reason: &'static str,
37                out: &mut Vec<Selected>,
38                seen: &mut Vec<&'static str>| {
39        let Some(keys) = solve_feature_keys(feature) else {
40            return;
41        };
42        for &k in keys {
43            if seen.contains(&k) {
44                continue;
45            }
46            if let Some(citation) = citation_by_key(k) {
47                seen.push(k);
48                out.push(Selected { citation, reason });
49            }
50        }
51    };
52
53    // Static core — every pounce run should cite these.
54    push(
55        "core",
56        "core solver (cite for any pounce result)",
57        &mut out,
58        &mut seen,
59    );
60
61    // Solve-aware extras.
62    if let Some(r) = report {
63        if r.statistics.restoration_calls > 0 {
64            push(
65                "restoration",
66                "the restoration phase was entered during this solve",
67                &mut out,
68                &mut seen,
69            );
70        }
71    }
72
73    out
74}
75
76/// Human-readable rendering: a short header followed by one stanza per
77/// citation with title, author, year, DOI, and the "why cited" note.
78pub fn render_human(selected: &[Selected]) -> String {
79    let mut s = String::new();
80    s.push_str("Please cite the following when publishing results obtained with pounce:\n");
81    for (i, sel) in selected.iter().enumerate() {
82        let c = sel.citation;
83        s.push_str(&format!("\n[{}] {}\n", i + 1, c.title));
84        s.push_str(&format!("    {} ({})\n", c.author, c.year));
85        if !c.venue.is_empty() {
86            s.push_str(&format!("    {}\n", c.venue));
87        }
88        if !c.doi.is_empty() {
89            s.push_str(&format!("    doi:{}\n", c.doi));
90        }
91        s.push_str(&format!("    — {}\n", sel.reason));
92    }
93    s
94}
95
96/// BibTeX rendering: one entry per citation, ready to paste into a
97/// `.bib` file. Fields are emitted only when non-empty.
98pub fn render_bibtex(selected: &[Selected]) -> String {
99    let mut s = String::new();
100    for (i, sel) in selected.iter().enumerate() {
101        let c = sel.citation;
102        if i > 0 {
103            s.push('\n');
104        }
105        s.push_str(&format!("@{}{{{},\n", c.entry_type, c.key));
106        s.push_str(&format!("  title = {{{}}},\n", c.title));
107        if !c.author.is_empty() {
108            s.push_str(&format!("  author = {{{}}},\n", c.author));
109        }
110        if !c.year.is_empty() {
111            s.push_str(&format!("  year = {{{}}},\n", c.year));
112        }
113        if !c.venue.is_empty() {
114            // `@article` expects `journal`; `howpublished` is an `@misc` field
115            // many styles ignore, which would silently drop a journal venue.
116            let venue_field = if c.entry_type == "article" {
117                "journal"
118            } else {
119                "howpublished"
120            };
121            s.push_str(&format!("  {venue_field} = {{{}}},\n", c.venue));
122        }
123        if !c.doi.is_empty() {
124            s.push_str(&format!("  doi = {{{}}},\n", c.doi));
125        }
126        s.push_str("}\n");
127    }
128    s
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::solve_report::{InputDescriptor, ReportBuilder, ReportDetail};
135
136    fn report(restoration_calls: i32) -> SolveReport {
137        let mut r = ReportBuilder::new(
138            ReportDetail::Summary,
139            InputDescriptor::Builtin {
140                name: "test".into(),
141            },
142        )
143        .finish();
144        r.statistics.restoration_calls = restoration_calls;
145        r
146    }
147
148    #[test]
149    fn core_always_present_without_report() {
150        let sel = select(None);
151        let keys: Vec<_> = sel.iter().map(|s| s.citation.key).collect();
152        assert!(keys.contains(&"pounce2026"));
153        assert!(keys.contains(&"wachter2006"));
154        // No solve report → no restoration paper.
155        assert!(!keys.contains(&"byrd2010"));
156    }
157
158    #[test]
159    fn restoration_adds_byrd_when_report_shows_it() {
160        let r = report(3);
161        let sel = select(Some(&r));
162        let keys: Vec<_> = sel.iter().map(|s| s.citation.key).collect();
163        assert!(keys.contains(&"pounce2026"));
164        assert!(keys.contains(&"byrd2010"));
165    }
166
167    #[test]
168    fn no_restoration_no_byrd() {
169        let r = report(0);
170        let sel = select(Some(&r));
171        let keys: Vec<_> = sel.iter().map(|s| s.citation.key).collect();
172        assert!(!keys.contains(&"byrd2010"));
173    }
174
175    #[test]
176    fn human_render_mentions_pounce_and_doi() {
177        let out = render_human(&select(None));
178        assert!(out.contains("POUNCE"));
179        assert!(out.contains("10.5281/zenodo.20387011"));
180    }
181
182    #[test]
183    fn bibtex_render_is_pasteable() {
184        let out = render_bibtex(&select(None));
185        assert!(out.contains("@software{pounce2026,"));
186        assert!(out.contains("@article{wachter2006,"));
187        assert!(out.contains("doi = {10.5281/zenodo.20387011}"));
188    }
189}