Skip to main content

vissue_core/
report.rs

1//! Read-only verbs. Every function returns its text instead of printing, so a
2//! CLI, an MCP server, and a library caller share one implementation.
3
4use anyhow::anyhow;
5
6use crate::error::{Error, Result};
7use chrono::{Local, NaiveDate};
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::fmt::Write as _;
10
11use crate::catalog::{CatalogService, load_recs};
12use crate::config::Layout;
13use crate::graph::DependencyGraph;
14use crate::model::{IssueHeading, READY_STATES};
15pub use crate::related::related;
16use crate::store::{IssueDoc, find_by_id, find_org_ids, list_projects, load_all, project_selected};
17use crate::views::{IssueRec, IssueRow, ListQuery};
18
19struct GraphIndex<'a> {
20    by_id: HashMap<&'a str, &'a IssueHeading>,
21    children: HashMap<&'a str, Vec<&'a str>>,
22    blockers: HashMap<&'a str, Vec<&'a str>>,
23}
24
25impl<'a> GraphIndex<'a> {
26    fn new(all: &'a [(String, IssueHeading)]) -> Self {
27        let mut index = Self {
28            by_id: HashMap::with_capacity(all.len()),
29            children: HashMap::new(),
30            blockers: HashMap::new(),
31        };
32        for (_, h) in all {
33            index.by_id.insert(h.id.as_str(), h);
34        }
35        for (_, h) in all {
36            if let Some(parent) = h.parent() {
37                index
38                    .children
39                    .entry(parent)
40                    .or_default()
41                    .push(h.id.as_str());
42            }
43            let blockers = blocker_ids(h);
44            if !blockers.is_empty() {
45                index.blockers.insert(h.id.as_str(), blockers);
46            }
47        }
48        for children in index.children.values_mut() {
49            children.sort_unstable();
50        }
51        index
52    }
53}
54
55fn blocker_ids(h: &IssueHeading) -> Vec<&str> {
56    let mut ids = Vec::new();
57    if let Some(raw) = crate::props::get(&h.properties, crate::props::BLOCKED_BY) {
58        ids.extend(
59            raw.split(|c: char| c == ',' || c.is_whitespace())
60                .map(str::trim)
61                .filter(|id| !id.is_empty()),
62        );
63    }
64    if let Some(raw) = h.properties.get("BLOCKER") {
65        if crate::org::is_edna_blocker(raw) {
66            ids.extend(crate::org::edna_blocker_id_refs(raw));
67        } else {
68            ids.extend(
69                raw.split(|c: char| c == ',' || c.is_whitespace())
70                    .map(str::trim)
71                    .filter(|id| !id.is_empty()),
72            );
73        }
74    }
75    let mut unique = Vec::new();
76    for id in ids {
77        if !unique.contains(&id) {
78            unique.push(id);
79        }
80    }
81    unique
82}
83
84/// One row per issue: id, state, priority cookie, title.
85///
86/// # Errors
87///
88/// Returns an error if the corpus cannot be read.
89pub fn list(
90    layout: &Layout,
91    project_filter: Option<&str>,
92    state_filter: Option<&str>,
93    ready_only: bool,
94) -> Result<String> {
95    let recs = load_recs(layout)?;
96    let rows = CatalogService::from_recs(&recs).issues_rows(ListQuery {
97        project: project_filter.map(str::to_string),
98        state: state_filter.map(str::to_string),
99        ready: ready_only,
100        ..ListQuery::default()
101    })?;
102    Ok(format_issue_rows(&recs, &rows))
103}
104
105fn format_issue_rows(recs: &[IssueRec], rows: &[IssueRow]) -> String {
106    let mut out = String::new();
107    for row in rows {
108        let suffix = recs
109            .iter()
110            .find(|r| r.heading.id == row.id)
111            .map(|r| claim_suffix(&r.heading))
112            .unwrap_or_default();
113        let _ = writeln!(
114            out,
115            "{:<22} {:<9} [#{}]  {}{}",
116            row.id, row.state, row.priority, row.title, suffix
117        );
118    }
119    out
120}
121
122/// ` (claimed 3d by <identity>)`, or nothing when no one holds the issue.
123/// Only a claimed issue grows the suffix, so an unclaimed corpus renders
124/// exactly as it did before claims existed.
125pub(crate) fn claim_suffix(h: &IssueHeading) -> String {
126    let Some(who) = h.claimed_by() else {
127        return String::new();
128    };
129    match h.claim_age_days(Local::now().date_naive()) {
130        Some(days) => format!("  (claimed {days}d by {who})"),
131        None => format!("  (claimed by {who})"),
132    }
133}
134
135/// Actionable issues: TODO or STARTED with no open blocker.
136///
137/// # Errors
138///
139/// Returns an error if the corpus cannot be read.
140pub fn ready(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
141    let recs = load_recs(layout)?;
142    let rows = CatalogService::from_recs(&recs).ready(project_filter)?;
143    Ok(format_issue_rows(&recs, &rows))
144}
145
146/// One issue's metadata, file range, and body text.
147///
148/// # Errors
149///
150/// Returns an error if the corpus cannot be read, or `id` is not in it.
151pub fn show(layout: &Layout, id: &str) -> Result<String> {
152    let (h, path, project) =
153        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
154    let mut out = String::new();
155    writeln!(out, "ID:       {}", h.id)?;
156    writeln!(out, "Project:  {project}")?;
157    writeln!(out, "Title:    {}", h.title)?;
158    writeln!(out, "State:    {}", h.state)?;
159    writeln!(out, "Priority: [#{}]", h.priority)?;
160    if let Some(who) = h.claimed_by() {
161        match h.claim_age_days(Local::now().date_naive()) {
162            Some(days) => writeln!(
163                out,
164                "Claimed:  {who} since {} ({days}d)",
165                h.claimed_at().unwrap_or("?")
166            )?,
167            None => writeln!(out, "Claimed:  {who}")?,
168        }
169    }
170    let settings = crate::org::tag_settings_from_preamble(
171        &IssueDoc::parse_file(&project, &path)
172            .map(|d| d.preamble)
173            .unwrap_or_default(),
174    );
175    let tags = settings.all_tags(&h.tags());
176    if !tags.is_empty() {
177        writeln!(out, "Tags:     {}", tags.join(", "))?;
178    }
179    if h.properties.iter().any(|(k, _)| k != "ID") {
180        writeln!(out, "Properties:")?;
181        for (k, v) in &h.properties {
182            if k == "ID" {
183                continue;
184            }
185            writeln!(out, "  {k}: {v}")?;
186        }
187    }
188    writeln!(
189        out,
190        "File:     {}:{}-{}",
191        path.display(),
192        h.line_start,
193        h.line_end
194    )?;
195    writeln!(out)?;
196    // The body is what the issue actually asks for, so printing the file
197    // range and stopping leaves every reader to go fetch it by hand.
198    let body = h.body.trim_end();
199    if body.is_empty() {
200        writeln!(out, "(no body; edit the range above to add one)")?;
201    } else {
202        writeln!(out, "Body:")?;
203        writeln!(out, "{body}")?;
204    }
205    Ok(out)
206}
207
208/// Case-insensitive substring scan over id, title, properties, and body. Linear
209/// in the corpus, which is the right cost until the issue count climbs.
210///
211/// # Errors
212///
213/// Returns an error if the corpus cannot be read.
214pub fn search(layout: &Layout, query: &str, limit: usize) -> Result<String> {
215    let recs = load_recs(layout)?;
216    let hits = CatalogService::from_recs(&recs).search(query, limit)?;
217    let mut out = String::new();
218    for h in hits {
219        let _ = writeln!(
220            out,
221            "{:<22} {:<9} [#{}]  {}  ({})",
222            h.id, h.state, h.priority, h.title, h.project
223        );
224    }
225    Ok(out)
226}
227
228/// Issues whose `:PARENT:` points at `parent_id`.
229///
230/// # Errors
231///
232/// Returns an error if the corpus cannot be read.
233pub fn children(layout: &Layout, parent_id: &str) -> Result<String> {
234    let mut rows: Vec<(String, IssueHeading)> = load_all(layout)?
235        .into_iter()
236        .filter(|(_, h)| h.parent() == Some(parent_id))
237        .collect();
238    rows.sort_by(|a, b| {
239        a.1.priority
240            .cmp(&b.1.priority)
241            .then_with(|| a.1.state.cmp(&b.1.state))
242            .then_with(|| a.1.id.cmp(&b.1.id))
243    });
244    let mut out = String::new();
245    for (project, h) in rows {
246        let _ = writeln!(
247            out,
248            "{:<22} {:<9} [#{}]  {}  ({})",
249            h.id, h.state, h.priority, h.title, project
250        );
251    }
252    Ok(out)
253}
254
255/// Open issues whose `:CREATED:` is at least `days` old. An issue without a
256/// parseable date is never stale, because its age is unknown.
257///
258/// # Errors
259///
260/// Returns an error if the corpus cannot be read.
261pub fn stale(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
262    let today = Local::now().date_naive();
263    let cutoff = today - chrono::Duration::days(days);
264    let mut rows: Vec<(String, IssueHeading, NaiveDate)> = Vec::new();
265    for (project, h) in load_all(layout)? {
266        if !project_selected(&project, project_filter) {
267            continue;
268        }
269        if !READY_STATES.contains(&h.state.as_str()) {
270            continue;
271        }
272        let Some(created) = h.properties.get("CREATED") else {
273            continue;
274        };
275        let Some(parsed) = parse_org_date(created) else {
276            continue;
277        };
278        if parsed <= cutoff {
279            rows.push((project, h, parsed));
280        }
281    }
282    rows.sort_by_key(|r| r.2);
283    let mut out = String::new();
284    for (project, h, created) in rows {
285        let age = (today - created).num_days();
286        let _ = writeln!(
287            out,
288            "{:<22} {:<9} [#{}]  {} ({}d, {})",
289            h.id, h.state, h.priority, h.title, age, project
290        );
291    }
292    Ok(out)
293}
294
295/// Every live claim, oldest first: the who-holds-what view. A claim is live
296/// while its issue is STARTED or BLOCKED (release happens on TODO, DONE, or
297/// CANCELLED), so this is the working set, not history.
298///
299/// # Errors
300///
301/// Returns an error if the corpus cannot be read, or JSON serialization fails
302/// when `json` is set.
303pub fn claims(
304    layout: &Layout,
305    holder_filter: Option<&str>,
306    project_filter: Option<&str>,
307    json: bool,
308) -> Result<String> {
309    let recs = load_recs(layout)?;
310    let rows = CatalogService::from_recs(&recs).claims(holder_filter, project_filter)?;
311
312    if json {
313        return Ok(format!("{}\n", serde_json::to_value(&rows)?));
314    }
315
316    let mut out = String::new();
317    for row in &rows {
318        let age_txt = if row.age_days < 0 {
319            "?d".to_string()
320        } else {
321            format!("{}d", row.age_days)
322        };
323        let _ = writeln!(
324            out,
325            "{:<22} {:<9} [#{}]  {:>4}  {}  {} ({})",
326            row.id,
327            row.state,
328            row.priority,
329            age_txt,
330            row.holder.as_deref().unwrap_or("?"),
331            row.title,
332            row.project
333        );
334    }
335    if rows.is_empty() {
336        out.push_str("no live claims\n");
337    }
338    Ok(out)
339}
340
341/// Dated open work in the next `days` days, plus anything already overdue.
342/// One line per (issue, date kind): deadlines first within a day, soonest day
343/// first, overdue on top with a negative day count.
344///
345/// # Errors
346///
347/// Returns an error if the corpus cannot be read.
348pub fn agenda(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
349    let today = Local::now().date_naive();
350    let horizon = today + chrono::Duration::days(days);
351    // kind sorts D before S so a same-day deadline outranks a scheduled start.
352    let mut rows: Vec<(NaiveDate, char, String, IssueHeading)> = Vec::new();
353    for (project, h) in load_all(layout)? {
354        if !project_selected(&project, project_filter) {
355            continue;
356        }
357        if !READY_STATES.contains(&h.state.as_str()) && h.state != "BLOCKED" {
358            continue;
359        }
360        for (kind, value) in [('D', h.deadline()), ('S', h.scheduled())] {
361            let Some(parsed) = value.and_then(parse_org_date) else {
362                continue;
363            };
364            if parsed <= horizon {
365                rows.push((parsed, kind, project.clone(), h.clone()));
366            }
367        }
368    }
369    rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.3.id.cmp(&b.3.id)));
370
371    let mut out = String::new();
372    for (date, kind, project, h) in rows {
373        let delta = (date - today).num_days();
374        let when = match delta {
375            d if d < 0 => format!("{}d overdue", -d),
376            0 => "today".to_string(),
377            d => format!("in {d}d"),
378        };
379        let label = if kind == 'D' { "deadline" } else { "scheduled" };
380        let _ = writeln!(
381            out,
382            "{date}  {label:<9} {when:<11} {:<22} {:<9} [#{}]  {}  ({})",
383            h.id, h.state, h.priority, h.title, project
384        );
385    }
386    if out.is_empty() {
387        out.push_str("nothing dated in range\n");
388    }
389    Ok(out)
390}
391
392pub(crate) fn parse_org_date(s: &str) -> Option<NaiveDate> {
393    let inner = s
394        .trim_start_matches(['<', '['])
395        .trim_end_matches(['>', ']']);
396    let token = inner.split_whitespace().next()?;
397    NaiveDate::parse_from_str(token, "%Y-%m-%d").ok()
398}
399
400/// The matching issue count and nothing else, for shell pipelines.
401///
402/// # Errors
403///
404/// Returns an error if the corpus cannot be read.
405pub fn count(
406    layout: &Layout,
407    project_filter: Option<&str>,
408    state_filter: Option<&str>,
409    ready_only: bool,
410) -> Result<String> {
411    let all = load_all(layout)?;
412    let active_blockers: HashSet<String> = if ready_only {
413        all.iter()
414            .filter(|(_, h)| h.state != "DONE" && h.state != "CANCELLED")
415            .map(|(_, h)| h.id.clone())
416            .collect()
417    } else {
418        HashSet::new()
419    };
420    let n = all
421        .iter()
422        .filter(|(project, h)| {
423            if !project_selected(project, project_filter) {
424                return false;
425            }
426            if let Some(s) = state_filter
427                && h.state != s
428            {
429                return false;
430            }
431            if ready_only {
432                if !READY_STATES.contains(&h.state.as_str()) {
433                    return false;
434                }
435                if blocker_ids(h).iter().any(|b| active_blockers.contains(*b)) {
436                    return false;
437                }
438            }
439            true
440        })
441        .count();
442    Ok(format!("{n}\n"))
443}
444
445/// One JSON object per line: every property, the logbook, the body, and the
446/// file line range. Round-trippable, and the seam other tools consume.
447///
448/// # Errors
449///
450/// Returns an error if the corpus cannot be read.
451pub fn export(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
452    let mut out = String::new();
453    for rec in load_recs(layout)? {
454        if !project_selected(&rec.project, project_filter) {
455            continue;
456        }
457        let _ = writeln!(
458            out,
459            "{}",
460            export_row(&rec.project, rec.heading, &rec.tag_settings)
461        );
462    }
463    Ok(out)
464}
465
466/// The same lines as [`export`], grouped by project, from one read.
467///
468/// `export` filters a whole-corpus read down to one project, so digesting
469/// every project separately re-read the corpus once per project: quadratic
470/// in the project count, and six seconds on a tracker with a hundred of
471/// them. The rows are built by the same function, so a project's text here
472/// is byte for byte what `export(layout, Some(project))` returns, and the
473/// digests taken from it do not move.
474///
475/// # Errors
476///
477/// Returns an error if the corpus cannot be read.
478pub fn export_by_project(layout: &Layout) -> Result<BTreeMap<String, String>> {
479    let mut out: BTreeMap<String, String> = BTreeMap::new();
480    for rec in load_recs(layout)? {
481        let row = export_row(&rec.project, rec.heading, &rec.tag_settings);
482        let _ = writeln!(out.entry(rec.project).or_default(), "{row}");
483    }
484    Ok(out)
485}
486
487fn export_row(
488    project: &str,
489    h: IssueHeading,
490    settings: &crate::org::TagSettings,
491) -> serde_json::Value {
492    let logbook: Vec<serde_json::Value> = h
493        .logbook
494        .iter()
495        .map(|e| {
496            let mut row = serde_json::json!({
497                "timestamp": e.timestamp,
498                "from": e.from_state,
499                "to": e.to_state,
500                "note": e.note,
501            });
502            if let Some(raw) = &e.raw {
503                row["raw"] = serde_json::Value::String(raw.clone());
504            }
505            row
506        })
507        .collect();
508    serde_json::json!({
509        "id": h.id,
510        "project": project,
511        "title": h.title,
512        "state": h.state,
513        "priority": h.priority.to_string(),
514        "properties": h.properties,
515        "org_tags": h.org_tags,
516        "tags": h.tags(),
517        "all_tags": settings.all_tags(&h.tags()),
518        "logbook": logbook,
519        "body": h.body,
520        "line_start": h.line_start,
521        "line_end": h.line_end,
522    })
523}
524
525/// Children and blockers below `root_id`, as indented text or Graphviz DOT.
526///
527/// # Errors
528///
529/// Returns an error if the corpus cannot be read, `root_id` is not in it, or
530/// `format` is not `ascii`, `text`, or `dot`.
531pub fn tree(layout: &Layout, root_id: &str, format: &str) -> Result<String> {
532    let all = load_all(layout)?;
533    let graph = GraphIndex::new(&all);
534    let Some(root_heading) = graph.by_id.get(root_id) else {
535        return Err(Error::IssueNotFound {
536            id: root_id.to_string(),
537        });
538    };
539    let mut out = String::new();
540    let root = root_heading.id.as_str();
541    match format {
542        "ascii" | "text" => tree_ascii(&graph, root, 0, &mut HashSet::new(), &mut out),
543        "dot" => tree_dot(&graph, root, &mut out),
544        _ => return Err(anyhow!("unknown format {format:?}; allowed: ascii, dot").into()),
545    }
546    Ok(out)
547}
548
549fn tree_ascii<'a>(
550    graph: &GraphIndex<'a>,
551    id: &'a str,
552    depth: usize,
553    seen: &mut HashSet<&'a str>,
554    out: &mut String,
555) {
556    if !seen.insert(id) {
557        let _ = writeln!(out, "{}{id} (cycle, stopping)", "  ".repeat(depth));
558        return;
559    }
560    let Some(h) = graph.by_id.get(id) else {
561        let _ = writeln!(out, "{}{id} (missing)", "  ".repeat(depth));
562        return;
563    };
564    let _ = writeln!(
565        out,
566        "{}{id} {:<9} [#{}]  {}",
567        "  ".repeat(depth),
568        h.state,
569        h.priority,
570        h.title
571    );
572    if let Some(blockers) = graph.blockers.get(id) {
573        for blocker in blockers {
574            let _ = writeln!(out, "{}* blocked-by {blocker}", "  ".repeat(depth + 1));
575        }
576    }
577    if let Some(kids) = graph.children.get(id) {
578        for k in kids {
579            tree_ascii(graph, k, depth + 1, seen, out);
580        }
581    }
582}
583
584/// Escape text for a Graphviz quoted string. Backslash goes first, or the
585/// escape introduced for a quote is itself re-escaped; a raw newline would end
586/// the statement early. Titles and ids are whatever someone committed to the
587/// tracker, so neither is trusted here.
588pub(crate) fn dot_quoted(text: &str) -> String {
589    text.replace('\\', "\\\\")
590        .replace('"', "\\\"")
591        .replace('\n', "\\n")
592        .replace('\r', "")
593}
594
595fn tree_dot<'a>(graph: &GraphIndex<'a>, root_id: &str, out: &mut String) {
596    let _ = writeln!(out, "digraph vissue_tree {{");
597    let _ = writeln!(out, "  rankdir=LR;");
598    let _ = writeln!(
599        out,
600        "  node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
601    );
602    let mut visited: HashSet<&str> = HashSet::new();
603    let mut stack = vec![graph.by_id.get(root_id).unwrap().id.as_str()];
604    while let Some(id) = stack.pop() {
605        if !visited.insert(id) {
606            continue;
607        }
608        if let Some(h) = graph.by_id.get(id) {
609            let _ = writeln!(
610                out,
611                "  \"{}\" [label=\"{}\\n{} [#{}]\"];",
612                dot_quoted(&h.id),
613                dot_quoted(&h.title),
614                dot_quoted(&h.state),
615                dot_quoted(&h.priority.to_string())
616            );
617            if let Some(kids) = graph.children.get(id) {
618                for k in kids {
619                    let _ = writeln!(
620                        out,
621                        "  \"{}\" -> \"{}\" [color=\"#00897B\"];",
622                        dot_quoted(&h.id),
623                        dot_quoted(k)
624                    );
625                    stack.push(k);
626                }
627            }
628            if let Some(blockers) = graph.blockers.get(id) {
629                for b in blockers {
630                    let _ = writeln!(
631                        out,
632                        "  \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
633                        dot_quoted(b),
634                        dot_quoted(&h.id)
635                    );
636                    stack.push(b);
637                }
638            }
639        }
640    }
641    let _ = writeln!(out, "}}");
642}
643
644/// Cycles in the blocker graph, one per line, or a line saying there are none.
645///
646/// # Errors
647///
648/// Returns an error if the corpus cannot be read.
649pub fn cycles(layout: &Layout) -> Result<String> {
650    let all = load_all(layout)?;
651    let graph = GraphIndex::new(&all);
652
653    // Colored depth-first search over BLOCKED_BY edges. Grey marks the
654    // current stack, black a finished node, so a shared blocker reached
655    // from two branches (a diamond) is never mistaken for a cycle.
656    const WHITE: u8 = 0;
657    const GREY: u8 = 1;
658    const BLACK: u8 = 2;
659    let mut color: HashMap<&str, u8> = HashMap::new();
660    let mut found: Vec<Vec<String>> = Vec::new();
661
662    fn dfs<'a>(
663        id: &'a str,
664        graph: &GraphIndex<'a>,
665        color: &mut HashMap<&'a str, u8>,
666        path: &mut Vec<&'a str>,
667        found: &mut Vec<Vec<String>>,
668    ) {
669        color.insert(id, GREY);
670        path.push(id);
671        if let Some(blockers) = graph.blockers.get(id) {
672            for b in blockers {
673                if !graph.by_id.contains_key(b) {
674                    continue; // a broken edge cannot close a loop; `check` reports it
675                }
676                match color.get(b).copied().unwrap_or(WHITE) {
677                    GREY => {
678                        let start = path.iter().position(|&x| x == *b).unwrap();
679                        let mut cycle: Vec<String> =
680                            path[start..].iter().map(|s| s.to_string()).collect();
681                        // Rotate so the smallest id leads: one canonical form
682                        // per cycle no matter where the walk entered it.
683                        let min = cycle
684                            .iter()
685                            .enumerate()
686                            .min_by(|a, b| a.1.cmp(b.1))
687                            .map(|(i, _)| i)
688                            .unwrap();
689                        cycle.rotate_left(min);
690                        cycle.push(cycle[0].clone());
691                        if !found.contains(&cycle) {
692                            found.push(cycle);
693                        }
694                    }
695                    WHITE => dfs(b, graph, color, path, found),
696                    _ => {}
697                }
698            }
699        }
700        path.pop();
701        color.insert(id, BLACK);
702    }
703
704    for (_, start) in &all {
705        if color.get(start.id.as_str()).copied().unwrap_or(WHITE) == WHITE {
706            let mut path = Vec::new();
707            dfs(start.id.as_str(), &graph, &mut color, &mut path, &mut found);
708        }
709    }
710
711    let mut out = String::new();
712    if found.is_empty() {
713        let _ = writeln!(out, "no cycles");
714    } else {
715        for cycle in found {
716            let _ = writeln!(out, "{}", cycle.join(" -> "));
717        }
718    }
719    Ok(out)
720}
721
722/// Transitive blocker ancestors, limited to a bounded number of hops.
723///
724/// # Errors
725///
726/// Returns an error if the corpus cannot be read, the blocker graph cannot be
727/// built, or `id` is not in it.
728pub fn ancestors(layout: &Layout, id: &str, depth: usize) -> Result<String> {
729    let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
730    let mut out = String::new();
731    for (distance, ancestor) in graph.ancestors(id, depth)? {
732        writeln!(out, "{distance} {ancestor}")?;
733    }
734    Ok(out)
735}
736
737/// Transitive issues waiting on this issue, limited to a bounded number of hops.
738///
739/// # Errors
740///
741/// Returns an error if the corpus cannot be read, the blocker graph cannot be
742/// built, or `id` is not in it.
743pub fn impact(layout: &Layout, id: &str, depth: usize) -> Result<String> {
744    let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
745    let mut out = String::new();
746    for (distance, descendant) in graph.descendants(id, depth)? {
747        writeln!(out, "{distance} {descendant}")?;
748    }
749    Ok(out)
750}
751
752/// The whole blocker and parent graph as Graphviz DOT. Node fill encodes state.
753///
754/// # Errors
755///
756/// Returns an error if the corpus cannot be read.
757/// The lines a DOT document opens with, up to and including the graph
758/// attributes. Exposed for the same reason as [`ROADMAP_HEADER`]: a caller
759/// drawing several projects as one graph writes them once. Concatenating whole
760/// documents gives `dot` a file of many graphs, and it renders the first.
761pub const GRAPH_HEADER: &str = concat!(
762    "digraph vissue_graph {\n",
763    "  rankdir=LR;\n",
764    "  node [shape=box, fontname=\"Jost\", style=filled];\n",
765    "  edge [fontname=\"Jost\"];\n"
766);
767
768/// The line that closes a DOT document.
769pub const GRAPH_FOOTER: &str = "}\n";
770
771/// A DOT graph of the corpus: one node per issue, blocker and parent edges.
772///
773/// # Errors
774///
775/// Returns an error if the corpus cannot be read.
776pub fn graph(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
777    Ok(format!(
778        "{GRAPH_HEADER}{}{GRAPH_FOOTER}",
779        graph_body(layout, project_filter)?
780    ))
781}
782
783/// The nodes and edges of the graph, without the enclosing `digraph` block.
784///
785/// # Errors
786///
787/// Returns an error if the corpus cannot be read.
788pub fn graph_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
789    let all = load_all(layout)?;
790    let graph = GraphIndex::new(&all);
791    let mut out = String::new();
792    for (project, h) in &all {
793        if !project_selected(project, project_filter) {
794            continue;
795        }
796        let fill = match h.state.as_str() {
797            "DONE" => "#A5D6A7",
798            "CANCELLED" => "#CFD8DC",
799            "BLOCKED" => "#FFCC80",
800            "STARTED" => "#80CBC4",
801            _ => "#E0F2F1",
802        };
803        let _ = writeln!(
804            out,
805            "  \"{}\" [label=\"{}\\n{} [#{}]\", fillcolor=\"{}\"];",
806            dot_quoted(&h.id),
807            dot_quoted(&h.title),
808            dot_quoted(&h.state),
809            dot_quoted(&h.priority.to_string()),
810            fill
811        );
812    }
813    for (project, h) in &all {
814        if !project_selected(project, project_filter) {
815            continue;
816        }
817        if let Some(blockers) = graph.blockers.get(h.id.as_str()) {
818            for b in blockers {
819                writeln!(
820                    out,
821                    "  \"{}\" -> \"{}\" [color=\"#FF7043\"];",
822                    dot_quoted(b),
823                    dot_quoted(&h.id)
824                )?;
825            }
826        }
827        if let Some(parent) = h.parent() {
828            writeln!(
829                out,
830                "  \"{}\" -> \"{}\" [color=\"#00897B\", style=dashed];",
831                dot_quoted(parent),
832                dot_quoted(&h.id)
833            )?;
834        }
835    }
836    Ok(out)
837}
838
839/// A markdown roadmap grouped by project and state. Closed items collapse into
840/// one section so the document stays about live work.
841///
842/// # Errors
843///
844/// Returns an error if the corpus cannot be read.
845/// The document furniture a roadmap opens with. Exposed because a caller that
846/// assembles one roadmap out of several projects writes it once rather than once
847/// per project: concatenating whole roadmaps puts a title above every project
848/// section, so a corpus of six carries six titles in one document.
849pub const ROADMAP_HEADER: &str = concat!(
850    "# Roadmap\n\n",
851    "Generated from `vissue roadmap`. Source of truth lives in the per-project issues.org files.\n\n"
852);
853
854/// A markdown roadmap of active and closed work, with its title.
855///
856/// # Errors
857///
858/// Returns an error if the corpus cannot be read.
859pub fn roadmap(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
860    Ok(format!(
861        "{ROADMAP_HEADER}{}",
862        roadmap_body(layout, project_filter)?
863    ))
864}
865
866/// The roadmap's project sections, without the document title.
867///
868/// # Errors
869///
870/// Returns an error if the corpus cannot be read.
871pub fn roadmap_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
872    let all = load_all(layout)?;
873    let mut by_project: BTreeMap<String, Vec<&IssueHeading>> = BTreeMap::new();
874    for (project, h) in &all {
875        if !project_selected(project, project_filter) {
876            continue;
877        }
878        by_project.entry(project.clone()).or_default().push(h);
879    }
880    let mut out = String::new();
881    for (project, mut headings) in by_project {
882        headings.sort_by(|a, b| {
883            a.priority
884                .cmp(&b.priority)
885                .then_with(|| a.state.cmp(&b.state))
886                .then_with(|| a.id.cmp(&b.id))
887        });
888        let buckets = ["STARTED", "TODO", "BLOCKED"];
889        let active: Vec<&&IssueHeading> = headings
890            .iter()
891            .filter(|h| buckets.contains(&h.state.as_str()))
892            .collect();
893        let closed: Vec<&&IssueHeading> = headings
894            .iter()
895            .filter(|h| h.state == "DONE" || h.state == "CANCELLED")
896            .collect();
897        if active.is_empty() && closed.is_empty() {
898            continue;
899        }
900        writeln!(out, "## {project}")?;
901        writeln!(out)?;
902        for state in buckets {
903            let in_state: Vec<&&IssueHeading> = active
904                .iter()
905                .copied()
906                .filter(|h| h.state == state)
907                .collect();
908            if in_state.is_empty() {
909                continue;
910            }
911            writeln!(out, "### {state}")?;
912            writeln!(out)?;
913            for h in in_state {
914                let deadline = h
915                    .deadline()
916                    .map(|d| format!(" :: deadline {d}"))
917                    .unwrap_or_default();
918                let blockers = blocker_ids(h);
919                let blocked_by = if blockers.is_empty() {
920                    String::new()
921                } else {
922                    format!(" :: blocked by {}", blockers.join(", "))
923                };
924                writeln!(
925                    out,
926                    "- **{}** [#{}] {}{}{}",
927                    h.id, h.priority, h.title, deadline, blocked_by
928                )?;
929            }
930            writeln!(out)?;
931        }
932        if !closed.is_empty() {
933            writeln!(out, "### Closed ({} items)", closed.len())?;
934            writeln!(out)?;
935            for h in closed.iter().take(10) {
936                writeln!(
937                    out,
938                    "- {} [#{}] {} ({})",
939                    h.id, h.priority, h.title, h.state
940                )?;
941            }
942            if closed.len() > 10 {
943                writeln!(out, "- ... and {} more", closed.len() - 10)?;
944            }
945            writeln!(out)?;
946        }
947    }
948    Ok(out)
949}
950
951/// Does this body say *this issue* was rejected, as opposed to using the word.
952///
953/// `contains("rejected")` cannot tell the two apart, and the difference is the
954/// whole finding. Every bug report about input validation says it: "silently
955/// corrupted rather than rejected", "ignored rather than enforced or rejected".
956/// A design note says it too: "a hand-written parser is rejected as strictly
957/// dominated". Three issues in one corpus were flagged for exactly those, all
958/// of them worked and closed properly, and a check that cries wolf about closed
959/// issues is a check nobody re-reads.
960///
961/// So this looks for the shapes a rejection is actually written in: the tool's
962/// own phrasing, a redirect, or a heading that says so.
963fn looks_like_reject_prose(body: &str) -> bool {
964    let lower = body.to_ascii_lowercase();
965    const CLOSING: &[&str] = &[
966        "vissue reject",
967        "superseded by",
968        "rejected in favour",
969        "rejected in favor",
970        "rejected as a duplicate",
971        "closed as a duplicate",
972        "closed as duplicate",
973        "not doing this",
974        "rejected this",
975        "rejected: ",
976    ];
977    if CLOSING.iter().any(|phrase| lower.contains(phrase)) {
978        return true;
979    }
980    // A heading that names the outcome, which is where a hand-written rejection
981    // goes when it is not one of the phrases above.
982    //
983    // "superseded" and not "supersede": the participle says this issue was
984    // replaced, and the third person says it replaced others. A corpus here has
985    // an issue whose "** Supersedes" section rolls up seven others it did not
986    // close, and reading that as its own rejection is the false positive this
987    // function exists to stop. Do not shorten the stem.
988    lower.lines().any(|line| {
989        line.starts_with('*')
990            && (line.contains("rejected")
991                || line.contains("superseded")
992                || line.contains("reject:"))
993    })
994}
995
996/// Does the prose around this link claim the relation the properties name.
997///
998/// A body mentions other issues for every reason there is: a parent lists its
999/// children, an umbrella rolls up what it does not close, a note says "see
1000/// also". Warning that any of those lacks a `DISCOVERED_FROM` asks for an edge
1001/// nobody can honestly supply, and the answer is a wrong edge or a warning that
1002/// gets ignored. One corpus had twenty-two of these and not one was a discovery.
1003///
1004/// So the warning is for a body that says discovery or a pivot and has no edge
1005/// to match, which is the case the properties exist for.
1006fn claims_discovery_or_pivot(body: &str, linked: &str) -> bool {
1007    const CLAIMS: &[&str] = &[
1008        "discovered from",
1009        "discovered while",
1010        "discovered during",
1011        "found while",
1012        "filed from",
1013        "split from",
1014        "pivoted to",
1015        "pivots to",
1016        "pivoted from",
1017        "replaced by",
1018        "moved to",
1019    ];
1020    let needle = format!("id:{linked}");
1021    let lower = body.to_ascii_lowercase();
1022    let lower_needle = needle.to_ascii_lowercase();
1023    // The claim has to be near the link rather than anywhere in the body: a long
1024    // issue can say "discovered while auditing" in one section and link three
1025    // unrelated ids in another.
1026    let window = 240;
1027    let mut from = 0;
1028    while let Some(at) = lower[from..].find(&lower_needle) {
1029        let hit = from + at;
1030        let start = hit.saturating_sub(window);
1031        let end = (hit + lower_needle.len() + window).min(lower.len());
1032        let near = &lower[floor_char_boundary(&lower, start)..ceil_char_boundary(&lower, end)];
1033        if CLAIMS.iter().any(|phrase| near.contains(phrase)) {
1034            return true;
1035        }
1036        from = hit + lower_needle.len();
1037    }
1038    false
1039}
1040
1041fn floor_char_boundary(s: &str, mut i: usize) -> usize {
1042    while i > 0 && !s.is_char_boundary(i) {
1043        i -= 1;
1044    }
1045    i
1046}
1047
1048fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
1049    while i < s.len() && !s.is_char_boundary(i) {
1050        i += 1;
1051    }
1052    i
1053}
1054
1055/// Is the relation between these two already held as an edge, in either
1056/// direction.
1057///
1058/// Discovery and a pivot are not the only relations people write. A parent
1059/// mentioning a child, or an issue naming what blocks it, is a stated relation
1060/// the tracker already holds, and warning that it lacks a DISCOVERED_FROM asks
1061/// for an edge nobody can honestly supply: the answer is either a wrong edge or
1062/// a warning that gets ignored.
1063fn edge_connects(all: &[(String, IssueHeading)], a: &str, b: &str) -> bool {
1064    all.iter().any(|(_, h)| {
1065        let far = if h.id == a {
1066            b
1067        } else if h.id == b {
1068            a
1069        } else {
1070            return false;
1071        };
1072        [
1073            crate::props::DISCOVERED_FROM,
1074            crate::props::PIVOTED_TO,
1075            crate::props::PARENT,
1076            crate::props::BLOCKED_BY,
1077            crate::props::EDNA_BLOCKER,
1078        ]
1079        .iter()
1080        .any(|key| {
1081            crate::props::get(&h.properties, key)
1082                .is_some_and(|value| value.split(&[',', ' '][..]).any(|part| part.trim() == far))
1083        })
1084    })
1085}
1086
1087/// Outcome of [`check`]: the findings, and how many were errors.
1088#[derive(Debug, Clone)]
1089pub struct CheckReport {
1090    /// Rendered findings, ending in a summary line.
1091    pub text: String,
1092    /// Count of `[err]` findings.
1093    pub errors: usize,
1094    /// Count of `[warn]` findings.
1095    pub warnings: usize,
1096}
1097
1098/// Findings as they accumulate, each carrying its own severity.
1099///
1100/// The counts are the point: `check` exits non-zero on an error, and a caller reads
1101/// the two numbers without reading the prose. Keeping them beside the text is what
1102/// stops a finding being written without being counted, which is a silent way for the
1103/// exit code to disagree with the report.
1104#[derive(Default)]
1105struct Findings {
1106    text: String,
1107    errors: usize,
1108    warnings: usize,
1109}
1110
1111impl Findings {
1112    /// A finding a reader has to fix. Writing to a `String` cannot fail.
1113    fn err(&mut self, what: std::fmt::Arguments) {
1114        let _ = writeln!(self.text, "[err]  {what}");
1115        self.errors += 1;
1116    }
1117
1118    /// A finding a reader may leave, which does not change the exit code.
1119    fn warn(&mut self, what: std::fmt::Arguments) {
1120        let _ = writeln!(self.text, "[warn] {what}");
1121        self.warnings += 1;
1122    }
1123}
1124
1125/// Validate the corpus: every parent and blocker id resolves, dates parse, open
1126/// issues carry a creation date, and ids are unique across projects.
1127///
1128/// # Errors
1129///
1130/// Returns an error if the corpus cannot be read.
1131pub fn check(layout: &Layout) -> Result<CheckReport> {
1132    let all = load_all(layout)?;
1133
1134    // A parent is usually another issue, and those ids are already in hand.
1135    // Only the ones that are not send us looking through the rest of the
1136    // tree, which on a tracker sharing a root with a notes vault is most of
1137    // the bytes on disk.
1138    let issue_ids: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1139    let unresolved: HashSet<String> = all
1140        .iter()
1141        .filter_map(|(_, h)| h.parent())
1142        .filter(|p| !issue_ids.contains(p))
1143        .map(str::to_string)
1144        .collect();
1145    let elsewhere = find_org_ids(layout, &unresolved)?;
1146    let resolves = |id: &str| issue_ids.contains(id) || elsewhere.contains(id);
1147
1148    let mut f = Findings::default();
1149
1150    let mut by_id: HashMap<String, (String, &IssueHeading)> = HashMap::new();
1151    for (project, h) in &all {
1152        if let Some(prev) = by_id.insert(h.id.clone(), (project.clone(), h)) {
1153            // An error, not a note: an id that names two issues makes every
1154            // blocker and parent edge pointing at it ambiguous.
1155            f.err(format_args!(
1156                "duplicate id: {} appears in {} and {}",
1157                h.id, prev.0, project
1158            ));
1159        }
1160    }
1161
1162    for project in list_projects(layout)? {
1163        check_project(&project, layout, &mut f)?;
1164    }
1165
1166    for (project, h) in &all {
1167        check_issue(project, h, &resolves, &by_id, &mut f);
1168    }
1169
1170    let known: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1171    for (project, h) in &all {
1172        check_provenance_links(&all, project, h, &known, &mut f);
1173    }
1174
1175    // A :PARENT: loop passes every edge check, because each id resolves, yet
1176    // it makes the hierarchy unwalkable: `tree` stops on it and prints
1177    // "(cycle, stopping)". Naming it here is what keeps a corpus that holds
1178    // one from reading as clean.
1179    let mut settled: HashSet<&str> = HashSet::new();
1180    for (_, h) in &all {
1181        check_parent_cycle(h, &by_id, &mut settled, &mut f);
1182    }
1183
1184    if f.errors == 0
1185        && let Err(err) = DependencyGraph::from_issues(&all)
1186    {
1187        f.err(format_args!("blocker graph: {err}"));
1188    }
1189
1190    let _ = writeln!(f.text);
1191    let projects = list_projects(layout)?.len();
1192    let _ = writeln!(
1193        f.text,
1194        "checked {} issue(s) across {projects} project(s): {} error(s), {} warning(s)",
1195        all.len(),
1196        f.errors,
1197        f.warnings
1198    );
1199    Ok(CheckReport {
1200        text: f.text,
1201        errors: f.errors,
1202        warnings: f.warnings,
1203    })
1204}
1205
1206/// Validate one project's file: its preamble, and the headings it holds.
1207///
1208/// The one cohesive thing in `check` that is about a file rather than about the
1209/// corpus. Everything here reads one project's own preamble and its own headings and
1210/// needs none of the others.
1211///
1212/// # Errors
1213///
1214/// Returns an error if the project's file cannot be read or parsed.
1215fn check_project(project: &str, layout: &Layout, f: &mut Findings) -> Result<()> {
1216    let path = layout.project_issues_path(project);
1217    let doc = IssueDoc::parse_file(project, &path)?;
1218    check_preamble(project, &doc, &path, f);
1219    // The loader skips a heading a calendar sync owns, so the parsed headings cannot
1220    // hold one and counting them there counted nothing. The heading is still in the
1221    // file, and one the tracker will not touch is the surprise worth reporting, so the
1222    // file is what gets counted.
1223    let gcal_ids = crate::store::org_ids(&std::fs::read_to_string(&path)?)
1224        .filter(|id| crate::org::is_gcal_event_id(id))
1225        .count();
1226    if gcal_ids > 0 {
1227        f.err(format_args!(
1228            "{project}: {gcal_ids} heading(s) use an org-gcal event id as :ID:"
1229        ));
1230    }
1231    check_headings(project, &doc, f);
1232    Ok(())
1233}
1234
1235/// What Org needs from the file's preamble to render the tracker as intended.
1236///
1237/// Each of these is a keyword whose absence Org does not complain about and a reader
1238/// notices later: an agenda that labels every row `issues`, a publish that exports
1239/// the tracker, a priority cookie outside the range the file declares.
1240fn check_preamble(project: &str, doc: &IssueDoc, path: &std::path::Path, f: &mut Findings) {
1241    match crate::org::protocol_from_preamble(&doc.preamble) {
1242        None => {
1243            f.warn(format_args!(
1244                "{project}: preamble has no #+VISSUE: protocol stamp"
1245            ));
1246        }
1247        Some(n) if n < crate::org::PROTOCOL_VERSION => {
1248            f.warn(format_args!(
1249                "{project}: #+VISSUE: {n} is behind protocol {}",
1250                crate::org::PROTOCOL_VERSION
1251            ));
1252        }
1253        Some(n) if n > crate::org::PROTOCOL_VERSION => {
1254            f.err(format_args!(
1255                "{project}: #+VISSUE: {n} is newer than this vissue (protocol {})",
1256                crate::org::PROTOCOL_VERSION
1257            ));
1258        }
1259        Some(_) => {}
1260    }
1261    if !crate::org::preamble_has_keyword(&doc.preamble, "CATEGORY") {
1262        f.warn(format_args!(
1263            "{project}: preamble has no #+CATEGORY: (org-agenda labels every row \"issues\")"
1264        ));
1265    }
1266    if !crate::org::preamble_has_keyword(&doc.preamble, "FILETAGS") {
1267        f.warn(format_args!("{project}: preamble has no #+FILETAGS:"));
1268    } else if !doc
1269        .tag_settings
1270        .filetags
1271        .iter()
1272        .any(|t| t.eq_ignore_ascii_case("noexport"))
1273    {
1274        f.warn(format_args!(
1275            "{project}: #+FILETAGS: has no noexport; a vault publish will export this tracker"
1276        ));
1277    }
1278    if !crate::org::preamble_has_keyword(&doc.preamble, "TAGS") {
1279        f.warn(format_args!(
1280            "{project}: preamble has no #+TAGS:; Emacs fast tag selection has no type group"
1281        ));
1282    }
1283    if !crate::org::preamble_has_keyword(
1284        &crate::org::merge_setupfile_settings(&doc.preamble, path.parent()),
1285        "PRIORITIES",
1286    ) {
1287        f.warn(format_args!(
1288            "{project}: preamble has no #+PRIORITIES:; cookies default to C and the range is A..C"
1289        ));
1290    }
1291}
1292
1293/// What the tracker needs from each heading in the file.
1294///
1295/// Counted rather than named one by one, because a file with forty headings that all
1296/// put `:PRIORITY:` in the drawer wants one line saying so, not forty.
1297fn check_headings(project: &str, doc: &IssueDoc, f: &mut Findings) {
1298    let spec = doc.priority_spec();
1299    let mut type_not_tagged = 0usize;
1300    let mut exclusive_clash = 0usize;
1301    let mut priority_out_of_range = 0usize;
1302    let mut ordered_skip = 0usize;
1303    let mut done_with_open_children = 0usize;
1304    let mut priority_in_drawer = 0usize;
1305    let mut blockedby_typo = 0usize;
1306    let mut blocker_as_ids = 0usize;
1307    let mut computed_specials = 0usize;
1308    let mut bad_effort = 0usize;
1309    for h in &doc.headings {
1310        if let Some(kind) = crate::props::get(&h.properties, crate::props::TYPE) {
1311            let kind = kind.trim();
1312            if !kind.is_empty()
1313                && kind.chars().all(crate::model::is_org_tag_char)
1314                && !h.org_tags.iter().any(|t| t == kind)
1315            {
1316                type_not_tagged += 1;
1317            }
1318        }
1319        for group in &doc.tag_settings.exclusive {
1320            let hits = group
1321                .iter()
1322                .filter(|name| h.org_tags.iter().any(|t| t == *name))
1323                .count();
1324            if hits > 1 {
1325                exclusive_clash += 1;
1326                break;
1327            }
1328        }
1329        if !spec.contains(h.priority) {
1330            priority_out_of_range += 1;
1331        }
1332        if h.properties.contains_key("PRIORITY") {
1333            priority_in_drawer += 1;
1334        }
1335        if h.properties.contains_key("BLOCKEDBY") {
1336            blockedby_typo += 1;
1337        }
1338        if let Some(raw) = h.properties.get("BLOCKER")
1339            && !crate::org::is_edna_blocker(raw)
1340        {
1341            blocker_as_ids += 1;
1342        }
1343        if crate::org::COMPUTED_SPECIALS
1344            .iter()
1345            .any(|k| *k != "PRIORITY" && h.properties.contains_key(*k))
1346        {
1347            computed_specials += 1;
1348        }
1349        if let Some(effort) = h.effort()
1350            && !crate::org::is_org_effort(effort)
1351        {
1352            bad_effort += 1;
1353        }
1354        if let Some(pid) = h.parent()
1355            && let Some(parent) = doc.headings.iter().find(|p| p.id == pid)
1356            && crate::org::org_property_is_set(&parent.properties, "ORDERED")
1357            && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1358        {
1359            let earlier_open = doc.headings.iter().any(|sib| {
1360                sib.parent() == Some(pid)
1361                    && sib.line_start < h.line_start
1362                    && sib.state != "DONE"
1363                    && sib.state != "CANCELLED"
1364            });
1365            if earlier_open && (h.state == "STARTED" || h.state == "DONE") {
1366                ordered_skip += 1;
1367            }
1368        }
1369        if h.state == "DONE"
1370            && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1371            && doc.headings.iter().any(|c| {
1372                c.parent() == Some(h.id.as_str()) && c.state != "DONE" && c.state != "CANCELLED"
1373            })
1374        {
1375            done_with_open_children += 1;
1376        }
1377    }
1378    if type_not_tagged > 0 {
1379        f.warn(format_args!("{project}: {type_not_tagged} heading(s) have :TYPE: that is a legal Org tag but is not on the heading"));
1380    }
1381    if exclusive_clash > 0 {
1382        f.warn(format_args!("{project}: {exclusive_clash} heading(s) carry more than one tag from a #+TAGS: exclusive group"));
1383    }
1384    if priority_in_drawer > 0 {
1385        f.warn(format_args!("{project}: {priority_in_drawer} heading(s) put :PRIORITY: in the drawer; Org reads the [#A] cookie"));
1386    }
1387    if blockedby_typo > 0 {
1388        f.warn(format_args!(
1389            "{project}: {blockedby_typo} heading(s) use :BLOCKEDBY: instead of :BLOCKED_BY:"
1390        ));
1391    }
1392    if blocker_as_ids > 0 {
1393        f.warn(format_args!("{project}: {blocker_as_ids} heading(s) use :BLOCKER: as a bare id list; a rewrite folds them into :BLOCKED_BY:"));
1394    }
1395    if computed_specials > 0 {
1396        f.warn(format_args!("{project}: {computed_specials} heading(s) set a computed Org special (TODO/ITEM/TAGS/...) in the drawer; Org ignores it"));
1397    }
1398    if bad_effort > 0 {
1399        f.warn(format_args!(
1400            "{project}: {bad_effort} heading(s) have an Effort value Org will not parse"
1401        ));
1402    }
1403    if priority_out_of_range > 0 {
1404        f.warn(format_args!(
1405            "{project}: {priority_out_of_range} heading(s) have a [#prio] outside #+PRIORITIES:"
1406        ));
1407    }
1408    if ordered_skip > 0 {
1409        f.warn(format_args!("{project}: {ordered_skip} heading(s) started or closed before an earlier ORDERED sibling"));
1410    }
1411    if done_with_open_children > 0 {
1412        f.warn(format_args!("{project}: {done_with_open_children} DONE heading(s) still have open children (Org ORDERED / todo-dependencies)"));
1413    }
1414}
1415
1416/// Validate one issue on its own: its edges resolve, its dates parse, and its state
1417/// agrees with what the drawer and the body say.
1418fn check_issue<'a>(
1419    project: &str,
1420    h: &'a IssueHeading,
1421    resolves: &impl Fn(&str) -> bool,
1422    by_id: &HashMap<String, (String, &'a IssueHeading)>,
1423    f: &mut Findings,
1424) {
1425    if let Some(parent) = h.parent()
1426        && !resolves(parent)
1427    {
1428        f.err(format_args!(
1429            "{} (in {}) :PARENT: {} -> not found",
1430            h.id, project, parent
1431        ));
1432    }
1433    for blk in blocker_ids(h) {
1434        if !by_id.contains_key(blk) {
1435            f.err(format_args!(
1436                "{} (in {}) :BLOCKED_BY: {} -> not found",
1437                h.id, project, blk
1438            ));
1439        }
1440    }
1441    if let Some(d) = h.deadline()
1442        && parse_org_date(d).is_none()
1443    {
1444        f.err(format_args!(
1445            "{} (in {}) :DEADLINE: {} -> unparseable",
1446            h.id, project, d
1447        ));
1448    }
1449    if let Some(s) = h.scheduled()
1450        && parse_org_date(s).is_none()
1451    {
1452        f.err(format_args!(
1453            "{} (in {}) :SCHEDULED: {} -> unparseable",
1454            h.id, project, s
1455        ));
1456    }
1457    if matches!(h.state.as_str(), "TODO" | "STARTED") && !h.properties.contains_key("CREATED") {
1458        f.warn(format_args!(
1459            "{} (in {}) state={} but :CREATED: is missing",
1460            h.id, project, h.state
1461        ));
1462    }
1463    if h.state == "DONE" && looks_like_reject_prose(&h.body) {
1464        f.warn(format_args!(
1465            "{} (in {}) is DONE but the body reads as a reject",
1466            h.id, project
1467        ));
1468    }
1469    if crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_some() {
1470        f.warn(format_args!(
1471            "{} (in {}) holds {} and sibling {}",
1472            h.id,
1473            project,
1474            h.state,
1475            crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).unwrap_or("?")
1476        ));
1477    }
1478}
1479
1480/// Report a body claiming one issue came out of another with no edge either way.
1481fn check_provenance_links<'a>(
1482    all: &[(String, IssueHeading)],
1483    project: &str,
1484    h: &'a IssueHeading,
1485    known: &HashSet<&'a str>,
1486    f: &mut Findings,
1487) {
1488    for linked in crate::related::org_link_targets(&h.body, known) {
1489        if edge_connects(all, &h.id, &linked) {
1490            continue;
1491        }
1492        if !claims_discovery_or_pivot(&h.body, &linked) {
1493            continue;
1494        }
1495        f.warn(format_args!(
1496            "{} (in {}) mentions [[id:{}]] as discovered or pivoted with no edge either way",
1497            h.id, project, linked
1498        ));
1499    }
1500}
1501
1502/// Walk `:PARENT:` from one heading and report a loop.
1503///
1504/// `settled` carries across headings, so the walk stays linear over the corpus: an id
1505/// already reached from somewhere else cannot start a loop that was not already
1506/// reported.
1507fn check_parent_cycle<'a>(
1508    start: &'a IssueHeading,
1509    by_id: &HashMap<String, (String, &'a IssueHeading)>,
1510    settled: &mut HashSet<&'a str>,
1511    f: &mut Findings,
1512) {
1513    if settled.contains(start.id.as_str()) {
1514        return;
1515    }
1516    let mut path: Vec<&str> = Vec::new();
1517    let mut on_path: HashSet<&str> = HashSet::new();
1518    let mut cursor = start.id.as_str();
1519    loop {
1520        if settled.contains(cursor) {
1521            break;
1522        }
1523        if !on_path.insert(cursor) {
1524            let start = path.iter().position(|id| *id == cursor).unwrap_or(0);
1525            let mut loop_ids: Vec<&str> = path[start..].to_vec();
1526            loop_ids.push(cursor);
1527            f.err(format_args!("parent cycle: {}", loop_ids.join(" -> ")));
1528            break;
1529        }
1530        path.push(cursor);
1531        match by_id.get(cursor).and_then(|(_, owner)| owner.parent()) {
1532            Some(parent) if by_id.contains_key(parent) => cursor = parent,
1533            _ => break,
1534        }
1535    }
1536    settled.extend(path);
1537}
1538
1539/// Every issue referring to `target_id` through a blocker edge, a parent link,
1540/// a discovered-from or pivoted-to property, or a body mention. The relation
1541/// is named on the row.
1542///
1543/// # Errors
1544///
1545/// Returns an error if the corpus cannot be read.
1546pub fn backlinks(layout: &Layout, target_id: &str) -> Result<String> {
1547    let all = load_all(layout)?;
1548    let mut out = String::new();
1549    for (project, h) in &all {
1550        if h.id == target_id {
1551            continue;
1552        }
1553        let mut hit = false;
1554        if blocker_ids(h).contains(&target_id) {
1555            let _ = writeln!(out, "{:<22} (blocked-by) ({})", h.id, project);
1556            hit = true;
1557        }
1558        if h.parent() == Some(target_id) {
1559            let _ = writeln!(out, "{:<22} (parent) ({})", h.id, project);
1560            hit = true;
1561        }
1562        if crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(target_id) {
1563            let _ = writeln!(out, "{:<22} (discovered-from) ({})", h.id, project);
1564            hit = true;
1565        }
1566        if crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(target_id) {
1567            let _ = writeln!(out, "{:<22} (pivoted-to) ({})", h.id, project);
1568            hit = true;
1569        }
1570        if !hit && h.body.contains(target_id) {
1571            let _ = writeln!(out, "{:<22} (body mention) ({})", h.id, project);
1572        }
1573    }
1574    Ok(out)
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579    use super::*;
1580
1581    #[test]
1582    fn dot_labels_escape_untrusted_issue_text() {
1583        assert_eq!(dot_quoted(r#"a "quoted" title"#), r#"a \"quoted\" title"#);
1584        // A trailing backslash would otherwise escape the closing quote and
1585        // let the rest of the title become DOT syntax.
1586        assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
1587        assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
1588    }
1589}