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/// One line per (issue, date kind): deadlines first within a day, soonest day
756/// first, overdue on top with a negative day count.
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 horizon = today + chrono::Duration::days(days);
764    // kind sorts D before S so a same-day deadline outranks a scheduled start.
765    let mut rows: Vec<(NaiveDate, char, String, IssueHeading)> = Vec::new();
766    for (project, h) in load_all(layout)? {
767        if !project_selected(&project, project_filter) {
768            continue;
769        }
770        if !READY_STATES.contains(&h.state.as_str()) && h.state != "BLOCKED" {
771            continue;
772        }
773        for (kind, value) in [('D', h.deadline()), ('S', h.scheduled())] {
774            let Some(parsed) = value.and_then(parse_org_date) else {
775                continue;
776            };
777            if parsed <= horizon {
778                rows.push((parsed, kind, project.clone(), h.clone()));
779            }
780        }
781    }
782    rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.3.id.cmp(&b.3.id)));
783
784    let mut out = String::new();
785    for (date, kind, project, h) in rows {
786        let delta = (date - today).num_days();
787        let when = match delta {
788            d if d < 0 => format!("{}d overdue", -d),
789            0 => "today".to_string(),
790            d => format!("in {d}d"),
791        };
792        let label = if kind == 'D' { "deadline" } else { "scheduled" };
793        let _ = writeln!(
794            out,
795            "{date}  {label:<9} {when:<11} {:<22} {:<9} [#{}]  {}  ({})",
796            h.id, h.state, h.priority, h.title, project
797        );
798    }
799    if out.is_empty() {
800        out.push_str("nothing dated in range\n");
801    }
802    Ok(out)
803}
804
805pub(crate) fn parse_org_date(s: &str) -> Option<NaiveDate> {
806    let inner = s
807        .trim_start_matches(['<', '['])
808        .trim_end_matches(['>', ']']);
809    let token = inner.split_whitespace().next()?;
810    NaiveDate::parse_from_str(token, "%Y-%m-%d").ok()
811}
812
813/// The matching issue count and nothing else, for shell pipelines.
814///
815/// # Errors
816///
817/// Returns an error if the corpus cannot be read.
818pub fn count(
819    layout: &Layout,
820    project_filter: Option<&str>,
821    state_filter: Option<&str>,
822    ready_only: bool,
823) -> Result<String> {
824    let all = load_all(layout)?;
825    let active_blockers: HashSet<String> = if ready_only {
826        all.iter()
827            .filter(|(_, h)| h.state != "DONE" && h.state != "CANCELLED")
828            .map(|(_, h)| h.id.clone())
829            .collect()
830    } else {
831        HashSet::new()
832    };
833    let n = all
834        .iter()
835        .filter(|(project, h)| {
836            if !project_selected(project, project_filter) {
837                return false;
838            }
839            if let Some(s) = state_filter
840                && h.state != s
841            {
842                return false;
843            }
844            if ready_only {
845                if !READY_STATES.contains(&h.state.as_str()) {
846                    return false;
847                }
848                if blocker_ids(h).iter().any(|b| active_blockers.contains(*b)) {
849                    return false;
850                }
851            }
852            true
853        })
854        .count();
855    Ok(format!("{n}\n"))
856}
857
858/// One JSON object per line: every property, the logbook, the body, and the
859/// file line range. Round-trippable, and the seam other tools consume.
860///
861/// # Errors
862///
863/// Returns an error if the corpus cannot be read.
864pub fn export(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
865    let mut out = String::new();
866    for rec in load_recs(layout)? {
867        if !project_selected(&rec.project, project_filter) {
868            continue;
869        }
870        let _ = writeln!(
871            out,
872            "{}",
873            export_row(&rec.project, rec.heading, &rec.tag_settings)
874        );
875    }
876    Ok(out)
877}
878
879/// The same lines as [`export`], grouped by project, from one read.
880///
881/// `export` filters a whole-corpus read down to one project, so digesting
882/// every project separately re-read the corpus once per project: quadratic
883/// in the project count, and six seconds on a tracker with a hundred of
884/// them. The rows are built by the same function, so a project's text here
885/// is byte for byte what `export(layout, Some(project))` returns, and the
886/// digests taken from it do not move.
887///
888/// # Errors
889///
890/// Returns an error if the corpus cannot be read.
891pub fn export_by_project(layout: &Layout) -> Result<BTreeMap<String, String>> {
892    let mut out: BTreeMap<String, String> = BTreeMap::new();
893    for rec in load_recs(layout)? {
894        let row = export_row(&rec.project, rec.heading, &rec.tag_settings);
895        let _ = writeln!(out.entry(rec.project).or_default(), "{row}");
896    }
897    Ok(out)
898}
899
900fn export_row(
901    project: &str,
902    h: IssueHeading,
903    settings: &crate::org::TagSettings,
904) -> serde_json::Value {
905    let logbook: Vec<serde_json::Value> = h
906        .logbook
907        .iter()
908        .map(|e| {
909            let mut row = serde_json::json!({
910                "timestamp": e.timestamp,
911                "from": e.from_state,
912                "to": e.to_state,
913                "note": e.note,
914            });
915            if let Some(raw) = &e.raw {
916                row["raw"] = serde_json::Value::String(raw.clone());
917            }
918            row
919        })
920        .collect();
921    serde_json::json!({
922        "id": h.id,
923        "project": project,
924        "title": h.title,
925        "state": h.state,
926        "priority": h.priority.to_string(),
927        "properties": h.properties,
928        // Typed beside the drawer rather than only inside it, so a consumer of
929        // the export reads the field the socket already hands over typed
930        // instead of splitting a drawer string on whichever separator the
931        // author happened to use. `properties` keeps `:DEEDS:` as well: a
932        // reader that wants the drawer verbatim should still get it.
933        "deeds": h.deeds(),
934        "org_tags": h.org_tags,
935        "tags": h.tags(),
936        "all_tags": settings.all_tags(&h.tags()),
937        "logbook": logbook,
938        "body": h.body,
939        "line_start": h.line_start,
940        "line_end": h.line_end,
941    })
942}
943
944/// Children and blockers below `root_id`, as indented text or Graphviz DOT.
945///
946/// # Errors
947///
948/// Returns an error if the corpus cannot be read, `root_id` is not in it, or
949/// `format` is not `ascii`, `text`, or `dot`.
950pub fn tree(layout: &Layout, root_id: &str, format: &str) -> Result<String> {
951    let all = load_all(layout)?;
952    let graph = GraphIndex::new(&all);
953    let Some(root_heading) = graph.by_id.get(root_id) else {
954        return Err(Error::IssueNotFound {
955            id: root_id.to_string(),
956        });
957    };
958    let mut out = String::new();
959    let root = root_heading.id.as_str();
960    match format {
961        "ascii" | "text" => tree_ascii(&graph, root, 0, &mut HashSet::new(), &mut out),
962        "dot" => tree_dot(&graph, root, &mut out),
963        _ => return Err(anyhow!("unknown format {format:?}; allowed: ascii, dot").into()),
964    }
965    Ok(out)
966}
967
968fn tree_ascii<'a>(
969    graph: &GraphIndex<'a>,
970    id: &'a str,
971    depth: usize,
972    seen: &mut HashSet<&'a str>,
973    out: &mut String,
974) {
975    if !seen.insert(id) {
976        let _ = writeln!(out, "{}{id} (cycle, stopping)", "  ".repeat(depth));
977        return;
978    }
979    let Some(h) = graph.by_id.get(id) else {
980        let _ = writeln!(out, "{}{id} (missing)", "  ".repeat(depth));
981        return;
982    };
983    let _ = writeln!(
984        out,
985        "{}{id} {:<9} [#{}]  {}",
986        "  ".repeat(depth),
987        h.state,
988        h.priority,
989        h.title
990    );
991    if let Some(blockers) = graph.blockers.get(id) {
992        for blocker in blockers {
993            let _ = writeln!(out, "{}* blocked-by {blocker}", "  ".repeat(depth + 1));
994        }
995    }
996    if let Some(kids) = graph.children.get(id) {
997        for k in kids {
998            tree_ascii(graph, k, depth + 1, seen, out);
999        }
1000    }
1001}
1002
1003/// Escape text for a Graphviz quoted string. Backslash goes first, or the
1004/// escape introduced for a quote is itself re-escaped; a raw newline would end
1005/// the statement early. Titles and ids are whatever someone committed to the
1006/// tracker, so neither is trusted here.
1007pub(crate) fn dot_quoted(text: &str) -> String {
1008    text.replace('\\', "\\\\")
1009        .replace('"', "\\\"")
1010        .replace('\n', "\\n")
1011        .replace('\r', "")
1012}
1013
1014fn tree_dot<'a>(graph: &GraphIndex<'a>, root_id: &str, out: &mut String) {
1015    let _ = writeln!(out, "digraph vissue_tree {{");
1016    let _ = writeln!(out, "  rankdir=LR;");
1017    let _ = writeln!(
1018        out,
1019        "  node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
1020    );
1021    let mut visited: HashSet<&str> = HashSet::new();
1022    let mut stack = vec![graph.by_id.get(root_id).unwrap().id.as_str()];
1023    while let Some(id) = stack.pop() {
1024        if !visited.insert(id) {
1025            continue;
1026        }
1027        if let Some(h) = graph.by_id.get(id) {
1028            let _ = writeln!(
1029                out,
1030                "  \"{}\" [label=\"{}\\n{} [#{}]\"];",
1031                dot_quoted(&h.id),
1032                dot_quoted(&h.title),
1033                dot_quoted(&h.state),
1034                dot_quoted(&h.priority.to_string())
1035            );
1036            if let Some(kids) = graph.children.get(id) {
1037                for k in kids {
1038                    let _ = writeln!(
1039                        out,
1040                        "  \"{}\" -> \"{}\" [color=\"#00897B\"];",
1041                        dot_quoted(&h.id),
1042                        dot_quoted(k)
1043                    );
1044                    stack.push(k);
1045                }
1046            }
1047            if let Some(blockers) = graph.blockers.get(id) {
1048                for b in blockers {
1049                    let _ = writeln!(
1050                        out,
1051                        "  \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
1052                        dot_quoted(b),
1053                        dot_quoted(&h.id)
1054                    );
1055                    stack.push(b);
1056                }
1057            }
1058        }
1059    }
1060    let _ = writeln!(out, "}}");
1061}
1062
1063/// Cycles in the blocker graph, one per line, or a line saying there are none.
1064///
1065/// # Errors
1066///
1067/// Returns an error if the corpus cannot be read.
1068pub fn cycles(layout: &Layout) -> Result<String> {
1069    let all = load_all(layout)?;
1070    let graph = GraphIndex::new(&all);
1071
1072    // Colored depth-first search over BLOCKED_BY edges. Grey marks the
1073    // current stack, black a finished node, so a shared blocker reached
1074    // from two branches (a diamond) is never mistaken for a cycle.
1075    const WHITE: u8 = 0;
1076    const GREY: u8 = 1;
1077    const BLACK: u8 = 2;
1078    let mut color: HashMap<&str, u8> = HashMap::new();
1079    let mut found: Vec<Vec<String>> = Vec::new();
1080
1081    fn dfs<'a>(
1082        id: &'a str,
1083        graph: &GraphIndex<'a>,
1084        color: &mut HashMap<&'a str, u8>,
1085        path: &mut Vec<&'a str>,
1086        found: &mut Vec<Vec<String>>,
1087    ) {
1088        color.insert(id, GREY);
1089        path.push(id);
1090        if let Some(blockers) = graph.blockers.get(id) {
1091            for b in blockers {
1092                if !graph.by_id.contains_key(b) {
1093                    continue; // a broken edge cannot close a loop; `check` reports it
1094                }
1095                match color.get(b).copied().unwrap_or(WHITE) {
1096                    GREY => {
1097                        let start = path.iter().position(|&x| x == *b).unwrap();
1098                        let mut cycle: Vec<String> =
1099                            path[start..].iter().map(|s| s.to_string()).collect();
1100                        // Rotate so the smallest id leads: one canonical form
1101                        // per cycle no matter where the walk entered it.
1102                        let min = cycle
1103                            .iter()
1104                            .enumerate()
1105                            .min_by(|a, b| a.1.cmp(b.1))
1106                            .map(|(i, _)| i)
1107                            .unwrap();
1108                        cycle.rotate_left(min);
1109                        cycle.push(cycle[0].clone());
1110                        if !found.contains(&cycle) {
1111                            found.push(cycle);
1112                        }
1113                    }
1114                    WHITE => dfs(b, graph, color, path, found),
1115                    _ => {}
1116                }
1117            }
1118        }
1119        path.pop();
1120        color.insert(id, BLACK);
1121    }
1122
1123    for (_, start) in &all {
1124        if color.get(start.id.as_str()).copied().unwrap_or(WHITE) == WHITE {
1125            let mut path = Vec::new();
1126            dfs(start.id.as_str(), &graph, &mut color, &mut path, &mut found);
1127        }
1128    }
1129
1130    let mut out = String::new();
1131    if found.is_empty() {
1132        let _ = writeln!(out, "no cycles");
1133    } else {
1134        for cycle in found {
1135            let _ = writeln!(out, "{}", cycle.join(" -> "));
1136        }
1137    }
1138    Ok(out)
1139}
1140
1141/// Transitive blocker ancestors, limited to a bounded number of hops.
1142///
1143/// # Errors
1144///
1145/// Returns an error if the corpus cannot be read, the blocker graph cannot be
1146/// built, or `id` is not in it.
1147pub fn ancestors(layout: &Layout, id: &str, depth: usize) -> Result<String> {
1148    let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
1149    let mut out = String::new();
1150    for (distance, ancestor) in graph.ancestors(id, depth)? {
1151        writeln!(out, "{distance} {ancestor}")?;
1152    }
1153    Ok(out)
1154}
1155
1156/// Transitive issues waiting on this issue, limited to a bounded number of hops.
1157///
1158/// # Errors
1159///
1160/// Returns an error if the corpus cannot be read, the blocker graph cannot be
1161/// built, or `id` is not in it.
1162pub fn impact(layout: &Layout, id: &str, depth: usize) -> Result<String> {
1163    let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
1164    let mut out = String::new();
1165    for (distance, descendant) in graph.descendants(id, depth)? {
1166        writeln!(out, "{distance} {descendant}")?;
1167    }
1168    Ok(out)
1169}
1170
1171/// The whole blocker and parent graph as Graphviz DOT. Node fill encodes state.
1172///
1173/// # Errors
1174///
1175/// Returns an error if the corpus cannot be read.
1176/// The lines a DOT document opens with, up to and including the graph
1177/// attributes. Exposed for the same reason as [`ROADMAP_HEADER`]: a caller
1178/// drawing several projects as one graph writes them once. Concatenating whole
1179/// documents gives `dot` a file of many graphs, and it renders the first.
1180pub const GRAPH_HEADER: &str = concat!(
1181    "digraph vissue_graph {\n",
1182    "  rankdir=LR;\n",
1183    "  node [shape=box, fontname=\"Jost\", style=filled];\n",
1184    "  edge [fontname=\"Jost\"];\n"
1185);
1186
1187/// The line that closes a DOT document.
1188pub const GRAPH_FOOTER: &str = "}\n";
1189
1190/// A DOT graph of the corpus: one node per issue, blocker and parent edges.
1191///
1192/// # Errors
1193///
1194/// Returns an error if the corpus cannot be read.
1195pub fn graph(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1196    Ok(format!(
1197        "{GRAPH_HEADER}{}{GRAPH_FOOTER}",
1198        graph_body(layout, project_filter)?
1199    ))
1200}
1201
1202/// The nodes and edges of the graph, without the enclosing `digraph` block.
1203///
1204/// # Errors
1205///
1206/// Returns an error if the corpus cannot be read.
1207pub fn graph_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1208    let all = load_all(layout)?;
1209    let graph = GraphIndex::new(&all);
1210    let mut out = String::new();
1211    for (project, h) in &all {
1212        if !project_selected(project, project_filter) {
1213            continue;
1214        }
1215        let fill = match h.state.as_str() {
1216            "DONE" => "#A5D6A7",
1217            "CANCELLED" => "#CFD8DC",
1218            "BLOCKED" => "#FFCC80",
1219            "STARTED" => "#80CBC4",
1220            _ => "#E0F2F1",
1221        };
1222        let _ = writeln!(
1223            out,
1224            "  \"{}\" [label=\"{}\\n{} [#{}]\", fillcolor=\"{}\"];",
1225            dot_quoted(&h.id),
1226            dot_quoted(&h.title),
1227            dot_quoted(&h.state),
1228            dot_quoted(&h.priority.to_string()),
1229            fill
1230        );
1231    }
1232    for (project, h) in &all {
1233        if !project_selected(project, project_filter) {
1234            continue;
1235        }
1236        if let Some(blockers) = graph.blockers.get(h.id.as_str()) {
1237            for b in blockers {
1238                writeln!(
1239                    out,
1240                    "  \"{}\" -> \"{}\" [color=\"#FF7043\"];",
1241                    dot_quoted(b),
1242                    dot_quoted(&h.id)
1243                )?;
1244            }
1245        }
1246        if let Some(parent) = h.parent() {
1247            writeln!(
1248                out,
1249                "  \"{}\" -> \"{}\" [color=\"#00897B\", style=dashed];",
1250                dot_quoted(parent),
1251                dot_quoted(&h.id)
1252            )?;
1253        }
1254    }
1255    Ok(out)
1256}
1257
1258/// A markdown roadmap grouped by project and state. Closed items collapse into
1259/// one section so the document stays about live work.
1260///
1261/// # Errors
1262///
1263/// Returns an error if the corpus cannot be read.
1264/// The document furniture a roadmap opens with. Exposed because a caller that
1265/// assembles one roadmap out of several projects writes it once rather than once
1266/// per project: concatenating whole roadmaps puts a title above every project
1267/// section, so a corpus of six carries six titles in one document.
1268pub const ROADMAP_HEADER: &str = concat!(
1269    "# Roadmap\n\n",
1270    "Generated from `vissue roadmap`. Source of truth lives in the per-project issues.org files.\n\n"
1271);
1272
1273/// A markdown roadmap of active and closed work, with its title.
1274///
1275/// # Errors
1276///
1277/// Returns an error if the corpus cannot be read.
1278pub fn roadmap(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1279    Ok(format!(
1280        "{ROADMAP_HEADER}{}",
1281        roadmap_body(layout, project_filter)?
1282    ))
1283}
1284
1285/// The roadmap's project sections, without the document title.
1286///
1287/// # Errors
1288///
1289/// Returns an error if the corpus cannot be read.
1290pub fn roadmap_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1291    let all = load_all(layout)?;
1292    let mut by_project: BTreeMap<String, Vec<&IssueHeading>> = BTreeMap::new();
1293    for (project, h) in &all {
1294        if !project_selected(project, project_filter) {
1295            continue;
1296        }
1297        by_project.entry(project.clone()).or_default().push(h);
1298    }
1299    let mut out = String::new();
1300    for (project, mut headings) in by_project {
1301        headings.sort_by(|a, b| {
1302            a.priority
1303                .cmp(&b.priority)
1304                .then_with(|| a.state.cmp(&b.state))
1305                .then_with(|| a.id.cmp(&b.id))
1306        });
1307        let buckets = ["STARTED", "TODO", "BLOCKED"];
1308        let active: Vec<&&IssueHeading> = headings
1309            .iter()
1310            .filter(|h| buckets.contains(&h.state.as_str()))
1311            .collect();
1312        let closed: Vec<&&IssueHeading> = headings
1313            .iter()
1314            .filter(|h| h.state == "DONE" || h.state == "CANCELLED")
1315            .collect();
1316        if active.is_empty() && closed.is_empty() {
1317            continue;
1318        }
1319        writeln!(out, "## {project}")?;
1320        writeln!(out)?;
1321        for state in buckets {
1322            let in_state: Vec<&&IssueHeading> = active
1323                .iter()
1324                .copied()
1325                .filter(|h| h.state == state)
1326                .collect();
1327            if in_state.is_empty() {
1328                continue;
1329            }
1330            writeln!(out, "### {state}")?;
1331            writeln!(out)?;
1332            for h in in_state {
1333                let deadline = h
1334                    .deadline()
1335                    .map(|d| format!(" :: deadline {d}"))
1336                    .unwrap_or_default();
1337                let blockers = blocker_ids(h);
1338                let blocked_by = if blockers.is_empty() {
1339                    String::new()
1340                } else {
1341                    format!(" :: blocked by {}", blockers.join(", "))
1342                };
1343                writeln!(
1344                    out,
1345                    "- **{}** [#{}] {}{}{}",
1346                    h.id, h.priority, h.title, deadline, blocked_by
1347                )?;
1348            }
1349            writeln!(out)?;
1350        }
1351        if !closed.is_empty() {
1352            writeln!(out, "### Closed ({} items)", closed.len())?;
1353            writeln!(out)?;
1354            for h in closed.iter().take(10) {
1355                writeln!(
1356                    out,
1357                    "- {} [#{}] {} ({})",
1358                    h.id, h.priority, h.title, h.state
1359                )?;
1360            }
1361            if closed.len() > 10 {
1362                writeln!(out, "- ... and {} more", closed.len() - 10)?;
1363            }
1364            writeln!(out)?;
1365        }
1366    }
1367    Ok(out)
1368}
1369
1370/// Does this body say *this issue* was rejected, as opposed to using the word.
1371///
1372/// `contains("rejected")` cannot tell the two apart, and the difference is the
1373/// whole finding. Every bug report about input validation says it: "silently
1374/// corrupted rather than rejected", "ignored rather than enforced or rejected".
1375/// A design note says it too: "a hand-written parser is rejected as strictly
1376/// dominated". Three issues in one corpus were flagged for exactly those, all
1377/// of them worked and closed properly, and a check that cries wolf about closed
1378/// issues is a check nobody re-reads.
1379///
1380/// So this looks for the shapes a rejection is actually written in: the tool's
1381/// own phrasing, a redirect, or a heading that says so.
1382fn looks_like_reject_prose(body: &str) -> bool {
1383    let lower = body.to_ascii_lowercase();
1384    const CLOSING: &[&str] = &[
1385        "vissue reject",
1386        "superseded by",
1387        "rejected in favour",
1388        "rejected in favor",
1389        "rejected as a duplicate",
1390        "closed as a duplicate",
1391        "closed as duplicate",
1392        "not doing this",
1393        "rejected this",
1394        "rejected: ",
1395    ];
1396    if CLOSING.iter().any(|phrase| lower.contains(phrase)) {
1397        return true;
1398    }
1399    // A heading that names the outcome, which is where a hand-written rejection
1400    // goes when it is not one of the phrases above.
1401    //
1402    // "superseded" and not "supersede": the participle says this issue was
1403    // replaced, and the third person says it replaced others. A corpus here has
1404    // an issue whose "** Supersedes" section rolls up seven others it did not
1405    // close, and reading that as its own rejection is the false positive this
1406    // function exists to stop. Do not shorten the stem.
1407    lower.lines().any(|line| {
1408        line.starts_with('*')
1409            && (line.contains("rejected")
1410                || line.contains("superseded")
1411                || line.contains("reject:"))
1412    })
1413}
1414
1415/// Does the prose around this link claim the relation the properties name.
1416///
1417/// A body mentions other issues for every reason there is: a parent lists its
1418/// children, an umbrella rolls up what it does not close, a note says "see
1419/// also". Warning that any of those lacks a `DISCOVERED_FROM` asks for an edge
1420/// nobody can honestly supply, and the answer is a wrong edge or a warning that
1421/// gets ignored. One corpus had twenty-two of these and not one was a discovery.
1422///
1423/// So the warning is for a body that says discovery or a pivot and has no edge
1424/// to match, which is the case the properties exist for.
1425fn claims_discovery_or_pivot(body: &str, linked: &str) -> bool {
1426    const CLAIMS: &[&str] = &[
1427        "discovered from",
1428        "discovered while",
1429        "discovered during",
1430        "found while",
1431        "filed from",
1432        "split from",
1433        "pivoted to",
1434        "pivots to",
1435        "pivoted from",
1436        "replaced by",
1437        "moved to",
1438    ];
1439    let needle = format!("id:{linked}");
1440    let lower = body.to_ascii_lowercase();
1441    let lower_needle = needle.to_ascii_lowercase();
1442    // The claim has to be near the link rather than anywhere in the body: a long
1443    // issue can say "discovered while auditing" in one section and link three
1444    // unrelated ids in another.
1445    let window = 240;
1446    let mut from = 0;
1447    while let Some(at) = lower[from..].find(&lower_needle) {
1448        let hit = from + at;
1449        let start = hit.saturating_sub(window);
1450        let end = (hit + lower_needle.len() + window).min(lower.len());
1451        let near = &lower[floor_char_boundary(&lower, start)..ceil_char_boundary(&lower, end)];
1452        if CLAIMS.iter().any(|phrase| near.contains(phrase)) {
1453            return true;
1454        }
1455        from = hit + lower_needle.len();
1456    }
1457    false
1458}
1459
1460fn floor_char_boundary(s: &str, mut i: usize) -> usize {
1461    while i > 0 && !s.is_char_boundary(i) {
1462        i -= 1;
1463    }
1464    i
1465}
1466
1467fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
1468    while i < s.len() && !s.is_char_boundary(i) {
1469        i += 1;
1470    }
1471    i
1472}
1473
1474/// Is the relation between these two already held as an edge, in either
1475/// direction.
1476///
1477/// Discovery and a pivot are not the only relations people write. A parent
1478/// mentioning a child, or an issue naming what blocks it, is a stated relation
1479/// the tracker already holds, and warning that it lacks a DISCOVERED_FROM asks
1480/// for an edge nobody can honestly supply: the answer is either a wrong edge or
1481/// a warning that gets ignored.
1482fn edge_connects(all: &[(String, IssueHeading)], a: &str, b: &str) -> bool {
1483    all.iter().any(|(_, h)| {
1484        let far = if h.id == a {
1485            b
1486        } else if h.id == b {
1487            a
1488        } else {
1489            return false;
1490        };
1491        [
1492            crate::props::DISCOVERED_FROM,
1493            crate::props::PIVOTED_TO,
1494            crate::props::PARENT,
1495            crate::props::BLOCKED_BY,
1496            crate::props::EDNA_BLOCKER,
1497        ]
1498        .iter()
1499        .any(|key| {
1500            crate::props::get(&h.properties, key)
1501                .is_some_and(|value| value.split(&[',', ' '][..]).any(|part| part.trim() == far))
1502        })
1503    })
1504}
1505
1506/// Outcome of [`check`]: the findings, and how many were errors.
1507#[derive(Debug, Clone)]
1508pub struct CheckReport {
1509    /// Rendered findings, ending in a summary line.
1510    pub text: String,
1511    /// Count of `[err]` findings.
1512    pub errors: usize,
1513    /// Count of `[warn]` findings.
1514    pub warnings: usize,
1515}
1516
1517/// Findings as they accumulate, each carrying its own severity.
1518///
1519/// The counts are the point: `check` exits non-zero on an error, and a caller reads
1520/// the two numbers without reading the prose. Keeping them beside the text is what
1521/// stops a finding being written without being counted, which is a silent way for the
1522/// exit code to disagree with the report.
1523#[derive(Default)]
1524struct Findings {
1525    text: String,
1526    errors: usize,
1527    warnings: usize,
1528}
1529
1530impl Findings {
1531    /// A finding a reader has to fix. Writing to a `String` cannot fail.
1532    fn err(&mut self, what: std::fmt::Arguments) {
1533        let _ = writeln!(self.text, "[err]  {what}");
1534        self.errors += 1;
1535    }
1536
1537    /// A finding a reader may leave, which does not change the exit code.
1538    fn warn(&mut self, what: std::fmt::Arguments) {
1539        let _ = writeln!(self.text, "[warn] {what}");
1540        self.warnings += 1;
1541    }
1542}
1543
1544/// Validate the corpus: every parent and blocker id resolves, dates parse, open
1545/// issues carry a creation date, and ids are unique across projects.
1546///
1547/// # Errors
1548///
1549/// Returns an error if the corpus cannot be read.
1550pub fn check(layout: &Layout) -> Result<CheckReport> {
1551    let all = load_all(layout)?;
1552
1553    // A parent is usually another issue, and those ids are already in hand.
1554    // Only the ones that are not send us looking through the rest of the
1555    // tree, which on a tracker sharing a root with a notes vault is most of
1556    // the bytes on disk.
1557    let issue_ids: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1558    let unresolved: HashSet<String> = all
1559        .iter()
1560        .filter_map(|(_, h)| h.parent())
1561        .filter(|p| !issue_ids.contains(p))
1562        .map(str::to_string)
1563        .collect();
1564    let elsewhere = find_org_ids(layout, &unresolved)?;
1565    let resolves = |id: &str| issue_ids.contains(id) || elsewhere.contains(id);
1566
1567    let mut f = Findings::default();
1568
1569    let mut by_id: HashMap<String, (String, &IssueHeading)> = HashMap::new();
1570    for (project, h) in &all {
1571        if let Some(prev) = by_id.insert(h.id.clone(), (project.clone(), h)) {
1572            // An error, not a note: an id that names two issues makes every
1573            // blocker and parent edge pointing at it ambiguous.
1574            f.err(format_args!(
1575                "duplicate id: {} appears in {} and {}",
1576                h.id, prev.0, project
1577            ));
1578        }
1579    }
1580
1581    for project in list_projects(layout)? {
1582        check_project(&project, layout, &mut f)?;
1583    }
1584
1585    for (project, h) in &all {
1586        check_issue(project, h, &resolves, &by_id, &mut f);
1587    }
1588
1589    let known: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1590    for (project, h) in &all {
1591        check_provenance_links(&all, project, h, &known, &mut f);
1592    }
1593
1594    // A :PARENT: loop passes every edge check, because each id resolves, yet
1595    // it makes the hierarchy unwalkable: `tree` stops on it and prints
1596    // "(cycle, stopping)". Naming it here is what keeps a corpus that holds
1597    // one from reading as clean.
1598    let mut settled: HashSet<&str> = HashSet::new();
1599    for (_, h) in &all {
1600        check_parent_cycle(h, &by_id, &mut settled, &mut f);
1601    }
1602
1603    if f.errors == 0
1604        && let Err(err) = DependencyGraph::from_issues(&all)
1605    {
1606        f.err(format_args!("blocker graph: {err}"));
1607    }
1608
1609    let _ = writeln!(f.text);
1610    let projects = list_projects(layout)?.len();
1611    let _ = writeln!(
1612        f.text,
1613        "checked {} issue(s) across {projects} project(s): {} error(s), {} warning(s)",
1614        all.len(),
1615        f.errors,
1616        f.warnings
1617    );
1618    Ok(CheckReport {
1619        text: f.text,
1620        errors: f.errors,
1621        warnings: f.warnings,
1622    })
1623}
1624
1625/// Validate one project's file: its preamble, and the headings it holds.
1626///
1627/// The one cohesive thing in `check` that is about a file rather than about the
1628/// corpus. Everything here reads one project's own preamble and its own headings and
1629/// needs none of the others.
1630///
1631/// # Errors
1632///
1633/// Returns an error if the project's file cannot be read or parsed.
1634fn check_project(project: &str, layout: &Layout, f: &mut Findings) -> Result<()> {
1635    let path = layout.project_issues_path(project);
1636    let doc = IssueDoc::parse_file(project, &path)?;
1637    check_preamble(project, &doc, &path, f);
1638    // The loader skips a heading a calendar sync owns, so the parsed headings cannot
1639    // hold one and counting them there counted nothing. The heading is still in the
1640    // file, and one the tracker will not touch is the surprise worth reporting, so the
1641    // file is what gets counted.
1642    let gcal_ids = crate::store::org_ids(&std::fs::read_to_string(&path)?)
1643        .filter(|id| crate::org::is_gcal_event_id(id))
1644        .count();
1645    if gcal_ids > 0 {
1646        f.err(format_args!(
1647            "{project}: {gcal_ids} heading(s) use an org-gcal event id as :ID:"
1648        ));
1649    }
1650    check_headings(project, &doc, f);
1651    Ok(())
1652}
1653
1654/// What Org needs from the file's preamble to render the tracker as intended.
1655///
1656/// Each of these is a keyword whose absence Org does not complain about and a reader
1657/// notices later: an agenda that labels every row `issues`, a publish that exports
1658/// the tracker, a priority cookie outside the range the file declares.
1659fn check_preamble(project: &str, doc: &IssueDoc, path: &std::path::Path, f: &mut Findings) {
1660    match crate::org::protocol_from_preamble(&doc.preamble) {
1661        None => {
1662            f.warn(format_args!(
1663                "{project}: preamble has no #+VISSUE: protocol stamp"
1664            ));
1665        }
1666        Some(n) if n < crate::org::PROTOCOL_VERSION => {
1667            f.warn(format_args!(
1668                "{project}: #+VISSUE: {n} is behind protocol {}",
1669                crate::org::PROTOCOL_VERSION
1670            ));
1671        }
1672        Some(n) if n > crate::org::PROTOCOL_VERSION => {
1673            f.err(format_args!(
1674                "{project}: #+VISSUE: {n} is newer than this vissue (protocol {})",
1675                crate::org::PROTOCOL_VERSION
1676            ));
1677        }
1678        Some(_) => {}
1679    }
1680    if !crate::org::preamble_has_keyword(&doc.preamble, "CATEGORY") {
1681        f.warn(format_args!(
1682            "{project}: preamble has no #+CATEGORY: (org-agenda labels every row \"issues\")"
1683        ));
1684    }
1685    if !crate::org::preamble_has_keyword(&doc.preamble, "FILETAGS") {
1686        f.warn(format_args!("{project}: preamble has no #+FILETAGS:"));
1687    } else if !doc
1688        .tag_settings
1689        .filetags
1690        .iter()
1691        .any(|t| t.eq_ignore_ascii_case("noexport"))
1692    {
1693        f.warn(format_args!(
1694            "{project}: #+FILETAGS: has no noexport; a vault publish will export this tracker"
1695        ));
1696    }
1697    if !crate::org::preamble_has_keyword(&doc.preamble, "TAGS") {
1698        f.warn(format_args!(
1699            "{project}: preamble has no #+TAGS:; Emacs fast tag selection has no type group"
1700        ));
1701    }
1702    if !crate::org::preamble_has_keyword(
1703        &crate::org::merge_setupfile_settings(&doc.preamble, path.parent()),
1704        "PRIORITIES",
1705    ) {
1706        f.warn(format_args!(
1707            "{project}: preamble has no #+PRIORITIES:; cookies default to C and the range is A..C"
1708        ));
1709    }
1710}
1711
1712/// What the tracker needs from each heading in the file.
1713///
1714/// Counted rather than named one by one, because a file with forty headings that all
1715/// put `:PRIORITY:` in the drawer wants one line saying so, not forty.
1716fn check_headings(project: &str, doc: &IssueDoc, f: &mut Findings) {
1717    let spec = doc.priority_spec();
1718    let mut type_not_tagged = 0usize;
1719    let mut exclusive_clash = 0usize;
1720    let mut priority_out_of_range = 0usize;
1721    let mut ordered_skip = 0usize;
1722    let mut done_with_open_children = 0usize;
1723    let mut priority_in_drawer = 0usize;
1724    let mut blockedby_typo = 0usize;
1725    let mut blocker_as_ids = 0usize;
1726    let mut computed_specials = 0usize;
1727    let mut bad_effort = 0usize;
1728    for h in &doc.headings {
1729        if let Some(kind) = crate::props::get(&h.properties, crate::props::TYPE) {
1730            let kind = kind.trim();
1731            if !kind.is_empty()
1732                && kind.chars().all(crate::model::is_org_tag_char)
1733                && !h.org_tags.iter().any(|t| t == kind)
1734            {
1735                type_not_tagged += 1;
1736            }
1737        }
1738        for group in &doc.tag_settings.exclusive {
1739            let hits = group
1740                .iter()
1741                .filter(|name| h.org_tags.iter().any(|t| t == *name))
1742                .count();
1743            if hits > 1 {
1744                exclusive_clash += 1;
1745                break;
1746            }
1747        }
1748        if !spec.contains(h.priority) {
1749            priority_out_of_range += 1;
1750        }
1751        if h.properties.contains_key("PRIORITY") {
1752            priority_in_drawer += 1;
1753        }
1754        if h.properties.contains_key("BLOCKEDBY") {
1755            blockedby_typo += 1;
1756        }
1757        if let Some(raw) = h.properties.get("BLOCKER")
1758            && !crate::org::is_edna_blocker(raw)
1759        {
1760            blocker_as_ids += 1;
1761        }
1762        if crate::org::COMPUTED_SPECIALS
1763            .iter()
1764            .any(|k| *k != "PRIORITY" && h.properties.contains_key(*k))
1765        {
1766            computed_specials += 1;
1767        }
1768        if let Some(effort) = h.effort()
1769            && !crate::org::is_org_effort(effort)
1770        {
1771            bad_effort += 1;
1772        }
1773        if let Some(pid) = h.parent()
1774            && let Some(parent) = doc.headings.iter().find(|p| p.id == pid)
1775            && crate::org::org_property_is_set(&parent.properties, "ORDERED")
1776            && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1777        {
1778            let earlier_open = doc.headings.iter().any(|sib| {
1779                sib.parent() == Some(pid)
1780                    && sib.line_start < h.line_start
1781                    && sib.state != "DONE"
1782                    && sib.state != "CANCELLED"
1783            });
1784            if earlier_open && (h.state == "STARTED" || h.state == "DONE") {
1785                ordered_skip += 1;
1786            }
1787        }
1788        if h.state == "DONE"
1789            && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1790            && doc.headings.iter().any(|c| {
1791                c.parent() == Some(h.id.as_str()) && c.state != "DONE" && c.state != "CANCELLED"
1792            })
1793        {
1794            done_with_open_children += 1;
1795        }
1796    }
1797    if type_not_tagged > 0 {
1798        f.warn(format_args!("{project}: {type_not_tagged} heading(s) have :TYPE: that is a legal Org tag but is not on the heading"));
1799    }
1800    if exclusive_clash > 0 {
1801        f.warn(format_args!("{project}: {exclusive_clash} heading(s) carry more than one tag from a #+TAGS: exclusive group"));
1802    }
1803    if priority_in_drawer > 0 {
1804        f.warn(format_args!("{project}: {priority_in_drawer} heading(s) put :PRIORITY: in the drawer; Org reads the [#A] cookie"));
1805    }
1806    if blockedby_typo > 0 {
1807        f.warn(format_args!(
1808            "{project}: {blockedby_typo} heading(s) use :BLOCKEDBY: instead of :BLOCKED_BY:"
1809        ));
1810    }
1811    if blocker_as_ids > 0 {
1812        f.warn(format_args!("{project}: {blocker_as_ids} heading(s) use :BLOCKER: as a bare id list; a rewrite folds them into :BLOCKED_BY:"));
1813    }
1814    if computed_specials > 0 {
1815        f.warn(format_args!("{project}: {computed_specials} heading(s) set a computed Org special (TODO/ITEM/TAGS/...) in the drawer; Org ignores it"));
1816    }
1817    if bad_effort > 0 {
1818        f.warn(format_args!(
1819            "{project}: {bad_effort} heading(s) have an Effort value Org will not parse"
1820        ));
1821    }
1822    if priority_out_of_range > 0 {
1823        f.warn(format_args!(
1824            "{project}: {priority_out_of_range} heading(s) have a [#prio] outside #+PRIORITIES:"
1825        ));
1826    }
1827    if ordered_skip > 0 {
1828        f.warn(format_args!("{project}: {ordered_skip} heading(s) started or closed before an earlier ORDERED sibling"));
1829    }
1830    if done_with_open_children > 0 {
1831        f.warn(format_args!("{project}: {done_with_open_children} DONE heading(s) still have open children (Org ORDERED / todo-dependencies)"));
1832    }
1833}
1834
1835/// Validate one issue on its own: its edges resolve, its dates parse, and its state
1836/// agrees with what the drawer and the body say.
1837fn check_issue<'a>(
1838    project: &str,
1839    h: &'a IssueHeading,
1840    resolves: &impl Fn(&str) -> bool,
1841    by_id: &HashMap<String, (String, &'a IssueHeading)>,
1842    f: &mut Findings,
1843) {
1844    if let Some(parent) = h.parent()
1845        && !resolves(parent)
1846    {
1847        f.err(format_args!(
1848            "{} (in {}) :PARENT: {} -> not found",
1849            h.id, project, parent
1850        ));
1851    }
1852    for blk in blocker_ids(h) {
1853        if !by_id.contains_key(blk) {
1854            f.err(format_args!(
1855                "{} (in {}) :BLOCKED_BY: {} -> not found",
1856                h.id, project, blk
1857            ));
1858        }
1859    }
1860    // A citation nothing can be asked for fails wherever it is finally opened,
1861    // which is a different process on a different day. `deed` refuses one; a
1862    // hand-edited drawer is how one gets in anyway.
1863    for cited in h.deeds() {
1864        if !crate::ops::is_deed_accession(&cited) {
1865            f.warn(format_args!(
1866                "{} (in {}) :DEEDS: {} -> not a deed accession",
1867                h.id, project, cited
1868            ));
1869        }
1870    }
1871    if let Some(d) = h.deadline()
1872        && parse_org_date(d).is_none()
1873    {
1874        f.err(format_args!(
1875            "{} (in {}) :DEADLINE: {} -> unparseable",
1876            h.id, project, d
1877        ));
1878    }
1879    if let Some(s) = h.scheduled()
1880        && parse_org_date(s).is_none()
1881    {
1882        f.err(format_args!(
1883            "{} (in {}) :SCHEDULED: {} -> unparseable",
1884            h.id, project, s
1885        ));
1886    }
1887    if matches!(h.state.as_str(), "TODO" | "STARTED") && !h.properties.contains_key("CREATED") {
1888        f.warn(format_args!(
1889            "{} (in {}) state={} but :CREATED: is missing",
1890            h.id, project, h.state
1891        ));
1892    }
1893    if h.state == "DONE" && looks_like_reject_prose(&h.body) {
1894        f.warn(format_args!(
1895            "{} (in {}) is DONE but the body reads as a reject",
1896            h.id, project
1897        ));
1898    }
1899    if crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_some() {
1900        f.warn(format_args!(
1901            "{} (in {}) holds {} and sibling {}",
1902            h.id,
1903            project,
1904            h.state,
1905            crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).unwrap_or("?")
1906        ));
1907    }
1908}
1909
1910/// Report a body claiming one issue came out of another with no edge either way.
1911fn check_provenance_links<'a>(
1912    all: &[(String, IssueHeading)],
1913    project: &str,
1914    h: &'a IssueHeading,
1915    known: &HashSet<&'a str>,
1916    f: &mut Findings,
1917) {
1918    for linked in crate::related::org_link_targets(&h.body, known) {
1919        if edge_connects(all, &h.id, &linked) {
1920            continue;
1921        }
1922        if !claims_discovery_or_pivot(&h.body, &linked) {
1923            continue;
1924        }
1925        f.warn(format_args!(
1926            "{} (in {}) mentions [[id:{}]] as discovered or pivoted with no edge either way",
1927            h.id, project, linked
1928        ));
1929    }
1930}
1931
1932/// Walk `:PARENT:` from one heading and report a loop.
1933///
1934/// `settled` carries across headings, so the walk stays linear over the corpus: an id
1935/// already reached from somewhere else cannot start a loop that was not already
1936/// reported.
1937fn check_parent_cycle<'a>(
1938    start: &'a IssueHeading,
1939    by_id: &HashMap<String, (String, &'a IssueHeading)>,
1940    settled: &mut HashSet<&'a str>,
1941    f: &mut Findings,
1942) {
1943    if settled.contains(start.id.as_str()) {
1944        return;
1945    }
1946    let mut path: Vec<&str> = Vec::new();
1947    let mut on_path: HashSet<&str> = HashSet::new();
1948    let mut cursor = start.id.as_str();
1949    loop {
1950        if settled.contains(cursor) {
1951            break;
1952        }
1953        if !on_path.insert(cursor) {
1954            let start = path.iter().position(|id| *id == cursor).unwrap_or(0);
1955            let mut loop_ids: Vec<&str> = path[start..].to_vec();
1956            loop_ids.push(cursor);
1957            f.err(format_args!("parent cycle: {}", loop_ids.join(" -> ")));
1958            break;
1959        }
1960        path.push(cursor);
1961        match by_id.get(cursor).and_then(|(_, owner)| owner.parent()) {
1962            Some(parent) if by_id.contains_key(parent) => cursor = parent,
1963            _ => break,
1964        }
1965    }
1966    settled.extend(path);
1967}
1968
1969/// Every issue referring to `target_id` through a blocker edge, a parent link,
1970/// a discovered-from or pivoted-to property, or a body mention. The relation
1971/// is named on the row.
1972///
1973/// # Errors
1974///
1975/// Returns an error if the corpus cannot be read.
1976pub fn backlinks(layout: &Layout, target_id: &str) -> Result<String> {
1977    let all = load_all(layout)?;
1978    let mut out = String::new();
1979
1980    // A deed accession is a different namespace from an issue id, and asking
1981    // what points at a product is the question you have when the product turns
1982    // out to be wrong. The corpus decides which namespace this is: a known id
1983    // is an issue, whatever it looks like, so a project actually named `deed`
1984    // keeps working. Only a token nobody minted is read as an accession.
1985    let known = all.iter().any(|(_, h)| h.id == target_id);
1986    if !known && crate::ops::is_deed_accession(target_id) {
1987        for (project, h) in &all {
1988            let relation = if h.deeds().iter().any(|cited| cited == target_id) {
1989                "cites"
1990            } else if h.body.contains(target_id) {
1991                "body mention"
1992            } else {
1993                continue;
1994            };
1995            let _ = writeln!(out, "{:<22} ({relation}) ({project})", h.id);
1996        }
1997        return Ok(out);
1998    }
1999
2000    for (project, h) in &all {
2001        if h.id == target_id {
2002            continue;
2003        }
2004        let mut hit = false;
2005        if blocker_ids(h).contains(&target_id) {
2006            let _ = writeln!(out, "{:<22} (blocked-by) ({})", h.id, project);
2007            hit = true;
2008        }
2009        if h.parent() == Some(target_id) {
2010            let _ = writeln!(out, "{:<22} (parent) ({})", h.id, project);
2011            hit = true;
2012        }
2013        if crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(target_id) {
2014            let _ = writeln!(out, "{:<22} (discovered-from) ({})", h.id, project);
2015            hit = true;
2016        }
2017        if crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(target_id) {
2018            let _ = writeln!(out, "{:<22} (pivoted-to) ({})", h.id, project);
2019            hit = true;
2020        }
2021        if !hit && h.body.contains(target_id) {
2022            let _ = writeln!(out, "{:<22} (body mention) ({})", h.id, project);
2023        }
2024    }
2025    Ok(out)
2026}
2027
2028#[cfg(test)]
2029mod tests {
2030    use super::*;
2031
2032    #[test]
2033    fn dot_labels_escape_untrusted_issue_text() {
2034        assert_eq!(dot_quoted(r#"a "quoted" title"#), r#"a \"quoted\" title"#);
2035        // A trailing backslash would otherwise escape the closing quote and
2036        // let the rest of the title become DOT syntax.
2037        assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
2038        assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
2039    }
2040}