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::{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).collect::<Vec<_>>();
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) -> impl Iterator<Item = &str> {
56    h.properties
57        .get("BLOCKED_BY")
58        .into_iter()
59        .flat_map(|raw| raw.split(|c: char| c == ',' || c.is_whitespace()))
60        .map(str::trim)
61        .filter(|id| !id.is_empty())
62}
63
64/// One row per issue: id, state, priority cookie, title.
65///
66/// # Errors
67///
68/// Returns an error if the corpus cannot be read.
69pub fn list(
70    layout: &Layout,
71    project_filter: Option<&str>,
72    state_filter: Option<&str>,
73    ready_only: bool,
74) -> Result<String> {
75    let recs = load_recs(layout)?;
76    let rows = CatalogService::from_recs(&recs).issues_rows(ListQuery {
77        project: project_filter.map(str::to_string),
78        state: state_filter.map(str::to_string),
79        ready: ready_only,
80        ..ListQuery::default()
81    })?;
82    Ok(format_issue_rows(&recs, &rows))
83}
84
85fn format_issue_rows(recs: &[IssueRec], rows: &[IssueRow]) -> String {
86    let mut out = String::new();
87    for row in rows {
88        let suffix = recs
89            .iter()
90            .find(|r| r.heading.id == row.id)
91            .map(|r| claim_suffix(&r.heading))
92            .unwrap_or_default();
93        let _ = writeln!(
94            out,
95            "{:<22} {:<9} [#{}]  {}{}",
96            row.id, row.state, row.priority, row.title, suffix
97        );
98    }
99    out
100}
101
102/// ` (claimed 3d by <identity>)`, or nothing when no one holds the issue.
103/// Only a claimed issue grows the suffix, so an unclaimed corpus renders
104/// exactly as it did before claims existed.
105pub(crate) fn claim_suffix(h: &IssueHeading) -> String {
106    let Some(who) = h.claimed_by() else {
107        return String::new();
108    };
109    match h.claim_age_days(Local::now().date_naive()) {
110        Some(days) => format!("  (claimed {days}d by {who})"),
111        None => format!("  (claimed by {who})"),
112    }
113}
114
115/// Actionable issues: TODO or STARTED with no open blocker.
116///
117/// # Errors
118///
119/// Returns an error if the corpus cannot be read.
120pub fn ready(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
121    let recs = load_recs(layout)?;
122    let rows = CatalogService::from_recs(&recs).ready(project_filter)?;
123    Ok(format_issue_rows(&recs, &rows))
124}
125
126/// One issue's metadata, file range, and body text.
127///
128/// # Errors
129///
130/// Returns an error if the corpus cannot be read, or `id` is not in it.
131pub fn show(layout: &Layout, id: &str) -> Result<String> {
132    let (h, path, project) =
133        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
134    let mut out = String::new();
135    writeln!(out, "ID:       {}", h.id)?;
136    writeln!(out, "Project:  {project}")?;
137    writeln!(out, "Title:    {}", h.title)?;
138    writeln!(out, "State:    {}", h.state)?;
139    writeln!(out, "Priority: [#{}]", h.priority)?;
140    if let Some(who) = h.claimed_by() {
141        match h.claim_age_days(Local::now().date_naive()) {
142            Some(days) => writeln!(
143                out,
144                "Claimed:  {who} since {} ({days}d)",
145                h.claimed_at().unwrap_or("?")
146            )?,
147            None => writeln!(out, "Claimed:  {who}")?,
148        }
149    }
150    let tags = h.tags();
151    if !tags.is_empty() {
152        writeln!(out, "Tags:     {}", tags.join(", "))?;
153    }
154    if h.properties.iter().any(|(k, _)| k != "ID") {
155        writeln!(out, "Properties:")?;
156        for (k, v) in &h.properties {
157            if k == "ID" {
158                continue;
159            }
160            writeln!(out, "  {k}: {v}")?;
161        }
162    }
163    writeln!(
164        out,
165        "File:     {}:{}-{}",
166        path.display(),
167        h.line_start,
168        h.line_end
169    )?;
170    writeln!(out)?;
171    // The body is what the issue actually asks for, so printing the file
172    // range and stopping leaves every reader to go fetch it by hand.
173    let body = h.body.trim_end();
174    if body.is_empty() {
175        writeln!(out, "(no body; edit the range above to add one)")?;
176    } else {
177        writeln!(out, "Body:")?;
178        writeln!(out, "{body}")?;
179    }
180    Ok(out)
181}
182
183/// Case-insensitive substring scan over id, title, properties, and body. Linear
184/// in the corpus, which is the right cost until the issue count climbs.
185///
186/// # Errors
187///
188/// Returns an error if the corpus cannot be read.
189pub fn search(layout: &Layout, query: &str, limit: usize) -> Result<String> {
190    let recs = load_recs(layout)?;
191    let hits = CatalogService::from_recs(&recs).search(query, limit)?;
192    let mut out = String::new();
193    for h in hits {
194        let _ = writeln!(
195            out,
196            "{:<22} {:<9} [#{}]  {}  ({})",
197            h.id, h.state, h.priority, h.title, h.project
198        );
199    }
200    Ok(out)
201}
202
203/// Issues whose `:PARENT:` points at `parent_id`.
204///
205/// # Errors
206///
207/// Returns an error if the corpus cannot be read.
208pub fn children(layout: &Layout, parent_id: &str) -> Result<String> {
209    let mut rows: Vec<(String, IssueHeading)> = load_all(layout)?
210        .into_iter()
211        .filter(|(_, h)| h.parent() == Some(parent_id))
212        .collect();
213    rows.sort_by(|a, b| {
214        a.1.priority
215            .cmp(&b.1.priority)
216            .then_with(|| a.1.state.cmp(&b.1.state))
217            .then_with(|| a.1.id.cmp(&b.1.id))
218    });
219    let mut out = String::new();
220    for (project, h) in rows {
221        let _ = writeln!(
222            out,
223            "{:<22} {:<9} [#{}]  {}  ({})",
224            h.id, h.state, h.priority, h.title, project
225        );
226    }
227    Ok(out)
228}
229
230/// Open issues whose `:CREATED:` is at least `days` old. An issue without a
231/// parseable date is never stale, because its age is unknown.
232///
233/// # Errors
234///
235/// Returns an error if the corpus cannot be read.
236pub fn stale(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
237    let today = Local::now().date_naive();
238    let cutoff = today - chrono::Duration::days(days);
239    let mut rows: Vec<(String, IssueHeading, NaiveDate)> = Vec::new();
240    for (project, h) in load_all(layout)? {
241        if !project_selected(&project, project_filter) {
242            continue;
243        }
244        if !READY_STATES.contains(&h.state.as_str()) {
245            continue;
246        }
247        let Some(created) = h.properties.get("CREATED") else {
248            continue;
249        };
250        let Some(parsed) = parse_org_date(created) else {
251            continue;
252        };
253        if parsed <= cutoff {
254            rows.push((project, h, parsed));
255        }
256    }
257    rows.sort_by_key(|r| r.2);
258    let mut out = String::new();
259    for (project, h, created) in rows {
260        let age = (today - created).num_days();
261        let _ = writeln!(
262            out,
263            "{:<22} {:<9} [#{}]  {} ({}d, {})",
264            h.id, h.state, h.priority, h.title, age, project
265        );
266    }
267    Ok(out)
268}
269
270/// Every live claim, oldest first: the who-holds-what view. A claim is live
271/// while its issue is STARTED or BLOCKED (release happens on TODO, DONE, or
272/// CANCELLED), so this is the working set, not history.
273///
274/// # Errors
275///
276/// Returns an error if the corpus cannot be read, or JSON serialization fails
277/// when `json` is set.
278pub fn claims(
279    layout: &Layout,
280    holder_filter: Option<&str>,
281    project_filter: Option<&str>,
282    json: bool,
283) -> Result<String> {
284    let recs = load_recs(layout)?;
285    let rows = CatalogService::from_recs(&recs).claims(holder_filter, project_filter)?;
286
287    if json {
288        return Ok(format!("{}\n", serde_json::to_value(&rows)?));
289    }
290
291    let mut out = String::new();
292    for row in &rows {
293        let age_txt = if row.age_days < 0 {
294            "?d".to_string()
295        } else {
296            format!("{}d", row.age_days)
297        };
298        let _ = writeln!(
299            out,
300            "{:<22} {:<9} [#{}]  {:>4}  {}  {} ({})",
301            row.id,
302            row.state,
303            row.priority,
304            age_txt,
305            row.holder.as_deref().unwrap_or("?"),
306            row.title,
307            row.project
308        );
309    }
310    if rows.is_empty() {
311        out.push_str("no live claims\n");
312    }
313    Ok(out)
314}
315
316/// Dated open work in the next `days` days, plus anything already overdue.
317/// One line per (issue, date kind): deadlines first within a day, soonest day
318/// first, overdue on top with a negative day count.
319///
320/// # Errors
321///
322/// Returns an error if the corpus cannot be read.
323pub fn agenda(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
324    let today = Local::now().date_naive();
325    let horizon = today + chrono::Duration::days(days);
326    // kind sorts D before S so a same-day deadline outranks a scheduled start.
327    let mut rows: Vec<(NaiveDate, char, String, IssueHeading)> = Vec::new();
328    for (project, h) in load_all(layout)? {
329        if !project_selected(&project, project_filter) {
330            continue;
331        }
332        if !READY_STATES.contains(&h.state.as_str()) && h.state != "BLOCKED" {
333            continue;
334        }
335        for (kind, value) in [('D', h.deadline()), ('S', h.scheduled())] {
336            let Some(parsed) = value.and_then(parse_org_date) else {
337                continue;
338            };
339            if parsed <= horizon {
340                rows.push((parsed, kind, project.clone(), h.clone()));
341            }
342        }
343    }
344    rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.3.id.cmp(&b.3.id)));
345
346    let mut out = String::new();
347    for (date, kind, project, h) in rows {
348        let delta = (date - today).num_days();
349        let when = match delta {
350            d if d < 0 => format!("{}d overdue", -d),
351            0 => "today".to_string(),
352            d => format!("in {d}d"),
353        };
354        let label = if kind == 'D' { "deadline" } else { "scheduled" };
355        let _ = writeln!(
356            out,
357            "{date}  {label:<9} {when:<11} {:<22} {:<9} [#{}]  {}  ({})",
358            h.id, h.state, h.priority, h.title, project
359        );
360    }
361    if out.is_empty() {
362        out.push_str("nothing dated in range\n");
363    }
364    Ok(out)
365}
366
367pub(crate) fn parse_org_date(s: &str) -> Option<NaiveDate> {
368    let inner = s
369        .trim_start_matches(['<', '['])
370        .trim_end_matches(['>', ']']);
371    let token = inner.split_whitespace().next()?;
372    NaiveDate::parse_from_str(token, "%Y-%m-%d").ok()
373}
374
375/// The matching issue count and nothing else, for shell pipelines.
376///
377/// # Errors
378///
379/// Returns an error if the corpus cannot be read.
380pub fn count(
381    layout: &Layout,
382    project_filter: Option<&str>,
383    state_filter: Option<&str>,
384    ready_only: bool,
385) -> Result<String> {
386    let all = load_all(layout)?;
387    let active_blockers: HashSet<String> = if ready_only {
388        all.iter()
389            .filter(|(_, h)| h.state != "DONE" && h.state != "CANCELLED")
390            .map(|(_, h)| h.id.clone())
391            .collect()
392    } else {
393        HashSet::new()
394    };
395    let n = all
396        .iter()
397        .filter(|(project, h)| {
398            if !project_selected(project, project_filter) {
399                return false;
400            }
401            if let Some(s) = state_filter
402                && h.state != s
403            {
404                return false;
405            }
406            if ready_only {
407                if !READY_STATES.contains(&h.state.as_str()) {
408                    return false;
409                }
410                if blocker_ids(h).any(|b| active_blockers.contains(b)) {
411                    return false;
412                }
413            }
414            true
415        })
416        .count();
417    Ok(format!("{n}\n"))
418}
419
420/// One JSON object per line: every property, the logbook, the body, and the
421/// file line range. Round-trippable, and the seam other tools consume.
422///
423/// # Errors
424///
425/// Returns an error if the corpus cannot be read.
426pub fn export(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
427    let mut out = String::new();
428    for (project, h) in load_all(layout)? {
429        if !project_selected(&project, project_filter) {
430            continue;
431        }
432        let _ = writeln!(out, "{}", export_row(&project, h));
433    }
434    Ok(out)
435}
436
437/// The same lines as [`export`], grouped by project, from one read.
438///
439/// `export` filters a whole-corpus read down to one project, so digesting
440/// every project separately re-read the corpus once per project: quadratic
441/// in the project count, and six seconds on a tracker with a hundred of
442/// them. The rows are built by the same function, so a project's text here
443/// is byte for byte what `export(layout, Some(project))` returns, and the
444/// digests taken from it do not move.
445///
446/// # Errors
447///
448/// Returns an error if the corpus cannot be read.
449pub fn export_by_project(layout: &Layout) -> Result<BTreeMap<String, String>> {
450    let mut out: BTreeMap<String, String> = BTreeMap::new();
451    for (project, h) in load_all(layout)? {
452        let row = export_row(&project, h);
453        let _ = writeln!(out.entry(project).or_default(), "{row}");
454    }
455    Ok(out)
456}
457
458fn export_row(project: &str, h: IssueHeading) -> serde_json::Value {
459    let logbook: Vec<serde_json::Value> = h
460        .logbook
461        .iter()
462        .map(|e| {
463            let mut row = serde_json::json!({
464                "timestamp": e.timestamp,
465                "from": e.from_state,
466                "to": e.to_state,
467                "note": e.note,
468            });
469            if let Some(raw) = &e.raw {
470                row["raw"] = serde_json::Value::String(raw.clone());
471            }
472            row
473        })
474        .collect();
475    serde_json::json!({
476        "id": h.id,
477        "project": project,
478        "title": h.title,
479        "state": h.state,
480        "priority": h.priority.to_string(),
481        "properties": h.properties,
482        "org_tags": h.org_tags,
483        "tags": h.tags(),
484        "logbook": logbook,
485        "body": h.body,
486        "line_start": h.line_start,
487        "line_end": h.line_end,
488    })
489}
490
491/// Children and blockers below `root_id`, as indented text or Graphviz DOT.
492///
493/// # Errors
494///
495/// Returns an error if the corpus cannot be read, `root_id` is not in it, or
496/// `format` is not `ascii`, `text`, or `dot`.
497pub fn tree(layout: &Layout, root_id: &str, format: &str) -> Result<String> {
498    let all = load_all(layout)?;
499    let graph = GraphIndex::new(&all);
500    let Some(root_heading) = graph.by_id.get(root_id) else {
501        return Err(Error::IssueNotFound {
502            id: root_id.to_string(),
503        });
504    };
505    let mut out = String::new();
506    let root = root_heading.id.as_str();
507    match format {
508        "ascii" | "text" => tree_ascii(&graph, root, 0, &mut HashSet::new(), &mut out),
509        "dot" => tree_dot(&graph, root, &mut out),
510        _ => return Err(anyhow!("unknown format {format:?}; allowed: ascii, dot").into()),
511    }
512    Ok(out)
513}
514
515fn tree_ascii<'a>(
516    graph: &GraphIndex<'a>,
517    id: &'a str,
518    depth: usize,
519    seen: &mut HashSet<&'a str>,
520    out: &mut String,
521) {
522    if !seen.insert(id) {
523        let _ = writeln!(out, "{}{id} (cycle, stopping)", "  ".repeat(depth));
524        return;
525    }
526    let Some(h) = graph.by_id.get(id) else {
527        let _ = writeln!(out, "{}{id} (missing)", "  ".repeat(depth));
528        return;
529    };
530    let _ = writeln!(
531        out,
532        "{}{id} {:<9} [#{}]  {}",
533        "  ".repeat(depth),
534        h.state,
535        h.priority,
536        h.title
537    );
538    if let Some(blockers) = graph.blockers.get(id) {
539        for blocker in blockers {
540            let _ = writeln!(out, "{}* blocked-by {blocker}", "  ".repeat(depth + 1));
541        }
542    }
543    if let Some(kids) = graph.children.get(id) {
544        for k in kids {
545            tree_ascii(graph, k, depth + 1, seen, out);
546        }
547    }
548}
549
550/// Escape text for a Graphviz quoted string. Backslash goes first, or the
551/// escape introduced for a quote is itself re-escaped; a raw newline would end
552/// the statement early. Titles and ids are whatever someone committed to the
553/// tracker, so neither is trusted here.
554pub(crate) fn dot_quoted(text: &str) -> String {
555    text.replace('\\', "\\\\")
556        .replace('"', "\\\"")
557        .replace('\n', "\\n")
558        .replace('\r', "")
559}
560
561fn tree_dot<'a>(graph: &GraphIndex<'a>, root_id: &str, out: &mut String) {
562    let _ = writeln!(out, "digraph vissue_tree {{");
563    let _ = writeln!(out, "  rankdir=LR;");
564    let _ = writeln!(
565        out,
566        "  node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
567    );
568    let mut visited: HashSet<&str> = HashSet::new();
569    let mut stack = vec![graph.by_id.get(root_id).unwrap().id.as_str()];
570    while let Some(id) = stack.pop() {
571        if !visited.insert(id) {
572            continue;
573        }
574        if let Some(h) = graph.by_id.get(id) {
575            let _ = writeln!(
576                out,
577                "  \"{}\" [label=\"{}\\n{} [#{}]\"];",
578                dot_quoted(&h.id),
579                dot_quoted(&h.title),
580                dot_quoted(&h.state),
581                dot_quoted(&h.priority.to_string())
582            );
583            if let Some(kids) = graph.children.get(id) {
584                for k in kids {
585                    let _ = writeln!(
586                        out,
587                        "  \"{}\" -> \"{}\" [color=\"#00897B\"];",
588                        dot_quoted(&h.id),
589                        dot_quoted(k)
590                    );
591                    stack.push(k);
592                }
593            }
594            if let Some(blockers) = graph.blockers.get(id) {
595                for b in blockers {
596                    let _ = writeln!(
597                        out,
598                        "  \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
599                        dot_quoted(b),
600                        dot_quoted(&h.id)
601                    );
602                    stack.push(b);
603                }
604            }
605        }
606    }
607    let _ = writeln!(out, "}}");
608}
609
610/// Cycles in the blocker graph, one per line, or a line saying there are none.
611///
612/// # Errors
613///
614/// Returns an error if the corpus cannot be read.
615pub fn cycles(layout: &Layout) -> Result<String> {
616    let all = load_all(layout)?;
617    let graph = GraphIndex::new(&all);
618
619    // Colored depth-first search over BLOCKED_BY edges. Grey marks the
620    // current stack, black a finished node, so a shared blocker reached
621    // from two branches (a diamond) is never mistaken for a cycle.
622    const WHITE: u8 = 0;
623    const GREY: u8 = 1;
624    const BLACK: u8 = 2;
625    let mut color: HashMap<&str, u8> = HashMap::new();
626    let mut found: Vec<Vec<String>> = Vec::new();
627
628    fn dfs<'a>(
629        id: &'a str,
630        graph: &GraphIndex<'a>,
631        color: &mut HashMap<&'a str, u8>,
632        path: &mut Vec<&'a str>,
633        found: &mut Vec<Vec<String>>,
634    ) {
635        color.insert(id, GREY);
636        path.push(id);
637        if let Some(blockers) = graph.blockers.get(id) {
638            for b in blockers {
639                if !graph.by_id.contains_key(b) {
640                    continue; // a broken edge cannot close a loop; `check` reports it
641                }
642                match color.get(b).copied().unwrap_or(WHITE) {
643                    GREY => {
644                        let start = path.iter().position(|&x| x == *b).unwrap();
645                        let mut cycle: Vec<String> =
646                            path[start..].iter().map(|s| s.to_string()).collect();
647                        // Rotate so the smallest id leads: one canonical form
648                        // per cycle no matter where the walk entered it.
649                        let min = cycle
650                            .iter()
651                            .enumerate()
652                            .min_by(|a, b| a.1.cmp(b.1))
653                            .map(|(i, _)| i)
654                            .unwrap();
655                        cycle.rotate_left(min);
656                        cycle.push(cycle[0].clone());
657                        if !found.contains(&cycle) {
658                            found.push(cycle);
659                        }
660                    }
661                    WHITE => dfs(b, graph, color, path, found),
662                    _ => {}
663                }
664            }
665        }
666        path.pop();
667        color.insert(id, BLACK);
668    }
669
670    for (_, start) in &all {
671        if color.get(start.id.as_str()).copied().unwrap_or(WHITE) == WHITE {
672            let mut path = Vec::new();
673            dfs(start.id.as_str(), &graph, &mut color, &mut path, &mut found);
674        }
675    }
676
677    let mut out = String::new();
678    if found.is_empty() {
679        let _ = writeln!(out, "no cycles");
680    } else {
681        for cycle in found {
682            let _ = writeln!(out, "{}", cycle.join(" -> "));
683        }
684    }
685    Ok(out)
686}
687
688/// Transitive blocker ancestors, limited to a bounded number of hops.
689///
690/// # Errors
691///
692/// Returns an error if the corpus cannot be read, the blocker graph cannot be
693/// built, or `id` is not in it.
694pub fn ancestors(layout: &Layout, id: &str, depth: usize) -> Result<String> {
695    let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
696    let mut out = String::new();
697    for (distance, ancestor) in graph.ancestors(id, depth)? {
698        writeln!(out, "{distance} {ancestor}")?;
699    }
700    Ok(out)
701}
702
703/// Transitive issues waiting on this issue, limited to a bounded number of hops.
704///
705/// # Errors
706///
707/// Returns an error if the corpus cannot be read, the blocker graph cannot be
708/// built, or `id` is not in it.
709pub fn impact(layout: &Layout, id: &str, depth: usize) -> Result<String> {
710    let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
711    let mut out = String::new();
712    for (distance, descendant) in graph.descendants(id, depth)? {
713        writeln!(out, "{distance} {descendant}")?;
714    }
715    Ok(out)
716}
717
718/// The whole blocker and parent graph as Graphviz DOT. Node fill encodes state.
719///
720/// # Errors
721///
722/// Returns an error if the corpus cannot be read.
723pub fn graph(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
724    let all = load_all(layout)?;
725    let graph = GraphIndex::new(&all);
726    let mut out = String::new();
727    writeln!(out, "digraph vissue_graph {{")?;
728    writeln!(out, "  rankdir=LR;")?;
729    writeln!(out, "  node [shape=box, fontname=\"Jost\", style=filled];")?;
730    writeln!(out, "  edge [fontname=\"Jost\"];")?;
731    for (project, h) in &all {
732        if !project_selected(project, project_filter) {
733            continue;
734        }
735        let fill = match h.state.as_str() {
736            "DONE" => "#A5D6A7",
737            "CANCELLED" => "#CFD8DC",
738            "BLOCKED" => "#FFCC80",
739            "STARTED" => "#80CBC4",
740            _ => "#E0F2F1",
741        };
742        let _ = writeln!(
743            out,
744            "  \"{}\" [label=\"{}\\n{} [#{}]\", fillcolor=\"{}\"];",
745            dot_quoted(&h.id),
746            dot_quoted(&h.title),
747            dot_quoted(&h.state),
748            dot_quoted(&h.priority.to_string()),
749            fill
750        );
751    }
752    for (project, h) in &all {
753        if !project_selected(project, project_filter) {
754            continue;
755        }
756        if let Some(blockers) = graph.blockers.get(h.id.as_str()) {
757            for b in blockers {
758                writeln!(
759                    out,
760                    "  \"{}\" -> \"{}\" [color=\"#FF7043\"];",
761                    dot_quoted(b),
762                    dot_quoted(&h.id)
763                )?;
764            }
765        }
766        if let Some(parent) = h.parent() {
767            writeln!(
768                out,
769                "  \"{}\" -> \"{}\" [color=\"#00897B\", style=dashed];",
770                dot_quoted(parent),
771                dot_quoted(&h.id)
772            )?;
773        }
774    }
775    writeln!(out, "}}")?;
776    Ok(out)
777}
778
779/// A markdown roadmap grouped by project and state. Closed items collapse into
780/// one section so the document stays about live work.
781///
782/// # Errors
783///
784/// Returns an error if the corpus cannot be read.
785pub fn roadmap(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
786    let all = load_all(layout)?;
787    let mut by_project: BTreeMap<String, Vec<&IssueHeading>> = BTreeMap::new();
788    for (project, h) in &all {
789        if !project_selected(project, project_filter) {
790            continue;
791        }
792        by_project.entry(project.clone()).or_default().push(h);
793    }
794    let mut out = String::new();
795    writeln!(out, "# Roadmap")?;
796    writeln!(out)?;
797    writeln!(
798        out,
799        "Generated from `vissue roadmap`. Source of truth lives in the per-project issues.org files."
800    )?;
801    writeln!(out)?;
802    for (project, mut headings) in by_project {
803        headings.sort_by(|a, b| {
804            a.priority
805                .cmp(&b.priority)
806                .then_with(|| a.state.cmp(&b.state))
807                .then_with(|| a.id.cmp(&b.id))
808        });
809        let buckets = ["STARTED", "TODO", "BLOCKED"];
810        let active: Vec<&&IssueHeading> = headings
811            .iter()
812            .filter(|h| buckets.contains(&h.state.as_str()))
813            .collect();
814        let closed: Vec<&&IssueHeading> = headings
815            .iter()
816            .filter(|h| h.state == "DONE" || h.state == "CANCELLED")
817            .collect();
818        if active.is_empty() && closed.is_empty() {
819            continue;
820        }
821        writeln!(out, "## {project}")?;
822        writeln!(out)?;
823        for state in buckets {
824            let in_state: Vec<&&IssueHeading> = active
825                .iter()
826                .copied()
827                .filter(|h| h.state == state)
828                .collect();
829            if in_state.is_empty() {
830                continue;
831            }
832            writeln!(out, "### {state}")?;
833            writeln!(out)?;
834            for h in in_state {
835                let deadline = h
836                    .deadline()
837                    .map(|d| format!(" :: deadline {d}"))
838                    .unwrap_or_default();
839                let blockers = blocker_ids(h).collect::<Vec<_>>();
840                let blocked_by = if blockers.is_empty() {
841                    String::new()
842                } else {
843                    format!(" :: blocked by {}", blockers.join(", "))
844                };
845                writeln!(
846                    out,
847                    "- **{}** [#{}] {}{}{}",
848                    h.id, h.priority, h.title, deadline, blocked_by
849                )?;
850            }
851            writeln!(out)?;
852        }
853        if !closed.is_empty() {
854            writeln!(out, "### Closed ({} items)", closed.len())?;
855            writeln!(out)?;
856            for h in closed.iter().take(10) {
857                writeln!(
858                    out,
859                    "- {} [#{}] {} ({})",
860                    h.id, h.priority, h.title, h.state
861                )?;
862            }
863            if closed.len() > 10 {
864                writeln!(out, "- ... and {} more", closed.len() - 10)?;
865            }
866            writeln!(out)?;
867        }
868    }
869    Ok(out)
870}
871
872fn looks_like_reject_prose(body: &str) -> bool {
873    let lower = body.to_ascii_lowercase();
874    lower.contains("rejected") || lower.contains("vissue reject")
875}
876
877fn edge_connects(all: &[(String, IssueHeading)], a: &str, b: &str) -> bool {
878    all.iter().any(|(_, h)| {
879        if h.id == a {
880            h.properties.get("DISCOVERED_FROM").map(String::as_str) == Some(b)
881                || h.properties.get("PIVOTED_TO").map(String::as_str) == Some(b)
882        } else if h.id == b {
883            h.properties.get("DISCOVERED_FROM").map(String::as_str) == Some(a)
884                || h.properties.get("PIVOTED_TO").map(String::as_str) == Some(a)
885        } else {
886            false
887        }
888    })
889}
890
891/// Outcome of [`check`]: the findings, and how many were errors.
892#[derive(Debug, Clone)]
893pub struct CheckReport {
894    /// Rendered findings, ending in a summary line.
895    pub text: String,
896    /// Count of `[err]` findings.
897    pub errors: usize,
898    /// Count of `[warn]` findings.
899    pub warnings: usize,
900}
901
902/// Validate the corpus: every parent and blocker id resolves, dates parse, open
903/// issues carry a creation date, and ids are unique across projects.
904///
905/// # Errors
906///
907/// Returns an error if the corpus cannot be read.
908pub fn check(layout: &Layout) -> Result<CheckReport> {
909    let all = load_all(layout)?;
910
911    // A parent is usually another issue, and those ids are already in hand.
912    // Only the ones that are not send us looking through the rest of the
913    // tree, which on a tracker sharing a root with a notes vault is most of
914    // the bytes on disk.
915    let issue_ids: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
916    let unresolved: HashSet<String> = all
917        .iter()
918        .filter_map(|(_, h)| h.parent())
919        .filter(|p| !issue_ids.contains(p))
920        .map(str::to_string)
921        .collect();
922    let elsewhere = find_org_ids(layout, &unresolved)?;
923    let resolves = |id: &str| issue_ids.contains(id) || elsewhere.contains(id);
924
925    let mut out = String::new();
926
927    let mut errors = 0usize;
928    let mut warnings = 0usize;
929
930    let mut by_id: HashMap<String, (String, &IssueHeading)> = HashMap::new();
931    for (project, h) in &all {
932        if let Some(prev) = by_id.insert(h.id.clone(), (project.clone(), h)) {
933            // An error, not a note: an id that names two issues makes every
934            // blocker and parent edge pointing at it ambiguous.
935            writeln!(
936                out,
937                "[err]  duplicate id: {} appears in {} and {}",
938                h.id, prev.0, project
939            )?;
940            errors += 1;
941        }
942    }
943
944    for (project, h) in &all {
945        if let Some(parent) = h.parent()
946            && !resolves(parent)
947        {
948            writeln!(
949                out,
950                "[err]  {} (in {}) :PARENT: {} -> not found",
951                h.id, project, parent
952            )?;
953            errors += 1;
954        }
955        for blk in blocker_ids(h) {
956            if !by_id.contains_key(blk) {
957                writeln!(
958                    out,
959                    "[err]  {} (in {}) :BLOCKED_BY: {} -> not found",
960                    h.id, project, blk
961                )?;
962                errors += 1;
963            }
964        }
965        if let Some(d) = h.deadline()
966            && parse_org_date(d).is_none()
967        {
968            writeln!(
969                out,
970                "[err]  {} (in {}) :DEADLINE: {} -> unparseable",
971                h.id, project, d
972            )?;
973            errors += 1;
974        }
975        if let Some(s) = h.scheduled()
976            && parse_org_date(s).is_none()
977        {
978            writeln!(
979                out,
980                "[err]  {} (in {}) :SCHEDULED: {} -> unparseable",
981                h.id, project, s
982            )?;
983            errors += 1;
984        }
985        if matches!(h.state.as_str(), "TODO" | "STARTED") && !h.properties.contains_key("CREATED") {
986            writeln!(
987                out,
988                "[warn] {} (in {}) state={} but :CREATED: is missing",
989                h.id, project, h.state
990            )?;
991            warnings += 1;
992        }
993        if h.state == "DONE" && looks_like_reject_prose(&h.body) {
994            writeln!(
995                out,
996                "[warn] {} (in {}) is DONE but the body reads as a reject",
997                h.id, project
998            )?;
999            warnings += 1;
1000        }
1001        if h.properties.contains_key("SIBLING_TERMINAL") {
1002            writeln!(
1003                out,
1004                "[warn] {} (in {}) holds {} and sibling {}",
1005                h.id,
1006                project,
1007                h.state,
1008                h.properties
1009                    .get("SIBLING_TERMINAL")
1010                    .map(String::as_str)
1011                    .unwrap_or("?")
1012            )?;
1013            warnings += 1;
1014        }
1015    }
1016
1017    let known: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1018    for (project, h) in &all {
1019        for linked in crate::related::org_link_targets(&h.body, &known) {
1020            if edge_connects(&all, &h.id, &linked) {
1021                continue;
1022            }
1023            writeln!(
1024                out,
1025                "[warn] {} (in {}) mentions [[id:{}]] with no DISCOVERED_FROM or PIVOTED_TO either way",
1026                h.id, project, linked
1027            )?;
1028            warnings += 1;
1029        }
1030    }
1031
1032    // A :PARENT: loop passes every edge check, because each id resolves, yet
1033    // it makes the hierarchy unwalkable: `tree` stops on it and prints
1034    // "(cycle, stopping)". Naming it here is what keeps a corpus that holds
1035    // one from reading as clean.
1036    let mut settled: HashSet<&str> = HashSet::new();
1037    for (_, h) in &all {
1038        if settled.contains(h.id.as_str()) {
1039            continue;
1040        }
1041        let mut path: Vec<&str> = Vec::new();
1042        let mut on_path: HashSet<&str> = HashSet::new();
1043        let mut cursor = h.id.as_str();
1044        loop {
1045            if settled.contains(cursor) {
1046                break;
1047            }
1048            if !on_path.insert(cursor) {
1049                let start = path.iter().position(|id| *id == cursor).unwrap_or(0);
1050                let mut loop_ids: Vec<&str> = path[start..].to_vec();
1051                loop_ids.push(cursor);
1052                writeln!(out, "[err]  parent cycle: {}", loop_ids.join(" -> "))?;
1053                errors += 1;
1054                break;
1055            }
1056            path.push(cursor);
1057            match by_id.get(cursor).and_then(|(_, owner)| owner.parent()) {
1058                Some(parent) if by_id.contains_key(parent) => cursor = parent,
1059                _ => break,
1060            }
1061        }
1062        settled.extend(path);
1063    }
1064
1065    if errors == 0
1066        && let Err(err) = DependencyGraph::from_issues(&all)
1067    {
1068        writeln!(out, "[err]  blocker graph: {err}")?;
1069        errors += 1;
1070    }
1071
1072    writeln!(out)?;
1073    writeln!(
1074        out,
1075        "checked {} issue(s) across {} project(s): {} error(s), {} warning(s)",
1076        all.len(),
1077        list_projects(layout)?.len(),
1078        errors,
1079        warnings
1080    )?;
1081    Ok(CheckReport {
1082        text: out,
1083        errors,
1084        warnings,
1085    })
1086}
1087
1088/// Every issue referring to `target_id` through a blocker edge, a parent link,
1089/// a discovered-from or pivoted-to property, or a body mention. The relation
1090/// is named on the row.
1091///
1092/// # Errors
1093///
1094/// Returns an error if the corpus cannot be read.
1095pub fn backlinks(layout: &Layout, target_id: &str) -> Result<String> {
1096    let all = load_all(layout)?;
1097    let mut out = String::new();
1098    for (project, h) in &all {
1099        if h.id == target_id {
1100            continue;
1101        }
1102        let mut hit = false;
1103        if blocker_ids(h).any(|b| b == target_id) {
1104            let _ = writeln!(out, "{:<22} (blocked-by) ({})", h.id, project);
1105            hit = true;
1106        }
1107        if h.parent() == Some(target_id) {
1108            let _ = writeln!(out, "{:<22} (parent) ({})", h.id, project);
1109            hit = true;
1110        }
1111        if h.properties.get("DISCOVERED_FROM").map(|s| s.as_str()) == Some(target_id) {
1112            let _ = writeln!(out, "{:<22} (discovered-from) ({})", h.id, project);
1113            hit = true;
1114        }
1115        if h.properties.get("PIVOTED_TO").map(|s| s.as_str()) == Some(target_id) {
1116            let _ = writeln!(out, "{:<22} (pivoted-to) ({})", h.id, project);
1117            hit = true;
1118        }
1119        if !hit && h.body.contains(target_id) {
1120            let _ = writeln!(out, "{:<22} (body mention) ({})", h.id, project);
1121        }
1122    }
1123    Ok(out)
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129
1130    #[test]
1131    fn dot_labels_escape_untrusted_issue_text() {
1132        assert_eq!(dot_quoted(r#"a "quoted" title"#), r#"a \"quoted\" title"#);
1133        // A trailing backslash would otherwise escape the closing quote and
1134        // let the rest of the title become DOT syntax.
1135        assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
1136        assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
1137    }
1138}