1use 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
64pub 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
102pub(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
115pub 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
126pub 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 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
183pub 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
203pub 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
230pub 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
270pub 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
316pub 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 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
375pub 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
420pub 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
437pub 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
491pub 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
550pub(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
610pub fn cycles(layout: &Layout) -> Result<String> {
616 let all = load_all(layout)?;
617 let graph = GraphIndex::new(&all);
618
619 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; }
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 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
688pub 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
703pub 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
718pub 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
779pub 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
872#[derive(Debug, Clone)]
874pub struct CheckReport {
875 pub text: String,
877 pub errors: usize,
879 pub warnings: usize,
881}
882
883pub fn check(layout: &Layout) -> Result<CheckReport> {
890 let all = load_all(layout)?;
891
892 let issue_ids: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
897 let unresolved: HashSet<String> = all
898 .iter()
899 .filter_map(|(_, h)| h.parent())
900 .filter(|p| !issue_ids.contains(p))
901 .map(str::to_string)
902 .collect();
903 let elsewhere = find_org_ids(layout, &unresolved)?;
904 let resolves = |id: &str| issue_ids.contains(id) || elsewhere.contains(id);
905
906 let mut out = String::new();
907
908 let mut errors = 0usize;
909 let mut warnings = 0usize;
910
911 let mut by_id: HashMap<String, (String, &IssueHeading)> = HashMap::new();
912 for (project, h) in &all {
913 if let Some(prev) = by_id.insert(h.id.clone(), (project.clone(), h)) {
914 writeln!(
917 out,
918 "[err] duplicate id: {} appears in {} and {}",
919 h.id, prev.0, project
920 )?;
921 errors += 1;
922 }
923 }
924
925 for (project, h) in &all {
926 if let Some(parent) = h.parent()
927 && !resolves(parent)
928 {
929 writeln!(
930 out,
931 "[err] {} (in {}) :PARENT: {} -> not found",
932 h.id, project, parent
933 )?;
934 errors += 1;
935 }
936 for blk in blocker_ids(h) {
937 if !by_id.contains_key(blk) {
938 writeln!(
939 out,
940 "[err] {} (in {}) :BLOCKED_BY: {} -> not found",
941 h.id, project, blk
942 )?;
943 errors += 1;
944 }
945 }
946 if let Some(d) = h.deadline()
947 && parse_org_date(d).is_none()
948 {
949 writeln!(
950 out,
951 "[err] {} (in {}) :DEADLINE: {} -> unparseable",
952 h.id, project, d
953 )?;
954 errors += 1;
955 }
956 if let Some(s) = h.scheduled()
957 && parse_org_date(s).is_none()
958 {
959 writeln!(
960 out,
961 "[err] {} (in {}) :SCHEDULED: {} -> unparseable",
962 h.id, project, s
963 )?;
964 errors += 1;
965 }
966 if matches!(h.state.as_str(), "TODO" | "STARTED") && !h.properties.contains_key("CREATED") {
967 writeln!(
968 out,
969 "[warn] {} (in {}) state={} but :CREATED: is missing",
970 h.id, project, h.state
971 )?;
972 warnings += 1;
973 }
974 }
975
976 let mut settled: HashSet<&str> = HashSet::new();
981 for (_, h) in &all {
982 if settled.contains(h.id.as_str()) {
983 continue;
984 }
985 let mut path: Vec<&str> = Vec::new();
986 let mut on_path: HashSet<&str> = HashSet::new();
987 let mut cursor = h.id.as_str();
988 loop {
989 if settled.contains(cursor) {
990 break;
991 }
992 if !on_path.insert(cursor) {
993 let start = path.iter().position(|id| *id == cursor).unwrap_or(0);
994 let mut loop_ids: Vec<&str> = path[start..].to_vec();
995 loop_ids.push(cursor);
996 writeln!(out, "[err] parent cycle: {}", loop_ids.join(" -> "))?;
997 errors += 1;
998 break;
999 }
1000 path.push(cursor);
1001 match by_id.get(cursor).and_then(|(_, owner)| owner.parent()) {
1002 Some(parent) if by_id.contains_key(parent) => cursor = parent,
1003 _ => break,
1004 }
1005 }
1006 settled.extend(path);
1007 }
1008
1009 if errors == 0
1010 && let Err(err) = DependencyGraph::from_issues(&all)
1011 {
1012 writeln!(out, "[err] blocker graph: {err}")?;
1013 errors += 1;
1014 }
1015
1016 writeln!(out)?;
1017 writeln!(
1018 out,
1019 "checked {} issue(s) across {} project(s): {} error(s), {} warning(s)",
1020 all.len(),
1021 list_projects(layout)?.len(),
1022 errors,
1023 warnings
1024 )?;
1025 Ok(CheckReport {
1026 text: out,
1027 errors,
1028 warnings,
1029 })
1030}
1031
1032pub fn backlinks(layout: &Layout, target_id: &str) -> Result<String> {
1039 let all = load_all(layout)?;
1040 let mut out = String::new();
1041 for (project, h) in &all {
1042 if h.id == target_id {
1043 continue;
1044 }
1045 let mut hit = false;
1046 if blocker_ids(h).any(|b| b == target_id) {
1047 let _ = writeln!(out, "{:<22} (blocked-by) ({})", h.id, project);
1048 hit = true;
1049 }
1050 if h.parent() == Some(target_id) {
1051 let _ = writeln!(out, "{:<22} (parent) ({})", h.id, project);
1052 hit = true;
1053 }
1054 if h.properties.get("DISCOVERED_FROM").map(|s| s.as_str()) == Some(target_id) {
1055 let _ = writeln!(out, "{:<22} (discovered-from) ({})", h.id, project);
1056 hit = true;
1057 }
1058 if !hit && h.body.contains(target_id) {
1059 let _ = writeln!(out, "{:<22} (body mention) ({})", h.id, project);
1060 }
1061 }
1062 Ok(out)
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067 use super::*;
1068
1069 #[test]
1070 fn dot_labels_escape_untrusted_issue_text() {
1071 assert_eq!(dot_quoted(r#"a "quoted" title"#), r#"a \"quoted\" title"#);
1072 assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
1075 assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
1076 }
1077}