1use 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
62pub 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
96pub(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
109pub 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
116pub 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 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
170pub 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
186pub 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
209pub 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
245pub 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
286pub 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 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
341pub 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
382pub 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
395pub 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
445pub 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
497pub(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
557pub fn cycles(layout: &Layout) -> Result<String> {
559 let all = load_all(layout)?;
560 let graph = GraphIndex::new(&all);
561
562 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; }
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 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
631pub 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
641pub 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
651pub 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
708pub 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#[derive(Debug, Clone)]
799pub struct CheckReport {
800 pub text: String,
801 pub errors: usize,
802 pub warnings: usize,
803}
804
805pub fn check(layout: &Layout) -> Result<CheckReport> {
808 let all = load_all(layout)?;
809
810 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 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 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
950pub 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 assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
989 assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
990 }
991}