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