Skip to main content

vissue_core/
report.rs

1//! Read-only verbs. Every function returns its text instead of printing, so a
2//! CLI, an MCP server, and a library caller share one implementation.
3
4use anyhow::anyhow;
5
6use crate::error::{Error, Result};
7use chrono::{Local, NaiveDate};
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::fmt::Write as _;
10
11use crate::catalog::{CatalogService, load_recs};
12use crate::config::Layout;
13use crate::graph::DependencyGraph;
14use crate::model::{IssueHeading, READY_STATES};
15pub use crate::related::related;
16use crate::store::{IssueDoc, find_by_id, find_org_ids, list_projects, load_all, project_selected};
17use crate::views::{IssueRec, IssueRow, ListQuery};
18
19struct GraphIndex<'a> {
20    by_id: HashMap<&'a str, &'a IssueHeading>,
21    children: HashMap<&'a str, Vec<&'a str>>,
22    blockers: HashMap<&'a str, Vec<&'a str>>,
23}
24
25impl<'a> GraphIndex<'a> {
26    fn new(all: &'a [(String, IssueHeading)]) -> Self {
27        let mut index = Self {
28            by_id: HashMap::with_capacity(all.len()),
29            children: HashMap::new(),
30            blockers: HashMap::new(),
31        };
32        for (_, h) in all {
33            index.by_id.insert(h.id.as_str(), h);
34        }
35        for (_, h) in all {
36            if let Some(parent) = h.parent() {
37                index
38                    .children
39                    .entry(parent)
40                    .or_default()
41                    .push(h.id.as_str());
42            }
43            let blockers = blocker_ids(h);
44            if !blockers.is_empty() {
45                index.blockers.insert(h.id.as_str(), blockers);
46            }
47        }
48        for children in index.children.values_mut() {
49            children.sort_unstable();
50        }
51        index
52    }
53}
54
55fn blocker_ids(h: &IssueHeading) -> Vec<&str> {
56    let mut ids = Vec::new();
57    if let Some(raw) = crate::props::get(&h.properties, crate::props::BLOCKED_BY) {
58        ids.extend(
59            raw.split(|c: char| c == ',' || c.is_whitespace())
60                .map(str::trim)
61                .filter(|id| !id.is_empty()),
62        );
63    }
64    if let Some(raw) = h.properties.get("BLOCKER") {
65        if crate::org::is_edna_blocker(raw) {
66            ids.extend(crate::org::edna_blocker_id_refs(raw));
67        } else {
68            ids.extend(
69                raw.split(|c: char| c == ',' || c.is_whitespace())
70                    .map(str::trim)
71                    .filter(|id| !id.is_empty()),
72            );
73        }
74    }
75    let mut unique = Vec::new();
76    for id in ids {
77        if !unique.contains(&id) {
78            unique.push(id);
79        }
80    }
81    unique
82}
83
84/// One row per issue: id, state, priority cookie, title.
85///
86/// # Errors
87///
88/// Returns an error if the corpus cannot be read.
89pub fn list(
90    layout: &Layout,
91    project_filter: Option<&str>,
92    state_filter: Option<&str>,
93    ready_only: bool,
94) -> Result<String> {
95    let recs = load_recs(layout)?;
96    let rows = CatalogService::from_recs(&recs).issues_rows(ListQuery {
97        project: project_filter.map(str::to_string),
98        state: state_filter.map(str::to_string),
99        ready: ready_only,
100        ..ListQuery::default()
101    })?;
102    Ok(format_issue_rows(&recs, &rows))
103}
104
105fn format_issue_rows(recs: &[IssueRec], rows: &[IssueRow]) -> String {
106    // Indexed once. Looking each row's record up by scanning the corpus made
107    // rendering quadratic in the number of issues, which is the whole cost of
108    // `list` on a large tracker and none of the work it is there to do.
109    let by_id: std::collections::HashMap<&str, &IssueRec> = recs
110        .iter()
111        .map(|rec| (rec.heading.id.as_str(), rec))
112        .collect();
113    let mut out = String::new();
114    for row in rows {
115        let suffix = by_id
116            .get(row.id.as_str())
117            .map(|r| claim_suffix(&r.heading))
118            .unwrap_or_default();
119        let _ = writeln!(
120            out,
121            "{:<22} {:<9} [#{}]  {}{}",
122            row.id, row.state, row.priority, row.title, suffix
123        );
124    }
125    out
126}
127
128/// ` (claimed 3d by <identity>)`, or nothing when no one holds the issue.
129/// Only a claimed issue grows the suffix, so an unclaimed corpus renders
130/// exactly as it did before claims existed.
131pub(crate) fn claim_suffix(h: &IssueHeading) -> String {
132    let Some(who) = h.claimed_by() else {
133        return String::new();
134    };
135    match h.claim_age_days(Local::now().date_naive()) {
136        Some(days) => format!("  (claimed {days}d by {who})"),
137        None => format!("  (claimed by {who})"),
138    }
139}
140
141/// Actionable issues: TODO or STARTED with no open blocker.
142///
143/// # Errors
144///
145/// Returns an error if the corpus cannot be read.
146pub fn ready(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
147    let recs = load_recs(layout)?;
148    let rows = CatalogService::from_recs(&recs).ready(project_filter)?;
149    Ok(format_issue_rows(&recs, &rows))
150}
151
152/// One issue's metadata, file range, and body text.
153///
154/// # Errors
155///
156/// Returns an error if the corpus cannot be read, or `id` is not in it.
157pub fn show(layout: &Layout, id: &str) -> Result<String> {
158    let (h, path, project) =
159        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
160    let mut out = String::new();
161    writeln!(out, "ID:       {}", h.id)?;
162    writeln!(out, "Project:  {project}")?;
163    writeln!(out, "Title:    {}", h.title)?;
164    writeln!(out, "State:    {}", h.state)?;
165    writeln!(out, "Priority: [#{}]", h.priority)?;
166    if let Some(who) = h.claimed_by() {
167        match h.claim_age_days(Local::now().date_naive()) {
168            Some(days) => writeln!(
169                out,
170                "Claimed:  {who} since {} ({days}d)",
171                h.claimed_at().unwrap_or("?")
172            )?,
173            None => writeln!(out, "Claimed:  {who}")?,
174        }
175    }
176    let settings = crate::org::tag_settings_from_preamble(
177        &IssueDoc::parse_file(&project, &path)
178            .map(|d| d.preamble)
179            .unwrap_or_default(),
180    );
181    let tags = settings.all_tags(&h.tags());
182    if !tags.is_empty() {
183        writeln!(out, "Tags:     {}", tags.join(", "))?;
184    }
185    if h.properties.iter().any(|(k, _)| k != "ID") {
186        writeln!(out, "Properties:")?;
187        for (k, v) in &h.properties {
188            if k == "ID" {
189                continue;
190            }
191            writeln!(out, "  {k}: {v}")?;
192        }
193    }
194    writeln!(
195        out,
196        "File:     {}:{}-{}",
197        path.display(),
198        h.line_start,
199        h.line_end
200    )?;
201    writeln!(out)?;
202    // The body is what the issue actually asks for, so printing the file
203    // range and stopping leaves every reader to go fetch it by hand.
204    let body = h.body.trim_end();
205    if body.is_empty() {
206        writeln!(out, "(no body; edit the range above to add one)")?;
207    } else {
208        writeln!(out, "Body:")?;
209        writeln!(out, "{body}")?;
210    }
211    Ok(out)
212}
213
214/// What a plan's children hold, child by child.
215///
216/// Deliberately not a number. The design note that settled this is in the
217/// vault; the short version is that no weighting over children can be picked
218/// without a judgement the tracker has no basis for, a child that settled split
219/// has no single position to fold in, and a child nobody voted on is absent
220/// rather than neutral. Rolling those into one figure would hide exactly the
221/// rows a person has to go read.
222///
223/// # Errors
224///
225/// Returns an error if the corpus cannot be read, `id` is not in it, or the
226/// configuration names a weight the iteration cannot use.
227pub fn plan_consensus(layout: &Layout, id: &str) -> Result<String> {
228    let roll = crate::consensus::of_plan(layout, id)?;
229    let mut out = String::new();
230    writeln!(out, "{}  {}", roll.plan, roll.title)?;
231    if roll.children.is_empty() {
232        writeln!(out, "  no children: nothing to roll up")?;
233        return Ok(out);
234    }
235
236    let voted = roll.children.iter().filter(|c| c.ballots > 0).count();
237    writeln!(
238        out,
239        "  {} child{}, {voted} with ballots",
240        roll.children.len(),
241        if roll.children.len() == 1 { "" } else { "ren" }
242    )?;
243    for child in &roll.children {
244        let held = match (&child.holds, child.settling) {
245            (Some((choice, share)), _) => format!("{choice} {share:.3}"),
246            (None, Some(crate::consensus::Settling::Split)) => "split".to_string(),
247            (None, Some(crate::consensus::Settling::Oscillating)) => "never settles".to_string(),
248            (None, Some(_)) => "no lead".to_string(),
249            (None, None) => "no ballots".to_string(),
250        };
251        writeln!(
252            out,
253            "    {:<22} {:<9} {:<16} {}",
254            child.id, child.state, held, child.title
255        )?;
256    }
257
258    let positions = roll.positions();
259    match positions.len() {
260        0 => writeln!(out, "  nothing holds a position yet")?,
261        1 => writeln!(
262            out,
263            "  the children that were voted on all hold {}",
264            positions[0]
265        )?,
266        n => writeln!(
267            out,
268            "  the children disagree with each other: {n} positions ({})",
269            positions.join(", ")
270        )?,
271    }
272    let split = roll.split();
273    if !split.is_empty() {
274        writeln!(
275            out,
276            "  {} child(ren) settled split and need a person: {}",
277            split.len(),
278            split
279                .iter()
280                .map(|c| c.id.as_str())
281                .collect::<Vec<_>>()
282                .join(", ")
283        )?;
284    }
285    let unvoted = roll.unvoted();
286    if !unvoted.is_empty() {
287        // Named rather than counted into an average. Most work is done rather
288        // than argued over, so this is the common row and folding it in as a
289        // neutral vote would make the plan's position mostly fiction.
290        writeln!(out, "  {} child(ren) carry no ballots", unvoted.len())?;
291    }
292    Ok(out)
293}
294
295/// The working set for one issue: the plan around it, the deeds its declared
296/// inputs produced, and what it has produced itself.
297///
298/// This is the layer between the task graph and the work: the tracker already
299/// records what a node waits on, so what an agent should open before starting is
300/// derivable rather than searchable. Nothing is ranked and nothing is embedded.
301/// A neighbourhood by resemblance is a different question and `related` answers
302/// it.
303///
304/// # Errors
305///
306/// Returns an error if the corpus cannot be read, `id` is not in it, or the
307/// blocker graph cannot be built.
308pub fn recall(layout: &Layout, id: &str, depth: usize, excerpts: bool) -> Result<String> {
309    let set = CatalogService::from_recs(&load_recs(layout)?).recall(id, depth, excerpts)?;
310    let mut out = String::new();
311    writeln!(
312        out,
313        "{:<22} {:<9} {}  ({})",
314        set.id, set.state, set.title, set.project
315    )?;
316
317    if !set.plan.is_empty() {
318        writeln!(out, "\nPlan")?;
319        for step in &set.plan {
320            writeln!(out, "  {:<22} {:<9} {}", step.id, step.state, step.title)?;
321        }
322    }
323
324    writeln!(out, "\nInputs")?;
325    if set.inputs.is_empty() {
326        writeln!(
327            out,
328            "  (none declared: nothing blocks this and it was not bounced)"
329        )?;
330    }
331    for input in &set.inputs {
332        writeln!(
333            out,
334            "  {:<22} {:<9} {}  [{}]",
335            input.id, input.state, input.title, input.relation
336        )?;
337        if input.deeds.is_empty() {
338            // Said rather than left blank. An input that produced nothing is the
339            // case where this view has nothing to hand over, and a silent gap
340            // reads as though the walk missed it.
341            writeln!(out, "    (no deeds cited)")?;
342        }
343        for deed in &input.deeds {
344            writeln!(out, "    {deed}")?;
345        }
346        if let Some(excerpt) = &input.excerpt {
347            // Indented under its input, so a working set carrying several
348            // stays readable as a list rather than running together.
349            for line in excerpt.lines() {
350                writeln!(out, "      {line}")?;
351            }
352        }
353        if let Some(note) = &input.last_note {
354            // The last thing said about an input is what a reader falls back on
355            // when it named no product.
356            writeln!(
357                out,
358                "    note: {}",
359                note.lines().next().unwrap_or_default().trim()
360            )?;
361        }
362    }
363
364    writeln!(out, "\nProduced")?;
365    if set.produced.is_empty() {
366        writeln!(out, "  (nothing cited yet)")?;
367    }
368    for deed in &set.produced {
369        writeln!(out, "  {deed}")?;
370    }
371
372    writeln!(out, "\nBody")?;
373    if set.body.is_empty() {
374        writeln!(out, "  (no body)")?;
375    } else {
376        for line in set.body.lines() {
377            writeln!(out, "  {line}")?;
378        }
379    }
380    Ok(out)
381}
382
383/// Just the deed accessions [`recall`] found, inputs first, one per line.
384///
385/// The form a shell substitutes: `deedar get $(vissue recall <id> --deeds-only)`
386/// opens the working set without a parser in between.
387///
388/// # Errors
389///
390/// Same as [`recall`].
391pub fn recall_deeds(layout: &Layout, id: &str, depth: usize) -> Result<String> {
392    let set = CatalogService::from_recs(&load_recs(layout)?).recall(id, depth, false)?;
393    let mut out = String::new();
394    // One line per deed even when two nodes cite the same one, which happens
395    // whenever work continues on the product it was handed. The consumer is a
396    // shell substitution, so a repeat would fetch or check the same deed twice.
397    let mut seen: HashSet<&str> = HashSet::new();
398    for deed in set
399        .inputs
400        .iter()
401        .flat_map(|i| i.deeds.iter())
402        .chain(set.produced.iter())
403    {
404        if seen.insert(deed.as_str()) {
405            writeln!(out, "{deed}")?;
406        }
407    }
408    Ok(out)
409}
410
411/// The DeGroot consensus over an issue's ballots, weighted by who the group
412/// listens to.
413///
414/// `vote` counts; this weighs. Both are printed, because the useful thing about
415/// the weighted answer is where it differs from the count, and a reader shown
416/// only one of them cannot tell whether the trust configuration did anything.
417///
418/// # Errors
419///
420/// Returns an error if the corpus cannot be read, `id` is not in it, or the
421/// configuration names a weight the iteration cannot use.
422pub fn consensus(layout: &Layout, id: &str) -> Result<String> {
423    let ballots = crate::ops::ballots(layout, id)?;
424    let outcome = crate::consensus::of_issue(layout, id)?;
425    Ok(consensus_text(id, &ballots, &outcome))
426}
427
428fn consensus_text(
429    id: &str,
430    ballots: &[crate::ops::Ballot],
431    outcome: &crate::consensus::Outcome,
432) -> String {
433    use crate::consensus::{Settling, TrustSource};
434
435    if ballots.is_empty() {
436        return format!("{id}: no votes\n");
437    }
438    let mut out = format!(
439        "{id}: {} ballot{} over {} option{}, trust {}\n",
440        ballots.len(),
441        if ballots.len() == 1 { "" } else { "s" },
442        outcome.choices.len(),
443        if outcome.choices.len() == 1 { "" } else { "s" },
444        match outcome.trust {
445            TrustSource::Default => "default (equal weight)",
446            TrustSource::Configured => "configured",
447        }
448    );
449
450    let counts = crate::consensus::tally(ballots);
451    let mut ranked: Vec<(&String, &Vec<String>)> = counts.iter().collect();
452    ranked.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0)));
453    let _ = writeln!(out, "  count");
454    for (choice, who) in &ranked {
455        let _ = writeln!(out, "    {:<24} {} ({})", choice, who.len(), who.join(", "));
456    }
457
458    match outcome.settling {
459        Settling::Agreed => {
460            let consensus = outcome.consensus.as_ref().expect("agreed carries a limit");
461            let mut shares: Vec<(&str, f64)> = outcome
462                .choices
463                .iter()
464                .map(String::as_str)
465                .zip(consensus.iter().copied())
466                .collect();
467            shares.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(b.0)));
468            let _ = writeln!(
469                out,
470                "  consensus after {} round(s){}",
471                outcome.rounds,
472                if outcome.budget_reached {
473                    ", which is the whole budget: the shares are an estimate"
474                } else {
475                    ""
476                }
477            );
478            for (choice, share) in &shares {
479                let _ = writeln!(out, "    {choice:<24} {share:.3}");
480            }
481            let mut power: Vec<(&str, f64)> = outcome
482                .agents
483                .iter()
484                .map(|a| (a.agent.as_str(), a.power.unwrap_or_default()))
485                .collect();
486            power.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(b.0)));
487            let _ = writeln!(out, "  social power");
488            for (agent, weight) in &power {
489                let _ = writeln!(out, "    {agent:<24} {weight:.3}");
490            }
491            match outcome.leader() {
492                // One agent agreeing with itself is not agreement, and the
493                // weighted answer is exactly as unchecked as the count was.
494                Some(_) if ballots.len() < 2 => {
495                    let _ = writeln!(
496                        out,
497                        "  one ballot only: {}, which nobody has agreed with yet",
498                        ranked[0].0
499                    );
500                }
501                Some((choice, share)) => {
502                    let _ = writeln!(out, "  holds: {choice} ({share:.3} of the group's weight)");
503                    if ranked[0].0 != choice {
504                        // The whole reason to weigh rather than count.
505                        let _ = writeln!(
506                            out,
507                            "  the count leads with {} and the group's weight does not",
508                            ranked[0].0
509                        );
510                    }
511                }
512                None => {
513                    let _ = writeln!(
514                        out,
515                        "  no lead: the group's weight is split evenly across the options"
516                    );
517                }
518            }
519        }
520        Settling::Split => {
521            let _ = writeln!(
522                out,
523                "  no consensus: the trust graph holds {} group(s) that do not listen to each other",
524                outcome.factions.len()
525            );
526            for faction in &outcome.factions {
527                // What a group settled on is the actionable half of a split.
528                // The members share a limit, so the first of them speaks for
529                // the group.
530                let held = faction
531                    .first()
532                    .and_then(|who| outcome.agents.iter().find(|a| a.agent == *who))
533                    .and_then(|row| {
534                        row.limit
535                            .iter()
536                            .enumerate()
537                            .max_by(|a, b| a.1.total_cmp(b.1))
538                            .map(|(at, share)| format!("{} {share:.3}", outcome.choices[at]))
539                    })
540                    .unwrap_or_default();
541                let _ = writeln!(out, "    {:<32} {held}", faction.join(", "));
542            }
543        }
544        Settling::Anchored => {
545            // Under an anchor there is no single position to report, and saying
546            // one would name a position none of them holds. What each agent
547            // landed on, and how far apart they stayed, is the result.
548            // The susceptibility is a diagonal, so it goes on the row when the
549            // agents differ and on the header when they do not. Printing one
550            // number over rows that used several would be the wrong number for
551            // all but one of them.
552            let uniform = outcome
553                .agents
554                .windows(2)
555                .all(|pair| (pair[0].susceptibility - pair[1].susceptibility).abs() < f64::EPSILON);
556            if uniform {
557                let _ = writeln!(
558                    out,
559                    "  anchored after {} round(s), susceptibility {:.2}",
560                    outcome.rounds,
561                    outcome
562                        .agents
563                        .first()
564                        .map_or(outcome.susceptibility, |a| a.susceptibility)
565                );
566            } else {
567                let _ = writeln!(out, "  anchored after {} round(s)", outcome.rounds);
568            }
569            for row in &outcome.agents {
570                let held = row
571                    .limit
572                    .iter()
573                    .enumerate()
574                    .max_by(|a, b| a.1.total_cmp(b.1))
575                    .map(|(at, share)| format!("{} {share:.3}", outcome.choices[at]))
576                    .unwrap_or_default();
577                if uniform {
578                    let _ = writeln!(out, "    {:<24} {held}", row.agent);
579                } else {
580                    let _ = writeln!(
581                        out,
582                        "    {:<24} {held:<16} susceptibility {:.2}",
583                        row.agent, row.susceptibility
584                    );
585                }
586            }
587            let _ = writeln!(
588                out,
589                "  spread {:.3}: what the group keeps disagreeing about after listening",
590                outcome.spread
591            );
592            // The unweighted mean across agents. Named as what it is: an
593            // average of positions, not a position anybody argued for.
594            let mut mean: Vec<(&str, f64)> = outcome
595                .choices
596                .iter()
597                .enumerate()
598                .map(|(at, choice)| {
599                    let total: f64 = outcome.agents.iter().map(|a| a.limit[at]).sum();
600                    (choice.as_str(), total / outcome.agents.len() as f64)
601                })
602                .collect();
603            mean.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(b.0)));
604            let _ = writeln!(out, "  mean of those positions");
605            for (choice, share) in &mean {
606                let _ = writeln!(out, "    {choice:<24} {share:.3}");
607            }
608        }
609        Settling::Oscillating => {
610            let _ = writeln!(
611                out,
612                "  no consensus: {} rounds did not settle, which is a trust graph with no \
613                 weight on its own opinions",
614                outcome.rounds
615            );
616        }
617    }
618    out
619}
620
621/// Case-insensitive substring scan over id, title, properties, and body. Linear
622/// in the corpus, which is the right cost until the issue count climbs.
623///
624/// # Errors
625///
626/// Returns an error if the corpus cannot be read.
627pub fn search(layout: &Layout, query: &str, limit: usize) -> Result<String> {
628    let recs = load_recs(layout)?;
629    let hits = CatalogService::from_recs(&recs).search(query, limit)?;
630    let mut out = String::new();
631    for h in hits {
632        let _ = writeln!(
633            out,
634            "{:<22} {:<9} [#{}]  {}  ({})",
635            h.id, h.state, h.priority, h.title, h.project
636        );
637    }
638    Ok(out)
639}
640
641/// Issues whose `:PARENT:` points at `parent_id`.
642///
643/// # Errors
644///
645/// Returns an error if the corpus cannot be read.
646pub fn children(layout: &Layout, parent_id: &str) -> Result<String> {
647    let mut rows: Vec<(String, IssueHeading)> = load_all(layout)?
648        .into_iter()
649        .filter(|(_, h)| h.parent() == Some(parent_id))
650        .collect();
651    rows.sort_by(|a, b| {
652        a.1.priority
653            .cmp(&b.1.priority)
654            .then_with(|| a.1.state.cmp(&b.1.state))
655            .then_with(|| a.1.id.cmp(&b.1.id))
656    });
657    let mut out = String::new();
658    for (project, h) in rows {
659        let _ = writeln!(
660            out,
661            "{:<22} {:<9} [#{}]  {}  ({})",
662            h.id, h.state, h.priority, h.title, project
663        );
664    }
665    Ok(out)
666}
667
668/// Open issues whose `:CREATED:` is at least `days` old. An issue without a
669/// parseable date is never stale, because its age is unknown.
670///
671/// # Errors
672///
673/// Returns an error if the corpus cannot be read.
674pub fn stale(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
675    let today = Local::now().date_naive();
676    let cutoff = today - chrono::Duration::days(days);
677    let mut rows: Vec<(String, IssueHeading, NaiveDate)> = Vec::new();
678    for (project, h) in load_all(layout)? {
679        if !project_selected(&project, project_filter) {
680            continue;
681        }
682        if !READY_STATES.contains(&h.state.as_str()) {
683            continue;
684        }
685        let Some(created) = h.properties.get("CREATED") else {
686            continue;
687        };
688        let Some(parsed) = parse_org_date(created) else {
689            continue;
690        };
691        if parsed <= cutoff {
692            rows.push((project, h, parsed));
693        }
694    }
695    rows.sort_by_key(|r| r.2);
696    let mut out = String::new();
697    for (project, h, created) in rows {
698        let age = (today - created).num_days();
699        let _ = writeln!(
700            out,
701            "{:<22} {:<9} [#{}]  {} ({}d, {})",
702            h.id, h.state, h.priority, h.title, age, project
703        );
704    }
705    Ok(out)
706}
707
708/// Every live claim, oldest first: the who-holds-what view. A claim is live
709/// while its issue is STARTED or BLOCKED (release happens on TODO, DONE, or
710/// CANCELLED), so this is the working set, not history.
711///
712/// # Errors
713///
714/// Returns an error if the corpus cannot be read, or JSON serialization fails
715/// when `json` is set.
716pub fn claims(
717    layout: &Layout,
718    holder_filter: Option<&str>,
719    project_filter: Option<&str>,
720    json: bool,
721) -> Result<String> {
722    let recs = load_recs(layout)?;
723    let rows = CatalogService::from_recs(&recs).claims(holder_filter, project_filter)?;
724
725    if json {
726        return Ok(format!("{}\n", serde_json::to_value(&rows)?));
727    }
728
729    let mut out = String::new();
730    for row in &rows {
731        let age_txt = if row.age_days < 0 {
732            "?d".to_string()
733        } else {
734            format!("{}d", row.age_days)
735        };
736        let _ = writeln!(
737            out,
738            "{:<22} {:<9} [#{}]  {:>4}  {}  {} ({})",
739            row.id,
740            row.state,
741            row.priority,
742            age_txt,
743            row.holder.as_deref().unwrap_or("?"),
744            row.title,
745            row.project
746        );
747    }
748    if rows.is_empty() {
749        out.push_str("no live claims\n");
750    }
751    Ok(out)
752}
753
754/// Dated open work in the next `days` days, plus anything already overdue.
755/// Dated open work, grouped the way Org does: deadline, then scheduled,
756/// then appointment. Overdue deadlines are first.
757///
758/// # Errors
759///
760/// Returns an error if the corpus cannot be read.
761pub fn agenda(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
762    let today = Local::now().date_naive();
763    let recs = load_recs(layout)?;
764    let rows = crate::catalog::agenda_rows_from(&recs, days, project_filter)?;
765
766    let mut out = String::new();
767    let mut last_kind: Option<&str> = None;
768    for row in &rows {
769        if last_kind != Some(row.kind.as_str()) {
770            let _ = writeln!(out, "{}", row.kind);
771            last_kind = Some(row.kind.as_str());
772        }
773        let date = chrono::NaiveDate::parse_from_str(&row.date, "%Y-%m-%d").ok();
774        let when = match date.map(|d| (d - today).num_days()) {
775            Some(d) if d < 0 => format!("{}d overdue", -d),
776            Some(0) => "today".to_string(),
777            Some(d) => format!("in {d}d"),
778            None => String::new(),
779        };
780        let label = if row.kind == "appointment" {
781            "on"
782        } else {
783            row.kind.as_str()
784        };
785        let _ = writeln!(
786            out,
787            "{}  {label:<9} {when:<11} {:<22} {:<9} [#{}]  {}  ({})",
788            row.date, row.id, row.state, row.priority, row.title, row.project
789        );
790    }
791    if out.is_empty() {
792        out.push_str("nothing dated in range\n");
793    }
794    Ok(out)
795}
796
797pub(crate) fn parse_org_date(s: &str) -> Option<NaiveDate> {
798    let inner = s
799        .trim_start_matches(['<', '['])
800        .trim_end_matches(['>', ']']);
801    let token = inner.split_whitespace().next()?;
802    NaiveDate::parse_from_str(token, "%Y-%m-%d").ok()
803}
804
805/// The matching issue count and nothing else, for shell pipelines.
806///
807/// # Errors
808///
809/// Returns an error if the corpus cannot be read.
810pub fn count(
811    layout: &Layout,
812    project_filter: Option<&str>,
813    state_filter: Option<&str>,
814    ready_only: bool,
815) -> Result<String> {
816    let all = load_all(layout)?;
817    let active_blockers: HashSet<String> = if ready_only {
818        all.iter()
819            .filter(|(_, h)| h.state != "DONE" && h.state != "CANCELLED")
820            .map(|(_, h)| h.id.clone())
821            .collect()
822    } else {
823        HashSet::new()
824    };
825    let n = all
826        .iter()
827        .filter(|(project, h)| {
828            if !project_selected(project, project_filter) {
829                return false;
830            }
831            if let Some(s) = state_filter
832                && h.state != s
833            {
834                return false;
835            }
836            if ready_only {
837                if !READY_STATES.contains(&h.state.as_str()) {
838                    return false;
839                }
840                if blocker_ids(h).iter().any(|b| active_blockers.contains(*b)) {
841                    return false;
842                }
843            }
844            true
845        })
846        .count();
847    Ok(format!("{n}\n"))
848}
849
850/// One JSON object per line: every property, the logbook, the body, and the
851/// file line range. Round-trippable, and the seam other tools consume.
852///
853/// # Errors
854///
855/// Returns an error if the corpus cannot be read.
856pub fn export(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
857    let mut out = String::new();
858    for rec in load_recs(layout)? {
859        if !project_selected(&rec.project, project_filter) {
860            continue;
861        }
862        let _ = writeln!(
863            out,
864            "{}",
865            export_row(&rec.project, rec.heading, &rec.tag_settings)
866        );
867    }
868    Ok(out)
869}
870
871/// The same lines as [`export`], grouped by project, from one read.
872///
873/// `export` filters a whole-corpus read down to one project, so digesting
874/// every project separately re-read the corpus once per project: quadratic
875/// in the project count, and six seconds on a tracker with a hundred of
876/// them. The rows are built by the same function, so a project's text here
877/// is byte for byte what `export(layout, Some(project))` returns, and the
878/// digests taken from it do not move.
879///
880/// # Errors
881///
882/// Returns an error if the corpus cannot be read.
883pub fn export_by_project(layout: &Layout) -> Result<BTreeMap<String, String>> {
884    let mut out: BTreeMap<String, String> = BTreeMap::new();
885    for rec in load_recs(layout)? {
886        let row = export_row(&rec.project, rec.heading, &rec.tag_settings);
887        let _ = writeln!(out.entry(rec.project).or_default(), "{row}");
888    }
889    Ok(out)
890}
891
892fn export_row(
893    project: &str,
894    h: IssueHeading,
895    settings: &crate::org::TagSettings,
896) -> serde_json::Value {
897    let logbook: Vec<serde_json::Value> = h
898        .logbook
899        .iter()
900        .map(|e| {
901            let mut row = serde_json::json!({
902                "timestamp": e.timestamp,
903                "from": e.from_state,
904                "to": e.to_state,
905                "note": e.note,
906            });
907            if let Some(raw) = &e.raw {
908                row["raw"] = serde_json::Value::String(raw.clone());
909            }
910            row
911        })
912        .collect();
913    serde_json::json!({
914        "id": h.id,
915        "project": project,
916        "title": h.title,
917        "state": h.state,
918        "priority": h.priority.to_string(),
919        "properties": h.properties,
920        // Typed beside the drawer rather than only inside it, so a consumer of
921        // the export reads the field the socket already hands over typed
922        // instead of splitting a drawer string on whichever separator the
923        // author happened to use. `properties` keeps `:DEEDS:` as well: a
924        // reader that wants the drawer verbatim should still get it.
925        "deeds": h.deeds(),
926        "org_tags": h.org_tags,
927        "tags": h.tags(),
928        "all_tags": settings.all_tags(&h.tags()),
929        "logbook": logbook,
930        "body": h.body,
931        "line_start": h.line_start,
932        "line_end": h.line_end,
933    })
934}
935
936/// Children and blockers below `root_id`, as indented text or Graphviz DOT.
937///
938/// # Errors
939///
940/// Returns an error if the corpus cannot be read, `root_id` is not in it, or
941/// `format` is not `ascii`, `text`, or `dot`.
942pub fn tree(layout: &Layout, root_id: &str, format: &str) -> Result<String> {
943    let all = load_all(layout)?;
944    let graph = GraphIndex::new(&all);
945    let Some(root_heading) = graph.by_id.get(root_id) else {
946        return Err(Error::IssueNotFound {
947            id: root_id.to_string(),
948        });
949    };
950    let mut out = String::new();
951    let root = root_heading.id.as_str();
952    match format {
953        "ascii" | "text" => tree_ascii(&graph, root, 0, &mut HashSet::new(), &mut out),
954        "dot" => tree_dot(&graph, root, &mut out),
955        _ => return Err(anyhow!("unknown format {format:?}; allowed: ascii, dot").into()),
956    }
957    Ok(out)
958}
959
960fn tree_ascii<'a>(
961    graph: &GraphIndex<'a>,
962    id: &'a str,
963    depth: usize,
964    seen: &mut HashSet<&'a str>,
965    out: &mut String,
966) {
967    if !seen.insert(id) {
968        let _ = writeln!(out, "{}{id} (cycle, stopping)", "  ".repeat(depth));
969        return;
970    }
971    let Some(h) = graph.by_id.get(id) else {
972        let _ = writeln!(out, "{}{id} (missing)", "  ".repeat(depth));
973        return;
974    };
975    let _ = writeln!(
976        out,
977        "{}{id} {:<9} [#{}]  {}",
978        "  ".repeat(depth),
979        h.state,
980        h.priority,
981        h.title
982    );
983    if let Some(blockers) = graph.blockers.get(id) {
984        for blocker in blockers {
985            let _ = writeln!(out, "{}* blocked-by {blocker}", "  ".repeat(depth + 1));
986        }
987    }
988    if let Some(kids) = graph.children.get(id) {
989        for k in kids {
990            tree_ascii(graph, k, depth + 1, seen, out);
991        }
992    }
993}
994
995/// Escape text for a Graphviz quoted string. Backslash goes first, or the
996/// escape introduced for a quote is itself re-escaped; a raw newline would end
997/// the statement early. Titles and ids are whatever someone committed to the
998/// tracker, so neither is trusted here.
999pub(crate) fn dot_quoted(text: &str) -> String {
1000    text.replace('\\', "\\\\")
1001        .replace('"', "\\\"")
1002        .replace('\n', "\\n")
1003        .replace('\r', "")
1004}
1005
1006fn tree_dot<'a>(graph: &GraphIndex<'a>, root_id: &str, out: &mut String) {
1007    let _ = writeln!(out, "digraph vissue_tree {{");
1008    let _ = writeln!(out, "  rankdir=LR;");
1009    let _ = writeln!(
1010        out,
1011        "  node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
1012    );
1013    let mut visited: HashSet<&str> = HashSet::new();
1014    let mut stack = vec![graph.by_id.get(root_id).unwrap().id.as_str()];
1015    while let Some(id) = stack.pop() {
1016        if !visited.insert(id) {
1017            continue;
1018        }
1019        if let Some(h) = graph.by_id.get(id) {
1020            let _ = writeln!(
1021                out,
1022                "  \"{}\" [label=\"{}\\n{} [#{}]\"];",
1023                dot_quoted(&h.id),
1024                dot_quoted(&h.title),
1025                dot_quoted(&h.state),
1026                dot_quoted(&h.priority.to_string())
1027            );
1028            if let Some(kids) = graph.children.get(id) {
1029                for k in kids {
1030                    let _ = writeln!(
1031                        out,
1032                        "  \"{}\" -> \"{}\" [color=\"#00897B\"];",
1033                        dot_quoted(&h.id),
1034                        dot_quoted(k)
1035                    );
1036                    stack.push(k);
1037                }
1038            }
1039            if let Some(blockers) = graph.blockers.get(id) {
1040                for b in blockers {
1041                    let _ = writeln!(
1042                        out,
1043                        "  \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
1044                        dot_quoted(b),
1045                        dot_quoted(&h.id)
1046                    );
1047                    stack.push(b);
1048                }
1049            }
1050        }
1051    }
1052    let _ = writeln!(out, "}}");
1053}
1054
1055/// Cycles in the blocker graph, one per line, or a line saying there are none.
1056///
1057/// # Errors
1058///
1059/// Returns an error if the corpus cannot be read.
1060pub fn cycles(layout: &Layout) -> Result<String> {
1061    let all = load_all(layout)?;
1062    let graph = GraphIndex::new(&all);
1063
1064    // Colored depth-first search over BLOCKED_BY edges. Grey marks the
1065    // current stack, black a finished node, so a shared blocker reached
1066    // from two branches (a diamond) is never mistaken for a cycle.
1067    const WHITE: u8 = 0;
1068    const GREY: u8 = 1;
1069    const BLACK: u8 = 2;
1070    let mut color: HashMap<&str, u8> = HashMap::new();
1071    let mut found: Vec<Vec<String>> = Vec::new();
1072
1073    fn dfs<'a>(
1074        id: &'a str,
1075        graph: &GraphIndex<'a>,
1076        color: &mut HashMap<&'a str, u8>,
1077        path: &mut Vec<&'a str>,
1078        found: &mut Vec<Vec<String>>,
1079    ) {
1080        color.insert(id, GREY);
1081        path.push(id);
1082        if let Some(blockers) = graph.blockers.get(id) {
1083            for b in blockers {
1084                if !graph.by_id.contains_key(b) {
1085                    continue; // a broken edge cannot close a loop; `check` reports it
1086                }
1087                match color.get(b).copied().unwrap_or(WHITE) {
1088                    GREY => {
1089                        let start = path.iter().position(|&x| x == *b).unwrap();
1090                        let mut cycle: Vec<String> =
1091                            path[start..].iter().map(|s| s.to_string()).collect();
1092                        // Rotate so the smallest id leads: one canonical form
1093                        // per cycle no matter where the walk entered it.
1094                        let min = cycle
1095                            .iter()
1096                            .enumerate()
1097                            .min_by(|a, b| a.1.cmp(b.1))
1098                            .map(|(i, _)| i)
1099                            .unwrap();
1100                        cycle.rotate_left(min);
1101                        cycle.push(cycle[0].clone());
1102                        if !found.contains(&cycle) {
1103                            found.push(cycle);
1104                        }
1105                    }
1106                    WHITE => dfs(b, graph, color, path, found),
1107                    _ => {}
1108                }
1109            }
1110        }
1111        path.pop();
1112        color.insert(id, BLACK);
1113    }
1114
1115    for (_, start) in &all {
1116        if color.get(start.id.as_str()).copied().unwrap_or(WHITE) == WHITE {
1117            let mut path = Vec::new();
1118            dfs(start.id.as_str(), &graph, &mut color, &mut path, &mut found);
1119        }
1120    }
1121
1122    let mut out = String::new();
1123    if found.is_empty() {
1124        let _ = writeln!(out, "no cycles");
1125    } else {
1126        for cycle in found {
1127            let _ = writeln!(out, "{}", cycle.join(" -> "));
1128        }
1129    }
1130    Ok(out)
1131}
1132
1133/// Transitive blocker ancestors, limited to a bounded number of hops.
1134///
1135/// # Errors
1136///
1137/// Returns an error if the corpus cannot be read, the blocker graph cannot be
1138/// built, or `id` is not in it.
1139pub fn ancestors(layout: &Layout, id: &str, depth: usize) -> Result<String> {
1140    let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
1141    let mut out = String::new();
1142    for (distance, ancestor) in graph.ancestors(id, depth)? {
1143        writeln!(out, "{distance} {ancestor}")?;
1144    }
1145    Ok(out)
1146}
1147
1148/// Transitive issues waiting on this issue, limited to a bounded number of hops.
1149///
1150/// # Errors
1151///
1152/// Returns an error if the corpus cannot be read, the blocker graph cannot be
1153/// built, or `id` is not in it.
1154pub fn impact(layout: &Layout, id: &str, depth: usize) -> Result<String> {
1155    let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
1156    let mut out = String::new();
1157    for (distance, descendant) in graph.descendants(id, depth)? {
1158        writeln!(out, "{distance} {descendant}")?;
1159    }
1160    Ok(out)
1161}
1162
1163/// The whole blocker and parent graph as Graphviz DOT. Node fill encodes state.
1164///
1165/// # Errors
1166///
1167/// Returns an error if the corpus cannot be read.
1168/// The lines a DOT document opens with, up to and including the graph
1169/// attributes. Exposed for the same reason as [`ROADMAP_HEADER`]: a caller
1170/// drawing several projects as one graph writes them once. Concatenating whole
1171/// documents gives `dot` a file of many graphs, and it renders the first.
1172pub const GRAPH_HEADER: &str = concat!(
1173    "digraph vissue_graph {\n",
1174    "  rankdir=LR;\n",
1175    "  node [shape=box, fontname=\"Jost\", style=filled];\n",
1176    "  edge [fontname=\"Jost\"];\n"
1177);
1178
1179/// The line that closes a DOT document.
1180pub const GRAPH_FOOTER: &str = "}\n";
1181
1182/// A DOT graph of the corpus: one node per issue, blocker and parent edges.
1183///
1184/// # Errors
1185///
1186/// Returns an error if the corpus cannot be read.
1187pub fn graph(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1188    Ok(format!(
1189        "{GRAPH_HEADER}{}{GRAPH_FOOTER}",
1190        graph_body(layout, project_filter)?
1191    ))
1192}
1193
1194/// The nodes and edges of the graph, without the enclosing `digraph` block.
1195///
1196/// # Errors
1197///
1198/// Returns an error if the corpus cannot be read.
1199pub fn graph_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1200    let all = load_all(layout)?;
1201    let graph = GraphIndex::new(&all);
1202    let mut out = String::new();
1203    for (project, h) in &all {
1204        if !project_selected(project, project_filter) {
1205            continue;
1206        }
1207        let fill = match h.state.as_str() {
1208            "DONE" => "#A5D6A7",
1209            "CANCELLED" => "#CFD8DC",
1210            "BLOCKED" => "#FFCC80",
1211            "STARTED" => "#80CBC4",
1212            _ => "#E0F2F1",
1213        };
1214        let _ = writeln!(
1215            out,
1216            "  \"{}\" [label=\"{}\\n{} [#{}]\", fillcolor=\"{}\"];",
1217            dot_quoted(&h.id),
1218            dot_quoted(&h.title),
1219            dot_quoted(&h.state),
1220            dot_quoted(&h.priority.to_string()),
1221            fill
1222        );
1223    }
1224    for (project, h) in &all {
1225        if !project_selected(project, project_filter) {
1226            continue;
1227        }
1228        if let Some(blockers) = graph.blockers.get(h.id.as_str()) {
1229            for b in blockers {
1230                writeln!(
1231                    out,
1232                    "  \"{}\" -> \"{}\" [color=\"#FF7043\"];",
1233                    dot_quoted(b),
1234                    dot_quoted(&h.id)
1235                )?;
1236            }
1237        }
1238        if let Some(parent) = h.parent() {
1239            writeln!(
1240                out,
1241                "  \"{}\" -> \"{}\" [color=\"#00897B\", style=dashed];",
1242                dot_quoted(parent),
1243                dot_quoted(&h.id)
1244            )?;
1245        }
1246    }
1247    Ok(out)
1248}
1249
1250/// A markdown roadmap grouped by project and state. Closed items collapse into
1251/// one section so the document stays about live work.
1252///
1253/// # Errors
1254///
1255/// Returns an error if the corpus cannot be read.
1256/// The document furniture a roadmap opens with. Exposed because a caller that
1257/// assembles one roadmap out of several projects writes it once rather than once
1258/// per project: concatenating whole roadmaps puts a title above every project
1259/// section, so a corpus of six carries six titles in one document.
1260pub const ROADMAP_HEADER: &str = concat!(
1261    "# Roadmap\n\n",
1262    "Generated from `vissue roadmap`. Source of truth lives in the per-project issues.org files.\n\n"
1263);
1264
1265/// A markdown roadmap of active and closed work, with its title.
1266///
1267/// # Errors
1268///
1269/// Returns an error if the corpus cannot be read.
1270pub fn roadmap(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1271    Ok(format!(
1272        "{ROADMAP_HEADER}{}",
1273        roadmap_body(layout, project_filter)?
1274    ))
1275}
1276
1277/// The roadmap's project sections, without the document title.
1278///
1279/// # Errors
1280///
1281/// Returns an error if the corpus cannot be read.
1282pub fn roadmap_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1283    let all = load_all(layout)?;
1284    let mut by_project: BTreeMap<String, Vec<&IssueHeading>> = BTreeMap::new();
1285    for (project, h) in &all {
1286        if !project_selected(project, project_filter) {
1287            continue;
1288        }
1289        by_project.entry(project.clone()).or_default().push(h);
1290    }
1291    let mut out = String::new();
1292    for (project, mut headings) in by_project {
1293        headings.sort_by(|a, b| {
1294            a.priority
1295                .cmp(&b.priority)
1296                .then_with(|| a.state.cmp(&b.state))
1297                .then_with(|| a.id.cmp(&b.id))
1298        });
1299        let buckets = ["STARTED", "TODO", "BLOCKED"];
1300        let active: Vec<&&IssueHeading> = headings
1301            .iter()
1302            .filter(|h| buckets.contains(&h.state.as_str()))
1303            .collect();
1304        let closed: Vec<&&IssueHeading> = headings
1305            .iter()
1306            .filter(|h| h.state == "DONE" || h.state == "CANCELLED")
1307            .collect();
1308        if active.is_empty() && closed.is_empty() {
1309            continue;
1310        }
1311        writeln!(out, "## {project}")?;
1312        writeln!(out)?;
1313        for state in buckets {
1314            let in_state: Vec<&&IssueHeading> = active
1315                .iter()
1316                .copied()
1317                .filter(|h| h.state == state)
1318                .collect();
1319            if in_state.is_empty() {
1320                continue;
1321            }
1322            writeln!(out, "### {state}")?;
1323            writeln!(out)?;
1324            for h in in_state {
1325                let deadline = h
1326                    .deadline()
1327                    .map(|d| format!(" :: deadline {d}"))
1328                    .unwrap_or_default();
1329                let blockers = blocker_ids(h);
1330                let blocked_by = if blockers.is_empty() {
1331                    String::new()
1332                } else {
1333                    format!(" :: blocked by {}", blockers.join(", "))
1334                };
1335                writeln!(
1336                    out,
1337                    "- **{}** [#{}] {}{}{}",
1338                    h.id, h.priority, h.title, deadline, blocked_by
1339                )?;
1340            }
1341            writeln!(out)?;
1342        }
1343        if !closed.is_empty() {
1344            writeln!(out, "### Closed ({} items)", closed.len())?;
1345            writeln!(out)?;
1346            for h in closed.iter().take(10) {
1347                writeln!(
1348                    out,
1349                    "- {} [#{}] {} ({})",
1350                    h.id, h.priority, h.title, h.state
1351                )?;
1352            }
1353            if closed.len() > 10 {
1354                writeln!(out, "- ... and {} more", closed.len() - 10)?;
1355            }
1356            writeln!(out)?;
1357        }
1358    }
1359    Ok(out)
1360}
1361
1362/// Does this body say *this issue* was rejected, as opposed to using the word.
1363///
1364/// `contains("rejected")` cannot tell the two apart, and the difference is the
1365/// whole finding. Every bug report about input validation says it: "silently
1366/// corrupted rather than rejected", "ignored rather than enforced or rejected".
1367/// A design note says it too: "a hand-written parser is rejected as strictly
1368/// dominated". Three issues in one corpus were flagged for exactly those, all
1369/// of them worked and closed properly, and a check that cries wolf about closed
1370/// issues is a check nobody re-reads.
1371///
1372/// So this looks for the shapes a rejection is actually written in: the tool's
1373/// own phrasing, a redirect, or a heading that says so.
1374fn looks_like_reject_prose(body: &str) -> bool {
1375    let lower = body.to_ascii_lowercase();
1376    const CLOSING: &[&str] = &[
1377        "vissue reject",
1378        "superseded by",
1379        "rejected in favour",
1380        "rejected in favor",
1381        "rejected as a duplicate",
1382        "closed as a duplicate",
1383        "closed as duplicate",
1384        "not doing this",
1385        "rejected this",
1386        "rejected: ",
1387    ];
1388    if CLOSING.iter().any(|phrase| lower.contains(phrase)) {
1389        return true;
1390    }
1391    // A heading that names the outcome, which is where a hand-written rejection
1392    // goes when it is not one of the phrases above.
1393    //
1394    // "superseded" and not "supersede": the participle says this issue was
1395    // replaced, and the third person says it replaced others. A corpus here has
1396    // an issue whose "** Supersedes" section rolls up seven others it did not
1397    // close, and reading that as its own rejection is the false positive this
1398    // function exists to stop. Do not shorten the stem.
1399    lower.lines().any(|line| {
1400        line.starts_with('*')
1401            && (line.contains("rejected")
1402                || line.contains("superseded")
1403                || line.contains("reject:"))
1404    })
1405}
1406
1407/// Does the prose around this link claim the relation the properties name.
1408///
1409/// A body mentions other issues for every reason there is: a parent lists its
1410/// children, an umbrella rolls up what it does not close, a note says "see
1411/// also". Warning that any of those lacks a `DISCOVERED_FROM` asks for an edge
1412/// nobody can honestly supply, and the answer is a wrong edge or a warning that
1413/// gets ignored. One corpus had twenty-two of these and not one was a discovery.
1414///
1415/// So the warning is for a body that says discovery or a pivot and has no edge
1416/// to match, which is the case the properties exist for.
1417fn claims_discovery_or_pivot(body: &str, linked: &str) -> bool {
1418    const CLAIMS: &[&str] = &[
1419        "discovered from",
1420        "discovered while",
1421        "discovered during",
1422        "found while",
1423        "filed from",
1424        "split from",
1425        "pivoted to",
1426        "pivots to",
1427        "pivoted from",
1428        "replaced by",
1429        "moved to",
1430    ];
1431    let needle = format!("id:{linked}");
1432    let lower = body.to_ascii_lowercase();
1433    let lower_needle = needle.to_ascii_lowercase();
1434    // The claim has to be near the link rather than anywhere in the body: a long
1435    // issue can say "discovered while auditing" in one section and link three
1436    // unrelated ids in another.
1437    let window = 240;
1438    let mut from = 0;
1439    while let Some(at) = lower[from..].find(&lower_needle) {
1440        let hit = from + at;
1441        let start = hit.saturating_sub(window);
1442        let end = (hit + lower_needle.len() + window).min(lower.len());
1443        let near = &lower[floor_char_boundary(&lower, start)..ceil_char_boundary(&lower, end)];
1444        if CLAIMS.iter().any(|phrase| near.contains(phrase)) {
1445            return true;
1446        }
1447        from = hit + lower_needle.len();
1448    }
1449    false
1450}
1451
1452fn floor_char_boundary(s: &str, mut i: usize) -> usize {
1453    while i > 0 && !s.is_char_boundary(i) {
1454        i -= 1;
1455    }
1456    i
1457}
1458
1459fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
1460    while i < s.len() && !s.is_char_boundary(i) {
1461        i += 1;
1462    }
1463    i
1464}
1465
1466/// Is the relation between these two already held as an edge, in either
1467/// direction.
1468///
1469/// Discovery and a pivot are not the only relations people write. A parent
1470/// mentioning a child, or an issue naming what blocks it, is a stated relation
1471/// the tracker already holds, and warning that it lacks a DISCOVERED_FROM asks
1472/// for an edge nobody can honestly supply: the answer is either a wrong edge or
1473/// a warning that gets ignored.
1474fn edge_connects(all: &[(String, IssueHeading)], a: &str, b: &str) -> bool {
1475    all.iter().any(|(_, h)| {
1476        let far = if h.id == a {
1477            b
1478        } else if h.id == b {
1479            a
1480        } else {
1481            return false;
1482        };
1483        [
1484            crate::props::DISCOVERED_FROM,
1485            crate::props::PIVOTED_TO,
1486            crate::props::PARENT,
1487            crate::props::BLOCKED_BY,
1488            crate::props::EDNA_BLOCKER,
1489        ]
1490        .iter()
1491        .any(|key| {
1492            crate::props::get(&h.properties, key)
1493                .is_some_and(|value| value.split(&[',', ' '][..]).any(|part| part.trim() == far))
1494        })
1495    })
1496}
1497
1498/// Outcome of [`check`]: the findings, and how many were errors.
1499#[derive(Debug, Clone)]
1500pub struct CheckReport {
1501    /// Rendered findings, ending in a summary line.
1502    pub text: String,
1503    /// Count of `[err]` findings.
1504    pub errors: usize,
1505    /// Count of `[warn]` findings.
1506    pub warnings: usize,
1507}
1508
1509/// Findings as they accumulate, each carrying its own severity.
1510///
1511/// The counts are the point: `check` exits non-zero on an error, and a caller reads
1512/// the two numbers without reading the prose. Keeping them beside the text is what
1513/// stops a finding being written without being counted, which is a silent way for the
1514/// exit code to disagree with the report.
1515#[derive(Default)]
1516struct Findings {
1517    text: String,
1518    errors: usize,
1519    warnings: usize,
1520}
1521
1522impl Findings {
1523    /// A finding a reader has to fix. Writing to a `String` cannot fail.
1524    fn err(&mut self, what: std::fmt::Arguments) {
1525        let _ = writeln!(self.text, "[err]  {what}");
1526        self.errors += 1;
1527    }
1528
1529    /// A finding a reader may leave, which does not change the exit code.
1530    fn warn(&mut self, what: std::fmt::Arguments) {
1531        let _ = writeln!(self.text, "[warn] {what}");
1532        self.warnings += 1;
1533    }
1534}
1535
1536/// Validate the corpus: every parent and blocker id resolves, dates parse, open
1537/// issues carry a creation date, and ids are unique across projects.
1538///
1539/// # Errors
1540///
1541/// Returns an error if the corpus cannot be read.
1542pub fn check(layout: &Layout) -> Result<CheckReport> {
1543    let all = load_all(layout)?;
1544
1545    // A parent is usually another issue, and those ids are already in hand.
1546    // Only the ones that are not send us looking through the rest of the
1547    // tree, which on a tracker sharing a root with a notes vault is most of
1548    // the bytes on disk.
1549    let issue_ids: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1550    let unresolved: HashSet<String> = all
1551        .iter()
1552        .filter_map(|(_, h)| h.parent())
1553        .filter(|p| !issue_ids.contains(p))
1554        .map(str::to_string)
1555        .collect();
1556    let elsewhere = find_org_ids(layout, &unresolved)?;
1557    let resolves = |id: &str| issue_ids.contains(id) || elsewhere.contains(id);
1558
1559    let mut f = Findings::default();
1560
1561    let mut by_id: HashMap<String, (String, &IssueHeading)> = HashMap::new();
1562    for (project, h) in &all {
1563        if let Some(prev) = by_id.insert(h.id.clone(), (project.clone(), h)) {
1564            // An error, not a note: an id that names two issues makes every
1565            // blocker and parent edge pointing at it ambiguous.
1566            f.err(format_args!(
1567                "duplicate id: {} appears in {} and {}",
1568                h.id, prev.0, project
1569            ));
1570        }
1571    }
1572
1573    for project in list_projects(layout)? {
1574        check_project(&project, layout, &mut f)?;
1575    }
1576
1577    for (project, h) in &all {
1578        check_issue(project, h, &resolves, &by_id, &mut f);
1579    }
1580
1581    let known: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1582    for (project, h) in &all {
1583        check_provenance_links(&all, project, h, &known, &mut f);
1584    }
1585
1586    // A :PARENT: loop passes every edge check, because each id resolves, yet
1587    // it makes the hierarchy unwalkable: `tree` stops on it and prints
1588    // "(cycle, stopping)". Naming it here is what keeps a corpus that holds
1589    // one from reading as clean.
1590    let mut settled: HashSet<&str> = HashSet::new();
1591    for (_, h) in &all {
1592        check_parent_cycle(h, &by_id, &mut settled, &mut f);
1593    }
1594
1595    if f.errors == 0
1596        && let Err(err) = DependencyGraph::from_issues(&all)
1597    {
1598        f.err(format_args!("blocker graph: {err}"));
1599    }
1600
1601    let _ = writeln!(f.text);
1602    let projects = list_projects(layout)?.len();
1603    let _ = writeln!(
1604        f.text,
1605        "checked {} issue(s) across {projects} project(s): {} error(s), {} warning(s)",
1606        all.len(),
1607        f.errors,
1608        f.warnings
1609    );
1610    Ok(CheckReport {
1611        text: f.text,
1612        errors: f.errors,
1613        warnings: f.warnings,
1614    })
1615}
1616
1617/// Validate one project's file: its preamble, and the headings it holds.
1618///
1619/// The one cohesive thing in `check` that is about a file rather than about the
1620/// corpus. Everything here reads one project's own preamble and its own headings and
1621/// needs none of the others.
1622///
1623/// # Errors
1624///
1625/// Returns an error if the project's file cannot be read or parsed.
1626fn check_project(project: &str, layout: &Layout, f: &mut Findings) -> Result<()> {
1627    let path = layout.project_issues_path(project);
1628    let doc = IssueDoc::parse_file(project, &path)?;
1629    check_preamble(project, &doc, &path, f);
1630    // The loader skips a heading a calendar sync owns, so the parsed headings cannot
1631    // hold one and counting them there counted nothing. The heading is still in the
1632    // file, and one the tracker will not touch is the surprise worth reporting, so the
1633    // file is what gets counted.
1634    let gcal_ids = crate::store::org_ids(&std::fs::read_to_string(&path)?)
1635        .filter(|id| crate::org::is_gcal_event_id(id))
1636        .count();
1637    if gcal_ids > 0 {
1638        f.err(format_args!(
1639            "{project}: {gcal_ids} heading(s) use an org-gcal event id as :ID:"
1640        ));
1641    }
1642    check_headings(project, &doc, f);
1643    Ok(())
1644}
1645
1646/// What Org needs from the file's preamble to render the tracker as intended.
1647///
1648/// Each of these is a keyword whose absence Org does not complain about and a reader
1649/// notices later: an agenda that labels every row `issues`, a publish that exports
1650/// the tracker, a priority cookie outside the range the file declares.
1651fn check_preamble(project: &str, doc: &IssueDoc, path: &std::path::Path, f: &mut Findings) {
1652    match crate::org::protocol_from_preamble(&doc.preamble) {
1653        None => {
1654            f.warn(format_args!(
1655                "{project}: preamble has no #+VISSUE: protocol stamp"
1656            ));
1657        }
1658        Some(n) if n < crate::org::PROTOCOL_VERSION => {
1659            f.warn(format_args!(
1660                "{project}: #+VISSUE: {n} is behind protocol {}",
1661                crate::org::PROTOCOL_VERSION
1662            ));
1663        }
1664        Some(n) if n > crate::org::PROTOCOL_VERSION => {
1665            f.err(format_args!(
1666                "{project}: #+VISSUE: {n} is newer than this vissue (protocol {})",
1667                crate::org::PROTOCOL_VERSION
1668            ));
1669        }
1670        Some(_) => {}
1671    }
1672    if !crate::org::preamble_has_keyword(&doc.preamble, "CATEGORY") {
1673        f.warn(format_args!(
1674            "{project}: preamble has no #+CATEGORY: (org-agenda labels every row \"issues\")"
1675        ));
1676    }
1677    if !crate::org::preamble_has_keyword(&doc.preamble, "FILETAGS") {
1678        f.warn(format_args!("{project}: preamble has no #+FILETAGS:"));
1679    } else if !doc
1680        .tag_settings
1681        .filetags
1682        .iter()
1683        .any(|t| t.eq_ignore_ascii_case("noexport"))
1684    {
1685        f.warn(format_args!(
1686            "{project}: #+FILETAGS: has no noexport; a vault publish will export this tracker"
1687        ));
1688    }
1689    if !crate::org::preamble_has_keyword(&doc.preamble, "TAGS") {
1690        f.warn(format_args!(
1691            "{project}: preamble has no #+TAGS:; Emacs fast tag selection has no type group"
1692        ));
1693    }
1694    if !crate::org::preamble_has_keyword(
1695        &crate::org::merge_setupfile_settings(&doc.preamble, path.parent()),
1696        "PRIORITIES",
1697    ) {
1698        f.warn(format_args!(
1699            "{project}: preamble has no #+PRIORITIES:; cookies default to C and the range is A..C"
1700        ));
1701    }
1702}
1703
1704/// What the tracker needs from each heading in the file.
1705///
1706/// Counted rather than named one by one, because a file with forty headings that all
1707/// put `:PRIORITY:` in the drawer wants one line saying so, not forty.
1708fn check_headings(project: &str, doc: &IssueDoc, f: &mut Findings) {
1709    let spec = doc.priority_spec();
1710    let mut type_not_tagged = 0usize;
1711    let mut exclusive_clash = 0usize;
1712    let mut priority_out_of_range = 0usize;
1713    let mut ordered_skip = 0usize;
1714    let mut done_with_open_children = 0usize;
1715    let mut priority_in_drawer = 0usize;
1716    let mut blockedby_typo = 0usize;
1717    let mut blocker_as_ids = 0usize;
1718    let mut computed_specials = 0usize;
1719    let mut bad_effort = 0usize;
1720    for h in &doc.headings {
1721        if let Some(kind) = crate::props::get(&h.properties, crate::props::TYPE) {
1722            let kind = kind.trim();
1723            if !kind.is_empty()
1724                && kind.chars().all(crate::model::is_org_tag_char)
1725                && !h.org_tags.iter().any(|t| t == kind)
1726            {
1727                type_not_tagged += 1;
1728            }
1729        }
1730        for group in &doc.tag_settings.exclusive {
1731            let hits = group
1732                .iter()
1733                .filter(|name| h.org_tags.iter().any(|t| t == *name))
1734                .count();
1735            if hits > 1 {
1736                exclusive_clash += 1;
1737                break;
1738            }
1739        }
1740        if !spec.contains(h.priority) {
1741            priority_out_of_range += 1;
1742        }
1743        if h.properties.contains_key("PRIORITY") {
1744            priority_in_drawer += 1;
1745        }
1746        if h.properties.contains_key("BLOCKEDBY") {
1747            blockedby_typo += 1;
1748        }
1749        if let Some(raw) = h.properties.get("BLOCKER")
1750            && !crate::org::is_edna_blocker(raw)
1751        {
1752            blocker_as_ids += 1;
1753        }
1754        if crate::org::COMPUTED_SPECIALS
1755            .iter()
1756            .any(|k| *k != "PRIORITY" && h.properties.contains_key(*k))
1757        {
1758            computed_specials += 1;
1759        }
1760        if let Some(effort) = h.effort()
1761            && !crate::org::is_org_effort(effort)
1762        {
1763            bad_effort += 1;
1764        }
1765        if let Some(pid) = h.parent()
1766            && let Some(parent) = doc.headings.iter().find(|p| p.id == pid)
1767            && crate::org::org_property_is_set(&parent.properties, "ORDERED")
1768            && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1769        {
1770            let earlier_open = doc.headings.iter().any(|sib| {
1771                sib.parent() == Some(pid)
1772                    && sib.line_start < h.line_start
1773                    && sib.state != "DONE"
1774                    && sib.state != "CANCELLED"
1775            });
1776            if earlier_open && (h.state == "STARTED" || h.state == "DONE") {
1777                ordered_skip += 1;
1778            }
1779        }
1780        if h.state == "DONE"
1781            && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1782            && doc.headings.iter().any(|c| {
1783                c.parent() == Some(h.id.as_str()) && c.state != "DONE" && c.state != "CANCELLED"
1784            })
1785        {
1786            done_with_open_children += 1;
1787        }
1788    }
1789    if type_not_tagged > 0 {
1790        f.warn(format_args!("{project}: {type_not_tagged} heading(s) have :TYPE: that is a legal Org tag but is not on the heading"));
1791    }
1792    if exclusive_clash > 0 {
1793        f.warn(format_args!("{project}: {exclusive_clash} heading(s) carry more than one tag from a #+TAGS: exclusive group"));
1794    }
1795    if priority_in_drawer > 0 {
1796        f.warn(format_args!("{project}: {priority_in_drawer} heading(s) put :PRIORITY: in the drawer; Org reads the [#A] cookie"));
1797    }
1798    if blockedby_typo > 0 {
1799        f.warn(format_args!(
1800            "{project}: {blockedby_typo} heading(s) use :BLOCKEDBY: instead of :BLOCKED_BY:"
1801        ));
1802    }
1803    if blocker_as_ids > 0 {
1804        f.warn(format_args!("{project}: {blocker_as_ids} heading(s) use :BLOCKER: as a bare id list; a rewrite folds them into :BLOCKED_BY:"));
1805    }
1806    if computed_specials > 0 {
1807        f.warn(format_args!("{project}: {computed_specials} heading(s) set a computed Org special (TODO/ITEM/TAGS/...) in the drawer; Org ignores it"));
1808    }
1809    if bad_effort > 0 {
1810        f.warn(format_args!(
1811            "{project}: {bad_effort} heading(s) have an Effort value Org will not parse"
1812        ));
1813    }
1814    if priority_out_of_range > 0 {
1815        f.warn(format_args!(
1816            "{project}: {priority_out_of_range} heading(s) have a [#prio] outside #+PRIORITIES:"
1817        ));
1818    }
1819    if ordered_skip > 0 {
1820        f.warn(format_args!("{project}: {ordered_skip} heading(s) started or closed before an earlier ORDERED sibling"));
1821    }
1822    if done_with_open_children > 0 {
1823        f.warn(format_args!("{project}: {done_with_open_children} DONE heading(s) still have open children (Org ORDERED / todo-dependencies)"));
1824    }
1825}
1826
1827/// Validate one issue on its own: its edges resolve, its dates parse, and its state
1828/// agrees with what the drawer and the body say.
1829fn check_issue<'a>(
1830    project: &str,
1831    h: &'a IssueHeading,
1832    resolves: &impl Fn(&str) -> bool,
1833    by_id: &HashMap<String, (String, &'a IssueHeading)>,
1834    f: &mut Findings,
1835) {
1836    if let Some(parent) = h.parent()
1837        && !resolves(parent)
1838    {
1839        f.err(format_args!(
1840            "{} (in {}) :PARENT: {} -> not found",
1841            h.id, project, parent
1842        ));
1843    }
1844    for blk in blocker_ids(h) {
1845        if !by_id.contains_key(blk) {
1846            f.err(format_args!(
1847                "{} (in {}) :BLOCKED_BY: {} -> not found",
1848                h.id, project, blk
1849            ));
1850        }
1851    }
1852    // A citation nothing can be asked for fails wherever it is finally opened,
1853    // which is a different process on a different day. `deed` refuses one; a
1854    // hand-edited drawer is how one gets in anyway.
1855    for cited in h.deeds() {
1856        if !crate::ops::is_deed_accession(&cited) {
1857            f.warn(format_args!(
1858                "{} (in {}) :DEEDS: {} -> not a deed accession",
1859                h.id, project, cited
1860            ));
1861        }
1862    }
1863    if let Some(d) = h.deadline()
1864        && parse_org_date(d).is_none()
1865    {
1866        f.err(format_args!(
1867            "{} (in {}) :DEADLINE: {} -> unparseable",
1868            h.id, project, d
1869        ));
1870    }
1871    if let Some(s) = h.scheduled()
1872        && parse_org_date(s).is_none()
1873    {
1874        f.err(format_args!(
1875            "{} (in {}) :SCHEDULED: {} -> unparseable",
1876            h.id, project, s
1877        ));
1878    }
1879    if matches!(h.state.as_str(), "TODO" | "STARTED") && !h.properties.contains_key("CREATED") {
1880        f.warn(format_args!(
1881            "{} (in {}) state={} but :CREATED: is missing",
1882            h.id, project, h.state
1883        ));
1884    }
1885    if h.state == "DONE" && looks_like_reject_prose(&h.body) {
1886        f.warn(format_args!(
1887            "{} (in {}) is DONE but the body reads as a reject",
1888            h.id, project
1889        ));
1890    }
1891    if crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_some() {
1892        f.warn(format_args!(
1893            "{} (in {}) holds {} and sibling {}",
1894            h.id,
1895            project,
1896            h.state,
1897            crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).unwrap_or("?")
1898        ));
1899    }
1900}
1901
1902/// Report a body claiming one issue came out of another with no edge either way.
1903fn check_provenance_links<'a>(
1904    all: &[(String, IssueHeading)],
1905    project: &str,
1906    h: &'a IssueHeading,
1907    known: &HashSet<&'a str>,
1908    f: &mut Findings,
1909) {
1910    for linked in crate::related::org_link_targets(&h.body, known) {
1911        if edge_connects(all, &h.id, &linked) {
1912            continue;
1913        }
1914        if !claims_discovery_or_pivot(&h.body, &linked) {
1915            continue;
1916        }
1917        f.warn(format_args!(
1918            "{} (in {}) mentions [[id:{}]] as discovered or pivoted with no edge either way",
1919            h.id, project, linked
1920        ));
1921    }
1922}
1923
1924/// Walk `:PARENT:` from one heading and report a loop.
1925///
1926/// `settled` carries across headings, so the walk stays linear over the corpus: an id
1927/// already reached from somewhere else cannot start a loop that was not already
1928/// reported.
1929fn check_parent_cycle<'a>(
1930    start: &'a IssueHeading,
1931    by_id: &HashMap<String, (String, &'a IssueHeading)>,
1932    settled: &mut HashSet<&'a str>,
1933    f: &mut Findings,
1934) {
1935    if settled.contains(start.id.as_str()) {
1936        return;
1937    }
1938    let mut path: Vec<&str> = Vec::new();
1939    let mut on_path: HashSet<&str> = HashSet::new();
1940    let mut cursor = start.id.as_str();
1941    loop {
1942        if settled.contains(cursor) {
1943            break;
1944        }
1945        if !on_path.insert(cursor) {
1946            let start = path.iter().position(|id| *id == cursor).unwrap_or(0);
1947            let mut loop_ids: Vec<&str> = path[start..].to_vec();
1948            loop_ids.push(cursor);
1949            f.err(format_args!("parent cycle: {}", loop_ids.join(" -> ")));
1950            break;
1951        }
1952        path.push(cursor);
1953        match by_id.get(cursor).and_then(|(_, owner)| owner.parent()) {
1954            Some(parent) if by_id.contains_key(parent) => cursor = parent,
1955            _ => break,
1956        }
1957    }
1958    settled.extend(path);
1959}
1960
1961/// Every issue referring to `target_id` through a blocker edge, a parent link,
1962/// a discovered-from or pivoted-to property, or a body mention. The relation
1963/// is named on the row.
1964///
1965/// # Errors
1966///
1967/// Returns an error if the corpus cannot be read.
1968pub fn backlinks(layout: &Layout, target_id: &str) -> Result<String> {
1969    let all = load_all(layout)?;
1970    let mut out = String::new();
1971
1972    // A deed accession is a different namespace from an issue id, and asking
1973    // what points at a product is the question you have when the product turns
1974    // out to be wrong. The corpus decides which namespace this is: a known id
1975    // is an issue, whatever it looks like, so a project actually named `deed`
1976    // keeps working. Only a token nobody minted is read as an accession.
1977    let known = all.iter().any(|(_, h)| h.id == target_id);
1978    if !known && crate::ops::is_deed_accession(target_id) {
1979        for (project, h) in &all {
1980            let relation = if h.deeds().iter().any(|cited| cited == target_id) {
1981                "cites"
1982            } else if h.body.contains(target_id) {
1983                "body mention"
1984            } else {
1985                continue;
1986            };
1987            let _ = writeln!(out, "{:<22} ({relation}) ({project})", h.id);
1988        }
1989        return Ok(out);
1990    }
1991
1992    for (project, h) in &all {
1993        if h.id == target_id {
1994            continue;
1995        }
1996        let mut hit = false;
1997        if blocker_ids(h).contains(&target_id) {
1998            let _ = writeln!(out, "{:<22} (blocked-by) ({})", h.id, project);
1999            hit = true;
2000        }
2001        if h.parent() == Some(target_id) {
2002            let _ = writeln!(out, "{:<22} (parent) ({})", h.id, project);
2003            hit = true;
2004        }
2005        if crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(target_id) {
2006            let _ = writeln!(out, "{:<22} (discovered-from) ({})", h.id, project);
2007            hit = true;
2008        }
2009        if crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(target_id) {
2010            let _ = writeln!(out, "{:<22} (pivoted-to) ({})", h.id, project);
2011            hit = true;
2012        }
2013        if !hit && h.body.contains(target_id) {
2014            let _ = writeln!(out, "{:<22} (body mention) ({})", h.id, project);
2015        }
2016    }
2017    Ok(out)
2018}
2019
2020#[cfg(test)]
2021mod tests {
2022    use super::*;
2023
2024    #[test]
2025    fn dot_labels_escape_untrusted_issue_text() {
2026        assert_eq!(dot_quoted(r#"a "quoted" title"#), r#"a \"quoted\" title"#);
2027        // A trailing backslash would otherwise escape the closing quote and
2028        // let the rest of the title become DOT syntax.
2029        assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
2030        assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
2031    }
2032}