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