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.
757pub fn graph(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
758    let all = load_all(layout)?;
759    let graph = GraphIndex::new(&all);
760    let mut out = String::new();
761    writeln!(out, "digraph vissue_graph {{")?;
762    writeln!(out, "  rankdir=LR;")?;
763    writeln!(out, "  node [shape=box, fontname=\"Jost\", style=filled];")?;
764    writeln!(out, "  edge [fontname=\"Jost\"];")?;
765    for (project, h) in &all {
766        if !project_selected(project, project_filter) {
767            continue;
768        }
769        let fill = match h.state.as_str() {
770            "DONE" => "#A5D6A7",
771            "CANCELLED" => "#CFD8DC",
772            "BLOCKED" => "#FFCC80",
773            "STARTED" => "#80CBC4",
774            _ => "#E0F2F1",
775        };
776        let _ = writeln!(
777            out,
778            "  \"{}\" [label=\"{}\\n{} [#{}]\", fillcolor=\"{}\"];",
779            dot_quoted(&h.id),
780            dot_quoted(&h.title),
781            dot_quoted(&h.state),
782            dot_quoted(&h.priority.to_string()),
783            fill
784        );
785    }
786    for (project, h) in &all {
787        if !project_selected(project, project_filter) {
788            continue;
789        }
790        if let Some(blockers) = graph.blockers.get(h.id.as_str()) {
791            for b in blockers {
792                writeln!(
793                    out,
794                    "  \"{}\" -> \"{}\" [color=\"#FF7043\"];",
795                    dot_quoted(b),
796                    dot_quoted(&h.id)
797                )?;
798            }
799        }
800        if let Some(parent) = h.parent() {
801            writeln!(
802                out,
803                "  \"{}\" -> \"{}\" [color=\"#00897B\", style=dashed];",
804                dot_quoted(parent),
805                dot_quoted(&h.id)
806            )?;
807        }
808    }
809    writeln!(out, "}}")?;
810    Ok(out)
811}
812
813/// A markdown roadmap grouped by project and state. Closed items collapse into
814/// one section so the document stays about live work.
815///
816/// # Errors
817///
818/// Returns an error if the corpus cannot be read.
819pub fn roadmap(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
820    let all = load_all(layout)?;
821    let mut by_project: BTreeMap<String, Vec<&IssueHeading>> = BTreeMap::new();
822    for (project, h) in &all {
823        if !project_selected(project, project_filter) {
824            continue;
825        }
826        by_project.entry(project.clone()).or_default().push(h);
827    }
828    let mut out = String::new();
829    writeln!(out, "# Roadmap")?;
830    writeln!(out)?;
831    writeln!(
832        out,
833        "Generated from `vissue roadmap`. Source of truth lives in the per-project issues.org files."
834    )?;
835    writeln!(out)?;
836    for (project, mut headings) in by_project {
837        headings.sort_by(|a, b| {
838            a.priority
839                .cmp(&b.priority)
840                .then_with(|| a.state.cmp(&b.state))
841                .then_with(|| a.id.cmp(&b.id))
842        });
843        let buckets = ["STARTED", "TODO", "BLOCKED"];
844        let active: Vec<&&IssueHeading> = headings
845            .iter()
846            .filter(|h| buckets.contains(&h.state.as_str()))
847            .collect();
848        let closed: Vec<&&IssueHeading> = headings
849            .iter()
850            .filter(|h| h.state == "DONE" || h.state == "CANCELLED")
851            .collect();
852        if active.is_empty() && closed.is_empty() {
853            continue;
854        }
855        writeln!(out, "## {project}")?;
856        writeln!(out)?;
857        for state in buckets {
858            let in_state: Vec<&&IssueHeading> = active
859                .iter()
860                .copied()
861                .filter(|h| h.state == state)
862                .collect();
863            if in_state.is_empty() {
864                continue;
865            }
866            writeln!(out, "### {state}")?;
867            writeln!(out)?;
868            for h in in_state {
869                let deadline = h
870                    .deadline()
871                    .map(|d| format!(" :: deadline {d}"))
872                    .unwrap_or_default();
873                let blockers = blocker_ids(h);
874                let blocked_by = if blockers.is_empty() {
875                    String::new()
876                } else {
877                    format!(" :: blocked by {}", blockers.join(", "))
878                };
879                writeln!(
880                    out,
881                    "- **{}** [#{}] {}{}{}",
882                    h.id, h.priority, h.title, deadline, blocked_by
883                )?;
884            }
885            writeln!(out)?;
886        }
887        if !closed.is_empty() {
888            writeln!(out, "### Closed ({} items)", closed.len())?;
889            writeln!(out)?;
890            for h in closed.iter().take(10) {
891                writeln!(
892                    out,
893                    "- {} [#{}] {} ({})",
894                    h.id, h.priority, h.title, h.state
895                )?;
896            }
897            if closed.len() > 10 {
898                writeln!(out, "- ... and {} more", closed.len() - 10)?;
899            }
900            writeln!(out)?;
901        }
902    }
903    Ok(out)
904}
905
906fn looks_like_reject_prose(body: &str) -> bool {
907    let lower = body.to_ascii_lowercase();
908    lower.contains("rejected") || lower.contains("vissue reject")
909}
910
911fn edge_connects(all: &[(String, IssueHeading)], a: &str, b: &str) -> bool {
912    all.iter().any(|(_, h)| {
913        if h.id == a {
914            crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(b)
915                || crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(b)
916        } else if h.id == b {
917            crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(a)
918                || crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(a)
919        } else {
920            false
921        }
922    })
923}
924
925/// Outcome of [`check`]: the findings, and how many were errors.
926#[derive(Debug, Clone)]
927pub struct CheckReport {
928    /// Rendered findings, ending in a summary line.
929    pub text: String,
930    /// Count of `[err]` findings.
931    pub errors: usize,
932    /// Count of `[warn]` findings.
933    pub warnings: usize,
934}
935
936/// Validate the corpus: every parent and blocker id resolves, dates parse, open
937/// issues carry a creation date, and ids are unique across projects.
938///
939/// # Errors
940///
941/// Returns an error if the corpus cannot be read.
942pub fn check(layout: &Layout) -> Result<CheckReport> {
943    let all = load_all(layout)?;
944
945    // A parent is usually another issue, and those ids are already in hand.
946    // Only the ones that are not send us looking through the rest of the
947    // tree, which on a tracker sharing a root with a notes vault is most of
948    // the bytes on disk.
949    let issue_ids: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
950    let unresolved: HashSet<String> = all
951        .iter()
952        .filter_map(|(_, h)| h.parent())
953        .filter(|p| !issue_ids.contains(p))
954        .map(str::to_string)
955        .collect();
956    let elsewhere = find_org_ids(layout, &unresolved)?;
957    let resolves = |id: &str| issue_ids.contains(id) || elsewhere.contains(id);
958
959    let mut out = String::new();
960
961    let mut errors = 0usize;
962    let mut warnings = 0usize;
963
964    let mut by_id: HashMap<String, (String, &IssueHeading)> = HashMap::new();
965    for (project, h) in &all {
966        if let Some(prev) = by_id.insert(h.id.clone(), (project.clone(), h)) {
967            // An error, not a note: an id that names two issues makes every
968            // blocker and parent edge pointing at it ambiguous.
969            writeln!(
970                out,
971                "[err]  duplicate id: {} appears in {} and {}",
972                h.id, prev.0, project
973            )?;
974            errors += 1;
975        }
976    }
977
978    for project in list_projects(layout)? {
979        let path = layout.project_issues_path(&project);
980        let doc = IssueDoc::parse_file(&project, &path)?;
981        match crate::org::protocol_from_preamble(&doc.preamble) {
982            None => {
983                writeln!(
984                    out,
985                    "[warn] {project}: preamble has no #+VISSUE: protocol stamp"
986                )?;
987                warnings += 1;
988            }
989            Some(n) if n < crate::org::PROTOCOL_VERSION => {
990                writeln!(
991                    out,
992                    "[warn] {project}: #+VISSUE: {n} is behind protocol {}",
993                    crate::org::PROTOCOL_VERSION
994                )?;
995                warnings += 1;
996            }
997            Some(n) if n > crate::org::PROTOCOL_VERSION => {
998                writeln!(
999                    out,
1000                    "[err]  {project}: #+VISSUE: {n} is newer than this vissue (protocol {})",
1001                    crate::org::PROTOCOL_VERSION
1002                )?;
1003                errors += 1;
1004            }
1005            Some(_) => {}
1006        }
1007        if !crate::org::preamble_has_keyword(&doc.preamble, "CATEGORY") {
1008            writeln!(
1009                out,
1010                "[warn] {project}: preamble has no #+CATEGORY: (org-agenda labels every row \"issues\")"
1011            )?;
1012            warnings += 1;
1013        }
1014        if !crate::org::preamble_has_keyword(&doc.preamble, "FILETAGS") {
1015            writeln!(out, "[warn] {project}: preamble has no #+FILETAGS:")?;
1016            warnings += 1;
1017        } else if !doc
1018            .tag_settings
1019            .filetags
1020            .iter()
1021            .any(|t| t.eq_ignore_ascii_case("noexport"))
1022        {
1023            writeln!(
1024                out,
1025                "[warn] {project}: #+FILETAGS: has no noexport; a vault publish will export this tracker"
1026            )?;
1027            warnings += 1;
1028        }
1029        if !crate::org::preamble_has_keyword(&doc.preamble, "TAGS") {
1030            writeln!(
1031                out,
1032                "[warn] {project}: preamble has no #+TAGS:; Emacs fast tag selection has no type group"
1033            )?;
1034            warnings += 1;
1035        }
1036        if !crate::org::preamble_has_keyword(
1037            &crate::org::merge_setupfile_settings(&doc.preamble, path.parent()),
1038            "PRIORITIES",
1039        ) {
1040            writeln!(
1041                out,
1042                "[warn] {project}: preamble has no #+PRIORITIES:; cookies default to C and the range is A..C"
1043            )?;
1044            warnings += 1;
1045        }
1046        let spec = doc.priority_spec();
1047        let mut type_not_tagged = 0usize;
1048        let mut exclusive_clash = 0usize;
1049        let mut priority_out_of_range = 0usize;
1050        let mut ordered_skip = 0usize;
1051        let mut done_with_open_children = 0usize;
1052        let mut gcal_ids = 0usize;
1053        let mut priority_in_drawer = 0usize;
1054        let mut blockedby_typo = 0usize;
1055        let mut blocker_as_ids = 0usize;
1056        let mut computed_specials = 0usize;
1057        let mut bad_effort = 0usize;
1058        for h in &doc.headings {
1059            if let Some(kind) = crate::props::get(&h.properties, crate::props::TYPE) {
1060                let kind = kind.trim();
1061                if !kind.is_empty()
1062                    && kind.chars().all(crate::model::is_org_tag_char)
1063                    && !h.org_tags.iter().any(|t| t == kind)
1064                {
1065                    type_not_tagged += 1;
1066                }
1067            }
1068            for group in &doc.tag_settings.exclusive {
1069                let hits = group
1070                    .iter()
1071                    .filter(|name| h.org_tags.iter().any(|t| t == *name))
1072                    .count();
1073                if hits > 1 {
1074                    exclusive_clash += 1;
1075                    break;
1076                }
1077            }
1078            if !spec.contains(h.priority) {
1079                priority_out_of_range += 1;
1080            }
1081            if crate::org::is_gcal_event_id(&h.id) {
1082                gcal_ids += 1;
1083            }
1084            if h.properties.contains_key("PRIORITY") {
1085                priority_in_drawer += 1;
1086            }
1087            if h.properties.contains_key("BLOCKEDBY") {
1088                blockedby_typo += 1;
1089            }
1090            if let Some(raw) = h.properties.get("BLOCKER")
1091                && !crate::org::is_edna_blocker(raw)
1092            {
1093                blocker_as_ids += 1;
1094            }
1095            if crate::org::COMPUTED_SPECIALS
1096                .iter()
1097                .any(|k| *k != "PRIORITY" && h.properties.contains_key(*k))
1098            {
1099                computed_specials += 1;
1100            }
1101            if let Some(effort) = h.effort()
1102                && !crate::org::is_org_effort(effort)
1103            {
1104                bad_effort += 1;
1105            }
1106            if let Some(pid) = h.parent()
1107                && let Some(parent) = doc.headings.iter().find(|p| p.id == pid)
1108                && crate::org::org_property_is_set(&parent.properties, "ORDERED")
1109                && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1110            {
1111                let earlier_open = doc.headings.iter().any(|sib| {
1112                    sib.parent() == Some(pid)
1113                        && sib.line_start < h.line_start
1114                        && sib.state != "DONE"
1115                        && sib.state != "CANCELLED"
1116                });
1117                if earlier_open && (h.state == "STARTED" || h.state == "DONE") {
1118                    ordered_skip += 1;
1119                }
1120            }
1121            if h.state == "DONE"
1122                && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1123                && doc.headings.iter().any(|c| {
1124                    c.parent() == Some(h.id.as_str()) && c.state != "DONE" && c.state != "CANCELLED"
1125                })
1126            {
1127                done_with_open_children += 1;
1128            }
1129        }
1130        if type_not_tagged > 0 {
1131            writeln!(
1132                out,
1133                "[warn] {project}: {type_not_tagged} heading(s) have :TYPE: that is a legal Org tag but is not on the heading"
1134            )?;
1135            warnings += 1;
1136        }
1137        if exclusive_clash > 0 {
1138            writeln!(
1139                out,
1140                "[warn] {project}: {exclusive_clash} heading(s) carry more than one tag from a #+TAGS: exclusive group"
1141            )?;
1142            warnings += 1;
1143        }
1144        if priority_in_drawer > 0 {
1145            writeln!(
1146                out,
1147                "[warn] {project}: {priority_in_drawer} heading(s) put :PRIORITY: in the drawer; Org reads the [#A] cookie"
1148            )?;
1149            warnings += 1;
1150        }
1151        if blockedby_typo > 0 {
1152            writeln!(
1153                out,
1154                "[warn] {project}: {blockedby_typo} heading(s) use :BLOCKEDBY: instead of :BLOCKED_BY:"
1155            )?;
1156            warnings += 1;
1157        }
1158        if blocker_as_ids > 0 {
1159            writeln!(
1160                out,
1161                "[warn] {project}: {blocker_as_ids} heading(s) use :BLOCKER: as a bare id list; a rewrite folds them into :BLOCKED_BY:"
1162            )?;
1163            warnings += 1;
1164        }
1165        if computed_specials > 0 {
1166            writeln!(
1167                out,
1168                "[warn] {project}: {computed_specials} heading(s) set a computed Org special (TODO/ITEM/TAGS/...) in the drawer; Org ignores it"
1169            )?;
1170            warnings += 1;
1171        }
1172        if bad_effort > 0 {
1173            writeln!(
1174                out,
1175                "[warn] {project}: {bad_effort} heading(s) have an Effort value Org will not parse"
1176            )?;
1177            warnings += 1;
1178        }
1179        if priority_out_of_range > 0 {
1180            writeln!(
1181                out,
1182                "[warn] {project}: {priority_out_of_range} heading(s) have a [#prio] outside #+PRIORITIES:"
1183            )?;
1184            warnings += 1;
1185        }
1186        if ordered_skip > 0 {
1187            writeln!(
1188                out,
1189                "[warn] {project}: {ordered_skip} heading(s) started or closed before an earlier ORDERED sibling"
1190            )?;
1191            warnings += 1;
1192        }
1193        if done_with_open_children > 0 {
1194            writeln!(
1195                out,
1196                "[warn] {project}: {done_with_open_children} DONE heading(s) still have open children (Org ORDERED / todo-dependencies)"
1197            )?;
1198            warnings += 1;
1199        }
1200        if gcal_ids > 0 {
1201            writeln!(
1202                out,
1203                "[err]  {project}: {gcal_ids} heading(s) use an org-gcal event id as :ID:"
1204            )?;
1205            errors += 1;
1206        }
1207    }
1208
1209    for (project, h) in &all {
1210        if let Some(parent) = h.parent()
1211            && !resolves(parent)
1212        {
1213            writeln!(
1214                out,
1215                "[err]  {} (in {}) :PARENT: {} -> not found",
1216                h.id, project, parent
1217            )?;
1218            errors += 1;
1219        }
1220        for blk in blocker_ids(h) {
1221            if !by_id.contains_key(blk) {
1222                writeln!(
1223                    out,
1224                    "[err]  {} (in {}) :BLOCKED_BY: {} -> not found",
1225                    h.id, project, blk
1226                )?;
1227                errors += 1;
1228            }
1229        }
1230        if let Some(d) = h.deadline()
1231            && parse_org_date(d).is_none()
1232        {
1233            writeln!(
1234                out,
1235                "[err]  {} (in {}) :DEADLINE: {} -> unparseable",
1236                h.id, project, d
1237            )?;
1238            errors += 1;
1239        }
1240        if let Some(s) = h.scheduled()
1241            && parse_org_date(s).is_none()
1242        {
1243            writeln!(
1244                out,
1245                "[err]  {} (in {}) :SCHEDULED: {} -> unparseable",
1246                h.id, project, s
1247            )?;
1248            errors += 1;
1249        }
1250        if matches!(h.state.as_str(), "TODO" | "STARTED") && !h.properties.contains_key("CREATED") {
1251            writeln!(
1252                out,
1253                "[warn] {} (in {}) state={} but :CREATED: is missing",
1254                h.id, project, h.state
1255            )?;
1256            warnings += 1;
1257        }
1258        if h.state == "DONE" && looks_like_reject_prose(&h.body) {
1259            writeln!(
1260                out,
1261                "[warn] {} (in {}) is DONE but the body reads as a reject",
1262                h.id, project
1263            )?;
1264            warnings += 1;
1265        }
1266        if crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_some() {
1267            writeln!(
1268                out,
1269                "[warn] {} (in {}) holds {} and sibling {}",
1270                h.id,
1271                project,
1272                h.state,
1273                crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).unwrap_or("?")
1274            )?;
1275            warnings += 1;
1276        }
1277    }
1278
1279    let known: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1280    for (project, h) in &all {
1281        for linked in crate::related::org_link_targets(&h.body, &known) {
1282            if edge_connects(&all, &h.id, &linked) {
1283                continue;
1284            }
1285            writeln!(
1286                out,
1287                "[warn] {} (in {}) mentions [[id:{}]] with no DISCOVERED_FROM or PIVOTED_TO either way",
1288                h.id, project, linked
1289            )?;
1290            warnings += 1;
1291        }
1292    }
1293
1294    // A :PARENT: loop passes every edge check, because each id resolves, yet
1295    // it makes the hierarchy unwalkable: `tree` stops on it and prints
1296    // "(cycle, stopping)". Naming it here is what keeps a corpus that holds
1297    // one from reading as clean.
1298    let mut settled: HashSet<&str> = HashSet::new();
1299    for (_, h) in &all {
1300        if settled.contains(h.id.as_str()) {
1301            continue;
1302        }
1303        let mut path: Vec<&str> = Vec::new();
1304        let mut on_path: HashSet<&str> = HashSet::new();
1305        let mut cursor = h.id.as_str();
1306        loop {
1307            if settled.contains(cursor) {
1308                break;
1309            }
1310            if !on_path.insert(cursor) {
1311                let start = path.iter().position(|id| *id == cursor).unwrap_or(0);
1312                let mut loop_ids: Vec<&str> = path[start..].to_vec();
1313                loop_ids.push(cursor);
1314                writeln!(out, "[err]  parent cycle: {}", loop_ids.join(" -> "))?;
1315                errors += 1;
1316                break;
1317            }
1318            path.push(cursor);
1319            match by_id.get(cursor).and_then(|(_, owner)| owner.parent()) {
1320                Some(parent) if by_id.contains_key(parent) => cursor = parent,
1321                _ => break,
1322            }
1323        }
1324        settled.extend(path);
1325    }
1326
1327    if errors == 0
1328        && let Err(err) = DependencyGraph::from_issues(&all)
1329    {
1330        writeln!(out, "[err]  blocker graph: {err}")?;
1331        errors += 1;
1332    }
1333
1334    writeln!(out)?;
1335    writeln!(
1336        out,
1337        "checked {} issue(s) across {} project(s): {} error(s), {} warning(s)",
1338        all.len(),
1339        list_projects(layout)?.len(),
1340        errors,
1341        warnings
1342    )?;
1343    Ok(CheckReport {
1344        text: out,
1345        errors,
1346        warnings,
1347    })
1348}
1349
1350/// Every issue referring to `target_id` through a blocker edge, a parent link,
1351/// a discovered-from or pivoted-to property, or a body mention. The relation
1352/// is named on the row.
1353///
1354/// # Errors
1355///
1356/// Returns an error if the corpus cannot be read.
1357pub fn backlinks(layout: &Layout, target_id: &str) -> Result<String> {
1358    let all = load_all(layout)?;
1359    let mut out = String::new();
1360    for (project, h) in &all {
1361        if h.id == target_id {
1362            continue;
1363        }
1364        let mut hit = false;
1365        if blocker_ids(h).contains(&target_id) {
1366            let _ = writeln!(out, "{:<22} (blocked-by) ({})", h.id, project);
1367            hit = true;
1368        }
1369        if h.parent() == Some(target_id) {
1370            let _ = writeln!(out, "{:<22} (parent) ({})", h.id, project);
1371            hit = true;
1372        }
1373        if crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(target_id) {
1374            let _ = writeln!(out, "{:<22} (discovered-from) ({})", h.id, project);
1375            hit = true;
1376        }
1377        if crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(target_id) {
1378            let _ = writeln!(out, "{:<22} (pivoted-to) ({})", h.id, project);
1379            hit = true;
1380        }
1381        if !hit && h.body.contains(target_id) {
1382            let _ = writeln!(out, "{:<22} (body mention) ({})", h.id, project);
1383        }
1384    }
1385    Ok(out)
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391
1392    #[test]
1393    fn dot_labels_escape_untrusted_issue_text() {
1394        assert_eq!(dot_quoted(r#"a "quoted" title"#), r#"a \"quoted\" title"#);
1395        // A trailing backslash would otherwise escape the closing quote and
1396        // let the rest of the title become DOT syntax.
1397        assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
1398        assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
1399    }
1400}