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