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::{IssueDoc, find_by_id, find_org_ids, list_projects, load_all, project_selected};
17use crate::views::{IssueRec, IssueRow, ListQuery};
18
19struct GraphIndex<'a> {
20 by_id: HashMap<&'a str, &'a IssueHeading>,
21 children: HashMap<&'a str, Vec<&'a str>>,
22 blockers: HashMap<&'a str, Vec<&'a str>>,
23}
24
25impl<'a> GraphIndex<'a> {
26 fn new(all: &'a [(String, IssueHeading)]) -> Self {
27 let mut index = Self {
28 by_id: HashMap::with_capacity(all.len()),
29 children: HashMap::new(),
30 blockers: HashMap::new(),
31 };
32 for (_, h) in all {
33 index.by_id.insert(h.id.as_str(), h);
34 }
35 for (_, h) in all {
36 if let Some(parent) = h.parent() {
37 index
38 .children
39 .entry(parent)
40 .or_default()
41 .push(h.id.as_str());
42 }
43 let blockers = blocker_ids(h);
44 if !blockers.is_empty() {
45 index.blockers.insert(h.id.as_str(), blockers);
46 }
47 }
48 for children in index.children.values_mut() {
49 children.sort_unstable();
50 }
51 index
52 }
53}
54
55fn blocker_ids(h: &IssueHeading) -> Vec<&str> {
56 let mut ids = Vec::new();
57 if let Some(raw) = crate::props::get(&h.properties, crate::props::BLOCKED_BY) {
58 ids.extend(
59 raw.split(|c: char| c == ',' || c.is_whitespace())
60 .map(str::trim)
61 .filter(|id| !id.is_empty()),
62 );
63 }
64 if let Some(raw) = h.properties.get("BLOCKER") {
65 if crate::org::is_edna_blocker(raw) {
66 ids.extend(crate::org::edna_blocker_id_refs(raw));
67 } else {
68 ids.extend(
69 raw.split(|c: char| c == ',' || c.is_whitespace())
70 .map(str::trim)
71 .filter(|id| !id.is_empty()),
72 );
73 }
74 }
75 let mut unique = Vec::new();
76 for id in ids {
77 if !unique.contains(&id) {
78 unique.push(id);
79 }
80 }
81 unique
82}
83
84pub fn list(
90 layout: &Layout,
91 project_filter: Option<&str>,
92 state_filter: Option<&str>,
93 ready_only: bool,
94) -> Result<String> {
95 let recs = load_recs(layout)?;
96 let rows = CatalogService::from_recs(&recs).issues_rows(ListQuery {
97 project: project_filter.map(str::to_string),
98 state: state_filter.map(str::to_string),
99 ready: ready_only,
100 ..ListQuery::default()
101 })?;
102 Ok(format_issue_rows(&recs, &rows))
103}
104
105fn format_issue_rows(recs: &[IssueRec], rows: &[IssueRow]) -> String {
106 let mut out = String::new();
107 for row in rows {
108 let suffix = recs
109 .iter()
110 .find(|r| r.heading.id == row.id)
111 .map(|r| claim_suffix(&r.heading))
112 .unwrap_or_default();
113 let _ = writeln!(
114 out,
115 "{:<22} {:<9} [#{}] {}{}",
116 row.id, row.state, row.priority, row.title, suffix
117 );
118 }
119 out
120}
121
122pub(crate) fn claim_suffix(h: &IssueHeading) -> String {
126 let Some(who) = h.claimed_by() else {
127 return String::new();
128 };
129 match h.claim_age_days(Local::now().date_naive()) {
130 Some(days) => format!(" (claimed {days}d by {who})"),
131 None => format!(" (claimed by {who})"),
132 }
133}
134
135pub fn ready(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
141 let recs = load_recs(layout)?;
142 let rows = CatalogService::from_recs(&recs).ready(project_filter)?;
143 Ok(format_issue_rows(&recs, &rows))
144}
145
146pub fn show(layout: &Layout, id: &str) -> Result<String> {
152 let (h, path, project) =
153 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
154 let mut out = String::new();
155 writeln!(out, "ID: {}", h.id)?;
156 writeln!(out, "Project: {project}")?;
157 writeln!(out, "Title: {}", h.title)?;
158 writeln!(out, "State: {}", h.state)?;
159 writeln!(out, "Priority: [#{}]", h.priority)?;
160 if let Some(who) = h.claimed_by() {
161 match h.claim_age_days(Local::now().date_naive()) {
162 Some(days) => writeln!(
163 out,
164 "Claimed: {who} since {} ({days}d)",
165 h.claimed_at().unwrap_or("?")
166 )?,
167 None => writeln!(out, "Claimed: {who}")?,
168 }
169 }
170 let settings = crate::org::tag_settings_from_preamble(
171 &IssueDoc::parse_file(&project, &path)
172 .map(|d| d.preamble)
173 .unwrap_or_default(),
174 );
175 let tags = settings.all_tags(&h.tags());
176 if !tags.is_empty() {
177 writeln!(out, "Tags: {}", tags.join(", "))?;
178 }
179 if h.properties.iter().any(|(k, _)| k != "ID") {
180 writeln!(out, "Properties:")?;
181 for (k, v) in &h.properties {
182 if k == "ID" {
183 continue;
184 }
185 writeln!(out, " {k}: {v}")?;
186 }
187 }
188 writeln!(
189 out,
190 "File: {}:{}-{}",
191 path.display(),
192 h.line_start,
193 h.line_end
194 )?;
195 writeln!(out)?;
196 let body = h.body.trim_end();
199 if body.is_empty() {
200 writeln!(out, "(no body; edit the range above to add one)")?;
201 } else {
202 writeln!(out, "Body:")?;
203 writeln!(out, "{body}")?;
204 }
205 Ok(out)
206}
207
208pub fn search(layout: &Layout, query: &str, limit: usize) -> Result<String> {
215 let recs = load_recs(layout)?;
216 let hits = CatalogService::from_recs(&recs).search(query, limit)?;
217 let mut out = String::new();
218 for h in hits {
219 let _ = writeln!(
220 out,
221 "{:<22} {:<9} [#{}] {} ({})",
222 h.id, h.state, h.priority, h.title, h.project
223 );
224 }
225 Ok(out)
226}
227
228pub fn children(layout: &Layout, parent_id: &str) -> Result<String> {
234 let mut rows: Vec<(String, IssueHeading)> = load_all(layout)?
235 .into_iter()
236 .filter(|(_, h)| h.parent() == Some(parent_id))
237 .collect();
238 rows.sort_by(|a, b| {
239 a.1.priority
240 .cmp(&b.1.priority)
241 .then_with(|| a.1.state.cmp(&b.1.state))
242 .then_with(|| a.1.id.cmp(&b.1.id))
243 });
244 let mut out = String::new();
245 for (project, h) in rows {
246 let _ = writeln!(
247 out,
248 "{:<22} {:<9} [#{}] {} ({})",
249 h.id, h.state, h.priority, h.title, project
250 );
251 }
252 Ok(out)
253}
254
255pub fn stale(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
262 let today = Local::now().date_naive();
263 let cutoff = today - chrono::Duration::days(days);
264 let mut rows: Vec<(String, IssueHeading, NaiveDate)> = Vec::new();
265 for (project, h) in load_all(layout)? {
266 if !project_selected(&project, project_filter) {
267 continue;
268 }
269 if !READY_STATES.contains(&h.state.as_str()) {
270 continue;
271 }
272 let Some(created) = h.properties.get("CREATED") else {
273 continue;
274 };
275 let Some(parsed) = parse_org_date(created) else {
276 continue;
277 };
278 if parsed <= cutoff {
279 rows.push((project, h, parsed));
280 }
281 }
282 rows.sort_by_key(|r| r.2);
283 let mut out = String::new();
284 for (project, h, created) in rows {
285 let age = (today - created).num_days();
286 let _ = writeln!(
287 out,
288 "{:<22} {:<9} [#{}] {} ({}d, {})",
289 h.id, h.state, h.priority, h.title, age, project
290 );
291 }
292 Ok(out)
293}
294
295pub fn claims(
304 layout: &Layout,
305 holder_filter: Option<&str>,
306 project_filter: Option<&str>,
307 json: bool,
308) -> Result<String> {
309 let recs = load_recs(layout)?;
310 let rows = CatalogService::from_recs(&recs).claims(holder_filter, project_filter)?;
311
312 if json {
313 return Ok(format!("{}\n", serde_json::to_value(&rows)?));
314 }
315
316 let mut out = String::new();
317 for row in &rows {
318 let age_txt = if row.age_days < 0 {
319 "?d".to_string()
320 } else {
321 format!("{}d", row.age_days)
322 };
323 let _ = writeln!(
324 out,
325 "{:<22} {:<9} [#{}] {:>4} {} {} ({})",
326 row.id,
327 row.state,
328 row.priority,
329 age_txt,
330 row.holder.as_deref().unwrap_or("?"),
331 row.title,
332 row.project
333 );
334 }
335 if rows.is_empty() {
336 out.push_str("no live claims\n");
337 }
338 Ok(out)
339}
340
341pub fn agenda(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
349 let today = Local::now().date_naive();
350 let horizon = today + chrono::Duration::days(days);
351 let mut rows: Vec<(NaiveDate, char, String, IssueHeading)> = Vec::new();
353 for (project, h) in load_all(layout)? {
354 if !project_selected(&project, project_filter) {
355 continue;
356 }
357 if !READY_STATES.contains(&h.state.as_str()) && h.state != "BLOCKED" {
358 continue;
359 }
360 for (kind, value) in [('D', h.deadline()), ('S', h.scheduled())] {
361 let Some(parsed) = value.and_then(parse_org_date) else {
362 continue;
363 };
364 if parsed <= horizon {
365 rows.push((parsed, kind, project.clone(), h.clone()));
366 }
367 }
368 }
369 rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.3.id.cmp(&b.3.id)));
370
371 let mut out = String::new();
372 for (date, kind, project, h) in rows {
373 let delta = (date - today).num_days();
374 let when = match delta {
375 d if d < 0 => format!("{}d overdue", -d),
376 0 => "today".to_string(),
377 d => format!("in {d}d"),
378 };
379 let label = if kind == 'D' { "deadline" } else { "scheduled" };
380 let _ = writeln!(
381 out,
382 "{date} {label:<9} {when:<11} {:<22} {:<9} [#{}] {} ({})",
383 h.id, h.state, h.priority, h.title, project
384 );
385 }
386 if out.is_empty() {
387 out.push_str("nothing dated in range\n");
388 }
389 Ok(out)
390}
391
392pub(crate) fn parse_org_date(s: &str) -> Option<NaiveDate> {
393 let inner = s
394 .trim_start_matches(['<', '['])
395 .trim_end_matches(['>', ']']);
396 let token = inner.split_whitespace().next()?;
397 NaiveDate::parse_from_str(token, "%Y-%m-%d").ok()
398}
399
400pub fn count(
406 layout: &Layout,
407 project_filter: Option<&str>,
408 state_filter: Option<&str>,
409 ready_only: bool,
410) -> Result<String> {
411 let all = load_all(layout)?;
412 let active_blockers: HashSet<String> = if ready_only {
413 all.iter()
414 .filter(|(_, h)| h.state != "DONE" && h.state != "CANCELLED")
415 .map(|(_, h)| h.id.clone())
416 .collect()
417 } else {
418 HashSet::new()
419 };
420 let n = all
421 .iter()
422 .filter(|(project, h)| {
423 if !project_selected(project, project_filter) {
424 return false;
425 }
426 if let Some(s) = state_filter
427 && h.state != s
428 {
429 return false;
430 }
431 if ready_only {
432 if !READY_STATES.contains(&h.state.as_str()) {
433 return false;
434 }
435 if blocker_ids(h).iter().any(|b| active_blockers.contains(*b)) {
436 return false;
437 }
438 }
439 true
440 })
441 .count();
442 Ok(format!("{n}\n"))
443}
444
445pub fn export(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
452 let mut out = String::new();
453 for rec in load_recs(layout)? {
454 if !project_selected(&rec.project, project_filter) {
455 continue;
456 }
457 let _ = writeln!(
458 out,
459 "{}",
460 export_row(&rec.project, rec.heading, &rec.tag_settings)
461 );
462 }
463 Ok(out)
464}
465
466pub fn export_by_project(layout: &Layout) -> Result<BTreeMap<String, String>> {
479 let mut out: BTreeMap<String, String> = BTreeMap::new();
480 for rec in load_recs(layout)? {
481 let row = export_row(&rec.project, rec.heading, &rec.tag_settings);
482 let _ = writeln!(out.entry(rec.project).or_default(), "{row}");
483 }
484 Ok(out)
485}
486
487fn export_row(
488 project: &str,
489 h: IssueHeading,
490 settings: &crate::org::TagSettings,
491) -> serde_json::Value {
492 let logbook: Vec<serde_json::Value> = h
493 .logbook
494 .iter()
495 .map(|e| {
496 let mut row = serde_json::json!({
497 "timestamp": e.timestamp,
498 "from": e.from_state,
499 "to": e.to_state,
500 "note": e.note,
501 });
502 if let Some(raw) = &e.raw {
503 row["raw"] = serde_json::Value::String(raw.clone());
504 }
505 row
506 })
507 .collect();
508 serde_json::json!({
509 "id": h.id,
510 "project": project,
511 "title": h.title,
512 "state": h.state,
513 "priority": h.priority.to_string(),
514 "properties": h.properties,
515 "org_tags": h.org_tags,
516 "tags": h.tags(),
517 "all_tags": settings.all_tags(&h.tags()),
518 "logbook": logbook,
519 "body": h.body,
520 "line_start": h.line_start,
521 "line_end": h.line_end,
522 })
523}
524
525pub fn tree(layout: &Layout, root_id: &str, format: &str) -> Result<String> {
532 let all = load_all(layout)?;
533 let graph = GraphIndex::new(&all);
534 let Some(root_heading) = graph.by_id.get(root_id) else {
535 return Err(Error::IssueNotFound {
536 id: root_id.to_string(),
537 });
538 };
539 let mut out = String::new();
540 let root = root_heading.id.as_str();
541 match format {
542 "ascii" | "text" => tree_ascii(&graph, root, 0, &mut HashSet::new(), &mut out),
543 "dot" => tree_dot(&graph, root, &mut out),
544 _ => return Err(anyhow!("unknown format {format:?}; allowed: ascii, dot").into()),
545 }
546 Ok(out)
547}
548
549fn tree_ascii<'a>(
550 graph: &GraphIndex<'a>,
551 id: &'a str,
552 depth: usize,
553 seen: &mut HashSet<&'a str>,
554 out: &mut String,
555) {
556 if !seen.insert(id) {
557 let _ = writeln!(out, "{}{id} (cycle, stopping)", " ".repeat(depth));
558 return;
559 }
560 let Some(h) = graph.by_id.get(id) else {
561 let _ = writeln!(out, "{}{id} (missing)", " ".repeat(depth));
562 return;
563 };
564 let _ = writeln!(
565 out,
566 "{}{id} {:<9} [#{}] {}",
567 " ".repeat(depth),
568 h.state,
569 h.priority,
570 h.title
571 );
572 if let Some(blockers) = graph.blockers.get(id) {
573 for blocker in blockers {
574 let _ = writeln!(out, "{}* blocked-by {blocker}", " ".repeat(depth + 1));
575 }
576 }
577 if let Some(kids) = graph.children.get(id) {
578 for k in kids {
579 tree_ascii(graph, k, depth + 1, seen, out);
580 }
581 }
582}
583
584pub(crate) fn dot_quoted(text: &str) -> String {
589 text.replace('\\', "\\\\")
590 .replace('"', "\\\"")
591 .replace('\n', "\\n")
592 .replace('\r', "")
593}
594
595fn tree_dot<'a>(graph: &GraphIndex<'a>, root_id: &str, out: &mut String) {
596 let _ = writeln!(out, "digraph vissue_tree {{");
597 let _ = writeln!(out, " rankdir=LR;");
598 let _ = writeln!(
599 out,
600 " node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
601 );
602 let mut visited: HashSet<&str> = HashSet::new();
603 let mut stack = vec![graph.by_id.get(root_id).unwrap().id.as_str()];
604 while let Some(id) = stack.pop() {
605 if !visited.insert(id) {
606 continue;
607 }
608 if let Some(h) = graph.by_id.get(id) {
609 let _ = writeln!(
610 out,
611 " \"{}\" [label=\"{}\\n{} [#{}]\"];",
612 dot_quoted(&h.id),
613 dot_quoted(&h.title),
614 dot_quoted(&h.state),
615 dot_quoted(&h.priority.to_string())
616 );
617 if let Some(kids) = graph.children.get(id) {
618 for k in kids {
619 let _ = writeln!(
620 out,
621 " \"{}\" -> \"{}\" [color=\"#00897B\"];",
622 dot_quoted(&h.id),
623 dot_quoted(k)
624 );
625 stack.push(k);
626 }
627 }
628 if let Some(blockers) = graph.blockers.get(id) {
629 for b in blockers {
630 let _ = writeln!(
631 out,
632 " \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
633 dot_quoted(b),
634 dot_quoted(&h.id)
635 );
636 stack.push(b);
637 }
638 }
639 }
640 }
641 let _ = writeln!(out, "}}");
642}
643
644pub fn cycles(layout: &Layout) -> Result<String> {
650 let all = load_all(layout)?;
651 let graph = GraphIndex::new(&all);
652
653 const WHITE: u8 = 0;
657 const GREY: u8 = 1;
658 const BLACK: u8 = 2;
659 let mut color: HashMap<&str, u8> = HashMap::new();
660 let mut found: Vec<Vec<String>> = Vec::new();
661
662 fn dfs<'a>(
663 id: &'a str,
664 graph: &GraphIndex<'a>,
665 color: &mut HashMap<&'a str, u8>,
666 path: &mut Vec<&'a str>,
667 found: &mut Vec<Vec<String>>,
668 ) {
669 color.insert(id, GREY);
670 path.push(id);
671 if let Some(blockers) = graph.blockers.get(id) {
672 for b in blockers {
673 if !graph.by_id.contains_key(b) {
674 continue; }
676 match color.get(b).copied().unwrap_or(WHITE) {
677 GREY => {
678 let start = path.iter().position(|&x| x == *b).unwrap();
679 let mut cycle: Vec<String> =
680 path[start..].iter().map(|s| s.to_string()).collect();
681 let min = cycle
684 .iter()
685 .enumerate()
686 .min_by(|a, b| a.1.cmp(b.1))
687 .map(|(i, _)| i)
688 .unwrap();
689 cycle.rotate_left(min);
690 cycle.push(cycle[0].clone());
691 if !found.contains(&cycle) {
692 found.push(cycle);
693 }
694 }
695 WHITE => dfs(b, graph, color, path, found),
696 _ => {}
697 }
698 }
699 }
700 path.pop();
701 color.insert(id, BLACK);
702 }
703
704 for (_, start) in &all {
705 if color.get(start.id.as_str()).copied().unwrap_or(WHITE) == WHITE {
706 let mut path = Vec::new();
707 dfs(start.id.as_str(), &graph, &mut color, &mut path, &mut found);
708 }
709 }
710
711 let mut out = String::new();
712 if found.is_empty() {
713 let _ = writeln!(out, "no cycles");
714 } else {
715 for cycle in found {
716 let _ = writeln!(out, "{}", cycle.join(" -> "));
717 }
718 }
719 Ok(out)
720}
721
722pub fn ancestors(layout: &Layout, id: &str, depth: usize) -> Result<String> {
729 let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
730 let mut out = String::new();
731 for (distance, ancestor) in graph.ancestors(id, depth)? {
732 writeln!(out, "{distance} {ancestor}")?;
733 }
734 Ok(out)
735}
736
737pub fn impact(layout: &Layout, id: &str, depth: usize) -> Result<String> {
744 let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
745 let mut out = String::new();
746 for (distance, descendant) in graph.descendants(id, depth)? {
747 writeln!(out, "{distance} {descendant}")?;
748 }
749 Ok(out)
750}
751
752pub const GRAPH_HEADER: &str = concat!(
762 "digraph vissue_graph {\n",
763 " rankdir=LR;\n",
764 " node [shape=box, fontname=\"Jost\", style=filled];\n",
765 " edge [fontname=\"Jost\"];\n"
766);
767
768pub const GRAPH_FOOTER: &str = "}\n";
770
771pub fn graph(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
777 Ok(format!(
778 "{GRAPH_HEADER}{}{GRAPH_FOOTER}",
779 graph_body(layout, project_filter)?
780 ))
781}
782
783pub fn graph_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
789 let all = load_all(layout)?;
790 let graph = GraphIndex::new(&all);
791 let mut out = String::new();
792 for (project, h) in &all {
793 if !project_selected(project, project_filter) {
794 continue;
795 }
796 let fill = match h.state.as_str() {
797 "DONE" => "#A5D6A7",
798 "CANCELLED" => "#CFD8DC",
799 "BLOCKED" => "#FFCC80",
800 "STARTED" => "#80CBC4",
801 _ => "#E0F2F1",
802 };
803 let _ = writeln!(
804 out,
805 " \"{}\" [label=\"{}\\n{} [#{}]\", fillcolor=\"{}\"];",
806 dot_quoted(&h.id),
807 dot_quoted(&h.title),
808 dot_quoted(&h.state),
809 dot_quoted(&h.priority.to_string()),
810 fill
811 );
812 }
813 for (project, h) in &all {
814 if !project_selected(project, project_filter) {
815 continue;
816 }
817 if let Some(blockers) = graph.blockers.get(h.id.as_str()) {
818 for b in blockers {
819 writeln!(
820 out,
821 " \"{}\" -> \"{}\" [color=\"#FF7043\"];",
822 dot_quoted(b),
823 dot_quoted(&h.id)
824 )?;
825 }
826 }
827 if let Some(parent) = h.parent() {
828 writeln!(
829 out,
830 " \"{}\" -> \"{}\" [color=\"#00897B\", style=dashed];",
831 dot_quoted(parent),
832 dot_quoted(&h.id)
833 )?;
834 }
835 }
836 Ok(out)
837}
838
839pub const ROADMAP_HEADER: &str = concat!(
850 "# Roadmap\n\n",
851 "Generated from `vissue roadmap`. Source of truth lives in the per-project issues.org files.\n\n"
852);
853
854pub fn roadmap(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
860 Ok(format!(
861 "{ROADMAP_HEADER}{}",
862 roadmap_body(layout, project_filter)?
863 ))
864}
865
866pub fn roadmap_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
872 let all = load_all(layout)?;
873 let mut by_project: BTreeMap<String, Vec<&IssueHeading>> = BTreeMap::new();
874 for (project, h) in &all {
875 if !project_selected(project, project_filter) {
876 continue;
877 }
878 by_project.entry(project.clone()).or_default().push(h);
879 }
880 let mut out = String::new();
881 for (project, mut headings) in by_project {
882 headings.sort_by(|a, b| {
883 a.priority
884 .cmp(&b.priority)
885 .then_with(|| a.state.cmp(&b.state))
886 .then_with(|| a.id.cmp(&b.id))
887 });
888 let buckets = ["STARTED", "TODO", "BLOCKED"];
889 let active: Vec<&&IssueHeading> = headings
890 .iter()
891 .filter(|h| buckets.contains(&h.state.as_str()))
892 .collect();
893 let closed: Vec<&&IssueHeading> = headings
894 .iter()
895 .filter(|h| h.state == "DONE" || h.state == "CANCELLED")
896 .collect();
897 if active.is_empty() && closed.is_empty() {
898 continue;
899 }
900 writeln!(out, "## {project}")?;
901 writeln!(out)?;
902 for state in buckets {
903 let in_state: Vec<&&IssueHeading> = active
904 .iter()
905 .copied()
906 .filter(|h| h.state == state)
907 .collect();
908 if in_state.is_empty() {
909 continue;
910 }
911 writeln!(out, "### {state}")?;
912 writeln!(out)?;
913 for h in in_state {
914 let deadline = h
915 .deadline()
916 .map(|d| format!(" :: deadline {d}"))
917 .unwrap_or_default();
918 let blockers = blocker_ids(h);
919 let blocked_by = if blockers.is_empty() {
920 String::new()
921 } else {
922 format!(" :: blocked by {}", blockers.join(", "))
923 };
924 writeln!(
925 out,
926 "- **{}** [#{}] {}{}{}",
927 h.id, h.priority, h.title, deadline, blocked_by
928 )?;
929 }
930 writeln!(out)?;
931 }
932 if !closed.is_empty() {
933 writeln!(out, "### Closed ({} items)", closed.len())?;
934 writeln!(out)?;
935 for h in closed.iter().take(10) {
936 writeln!(
937 out,
938 "- {} [#{}] {} ({})",
939 h.id, h.priority, h.title, h.state
940 )?;
941 }
942 if closed.len() > 10 {
943 writeln!(out, "- ... and {} more", closed.len() - 10)?;
944 }
945 writeln!(out)?;
946 }
947 }
948 Ok(out)
949}
950
951fn looks_like_reject_prose(body: &str) -> bool {
964 let lower = body.to_ascii_lowercase();
965 const CLOSING: &[&str] = &[
966 "vissue reject",
967 "superseded by",
968 "rejected in favour",
969 "rejected in favor",
970 "rejected as a duplicate",
971 "closed as a duplicate",
972 "closed as duplicate",
973 "not doing this",
974 "rejected this",
975 "rejected: ",
976 ];
977 if CLOSING.iter().any(|phrase| lower.contains(phrase)) {
978 return true;
979 }
980 lower.lines().any(|line| {
989 line.starts_with('*')
990 && (line.contains("rejected")
991 || line.contains("superseded")
992 || line.contains("reject:"))
993 })
994}
995
996fn claims_discovery_or_pivot(body: &str, linked: &str) -> bool {
1007 const CLAIMS: &[&str] = &[
1008 "discovered from",
1009 "discovered while",
1010 "discovered during",
1011 "found while",
1012 "filed from",
1013 "split from",
1014 "pivoted to",
1015 "pivots to",
1016 "pivoted from",
1017 "replaced by",
1018 "moved to",
1019 ];
1020 let needle = format!("id:{linked}");
1021 let lower = body.to_ascii_lowercase();
1022 let lower_needle = needle.to_ascii_lowercase();
1023 let window = 240;
1027 let mut from = 0;
1028 while let Some(at) = lower[from..].find(&lower_needle) {
1029 let hit = from + at;
1030 let start = hit.saturating_sub(window);
1031 let end = (hit + lower_needle.len() + window).min(lower.len());
1032 let near = &lower[floor_char_boundary(&lower, start)..ceil_char_boundary(&lower, end)];
1033 if CLAIMS.iter().any(|phrase| near.contains(phrase)) {
1034 return true;
1035 }
1036 from = hit + lower_needle.len();
1037 }
1038 false
1039}
1040
1041fn floor_char_boundary(s: &str, mut i: usize) -> usize {
1042 while i > 0 && !s.is_char_boundary(i) {
1043 i -= 1;
1044 }
1045 i
1046}
1047
1048fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
1049 while i < s.len() && !s.is_char_boundary(i) {
1050 i += 1;
1051 }
1052 i
1053}
1054
1055fn edge_connects(all: &[(String, IssueHeading)], a: &str, b: &str) -> bool {
1064 all.iter().any(|(_, h)| {
1065 let far = if h.id == a {
1066 b
1067 } else if h.id == b {
1068 a
1069 } else {
1070 return false;
1071 };
1072 [
1073 crate::props::DISCOVERED_FROM,
1074 crate::props::PIVOTED_TO,
1075 crate::props::PARENT,
1076 crate::props::BLOCKED_BY,
1077 crate::props::EDNA_BLOCKER,
1078 ]
1079 .iter()
1080 .any(|key| {
1081 crate::props::get(&h.properties, key)
1082 .is_some_and(|value| value.split(&[',', ' '][..]).any(|part| part.trim() == far))
1083 })
1084 })
1085}
1086
1087#[derive(Debug, Clone)]
1089pub struct CheckReport {
1090 pub text: String,
1092 pub errors: usize,
1094 pub warnings: usize,
1096}
1097
1098#[derive(Default)]
1105struct Findings {
1106 text: String,
1107 errors: usize,
1108 warnings: usize,
1109}
1110
1111impl Findings {
1112 fn err(&mut self, what: std::fmt::Arguments) {
1114 let _ = writeln!(self.text, "[err] {what}");
1115 self.errors += 1;
1116 }
1117
1118 fn warn(&mut self, what: std::fmt::Arguments) {
1120 let _ = writeln!(self.text, "[warn] {what}");
1121 self.warnings += 1;
1122 }
1123}
1124
1125pub fn check(layout: &Layout) -> Result<CheckReport> {
1132 let all = load_all(layout)?;
1133
1134 let issue_ids: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1139 let unresolved: HashSet<String> = all
1140 .iter()
1141 .filter_map(|(_, h)| h.parent())
1142 .filter(|p| !issue_ids.contains(p))
1143 .map(str::to_string)
1144 .collect();
1145 let elsewhere = find_org_ids(layout, &unresolved)?;
1146 let resolves = |id: &str| issue_ids.contains(id) || elsewhere.contains(id);
1147
1148 let mut f = Findings::default();
1149
1150 let mut by_id: HashMap<String, (String, &IssueHeading)> = HashMap::new();
1151 for (project, h) in &all {
1152 if let Some(prev) = by_id.insert(h.id.clone(), (project.clone(), h)) {
1153 f.err(format_args!(
1156 "duplicate id: {} appears in {} and {}",
1157 h.id, prev.0, project
1158 ));
1159 }
1160 }
1161
1162 for project in list_projects(layout)? {
1163 check_project(&project, layout, &mut f)?;
1164 }
1165
1166 for (project, h) in &all {
1167 check_issue(project, h, &resolves, &by_id, &mut f);
1168 }
1169
1170 let known: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1171 for (project, h) in &all {
1172 check_provenance_links(&all, project, h, &known, &mut f);
1173 }
1174
1175 let mut settled: HashSet<&str> = HashSet::new();
1180 for (_, h) in &all {
1181 check_parent_cycle(h, &by_id, &mut settled, &mut f);
1182 }
1183
1184 if f.errors == 0
1185 && let Err(err) = DependencyGraph::from_issues(&all)
1186 {
1187 f.err(format_args!("blocker graph: {err}"));
1188 }
1189
1190 let _ = writeln!(f.text);
1191 let projects = list_projects(layout)?.len();
1192 let _ = writeln!(
1193 f.text,
1194 "checked {} issue(s) across {projects} project(s): {} error(s), {} warning(s)",
1195 all.len(),
1196 f.errors,
1197 f.warnings
1198 );
1199 Ok(CheckReport {
1200 text: f.text,
1201 errors: f.errors,
1202 warnings: f.warnings,
1203 })
1204}
1205
1206fn check_project(project: &str, layout: &Layout, f: &mut Findings) -> Result<()> {
1216 let path = layout.project_issues_path(project);
1217 let doc = IssueDoc::parse_file(project, &path)?;
1218 check_preamble(project, &doc, &path, f);
1219 let gcal_ids = crate::store::org_ids(&std::fs::read_to_string(&path)?)
1224 .filter(|id| crate::org::is_gcal_event_id(id))
1225 .count();
1226 if gcal_ids > 0 {
1227 f.err(format_args!(
1228 "{project}: {gcal_ids} heading(s) use an org-gcal event id as :ID:"
1229 ));
1230 }
1231 check_headings(project, &doc, f);
1232 Ok(())
1233}
1234
1235fn check_preamble(project: &str, doc: &IssueDoc, path: &std::path::Path, f: &mut Findings) {
1241 match crate::org::protocol_from_preamble(&doc.preamble) {
1242 None => {
1243 f.warn(format_args!(
1244 "{project}: preamble has no #+VISSUE: protocol stamp"
1245 ));
1246 }
1247 Some(n) if n < crate::org::PROTOCOL_VERSION => {
1248 f.warn(format_args!(
1249 "{project}: #+VISSUE: {n} is behind protocol {}",
1250 crate::org::PROTOCOL_VERSION
1251 ));
1252 }
1253 Some(n) if n > crate::org::PROTOCOL_VERSION => {
1254 f.err(format_args!(
1255 "{project}: #+VISSUE: {n} is newer than this vissue (protocol {})",
1256 crate::org::PROTOCOL_VERSION
1257 ));
1258 }
1259 Some(_) => {}
1260 }
1261 if !crate::org::preamble_has_keyword(&doc.preamble, "CATEGORY") {
1262 f.warn(format_args!(
1263 "{project}: preamble has no #+CATEGORY: (org-agenda labels every row \"issues\")"
1264 ));
1265 }
1266 if !crate::org::preamble_has_keyword(&doc.preamble, "FILETAGS") {
1267 f.warn(format_args!("{project}: preamble has no #+FILETAGS:"));
1268 } else if !doc
1269 .tag_settings
1270 .filetags
1271 .iter()
1272 .any(|t| t.eq_ignore_ascii_case("noexport"))
1273 {
1274 f.warn(format_args!(
1275 "{project}: #+FILETAGS: has no noexport; a vault publish will export this tracker"
1276 ));
1277 }
1278 if !crate::org::preamble_has_keyword(&doc.preamble, "TAGS") {
1279 f.warn(format_args!(
1280 "{project}: preamble has no #+TAGS:; Emacs fast tag selection has no type group"
1281 ));
1282 }
1283 if !crate::org::preamble_has_keyword(
1284 &crate::org::merge_setupfile_settings(&doc.preamble, path.parent()),
1285 "PRIORITIES",
1286 ) {
1287 f.warn(format_args!(
1288 "{project}: preamble has no #+PRIORITIES:; cookies default to C and the range is A..C"
1289 ));
1290 }
1291}
1292
1293fn check_headings(project: &str, doc: &IssueDoc, f: &mut Findings) {
1298 let spec = doc.priority_spec();
1299 let mut type_not_tagged = 0usize;
1300 let mut exclusive_clash = 0usize;
1301 let mut priority_out_of_range = 0usize;
1302 let mut ordered_skip = 0usize;
1303 let mut done_with_open_children = 0usize;
1304 let mut priority_in_drawer = 0usize;
1305 let mut blockedby_typo = 0usize;
1306 let mut blocker_as_ids = 0usize;
1307 let mut computed_specials = 0usize;
1308 let mut bad_effort = 0usize;
1309 for h in &doc.headings {
1310 if let Some(kind) = crate::props::get(&h.properties, crate::props::TYPE) {
1311 let kind = kind.trim();
1312 if !kind.is_empty()
1313 && kind.chars().all(crate::model::is_org_tag_char)
1314 && !h.org_tags.iter().any(|t| t == kind)
1315 {
1316 type_not_tagged += 1;
1317 }
1318 }
1319 for group in &doc.tag_settings.exclusive {
1320 let hits = group
1321 .iter()
1322 .filter(|name| h.org_tags.iter().any(|t| t == *name))
1323 .count();
1324 if hits > 1 {
1325 exclusive_clash += 1;
1326 break;
1327 }
1328 }
1329 if !spec.contains(h.priority) {
1330 priority_out_of_range += 1;
1331 }
1332 if h.properties.contains_key("PRIORITY") {
1333 priority_in_drawer += 1;
1334 }
1335 if h.properties.contains_key("BLOCKEDBY") {
1336 blockedby_typo += 1;
1337 }
1338 if let Some(raw) = h.properties.get("BLOCKER")
1339 && !crate::org::is_edna_blocker(raw)
1340 {
1341 blocker_as_ids += 1;
1342 }
1343 if crate::org::COMPUTED_SPECIALS
1344 .iter()
1345 .any(|k| *k != "PRIORITY" && h.properties.contains_key(*k))
1346 {
1347 computed_specials += 1;
1348 }
1349 if let Some(effort) = h.effort()
1350 && !crate::org::is_org_effort(effort)
1351 {
1352 bad_effort += 1;
1353 }
1354 if let Some(pid) = h.parent()
1355 && let Some(parent) = doc.headings.iter().find(|p| p.id == pid)
1356 && crate::org::org_property_is_set(&parent.properties, "ORDERED")
1357 && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1358 {
1359 let earlier_open = doc.headings.iter().any(|sib| {
1360 sib.parent() == Some(pid)
1361 && sib.line_start < h.line_start
1362 && sib.state != "DONE"
1363 && sib.state != "CANCELLED"
1364 });
1365 if earlier_open && (h.state == "STARTED" || h.state == "DONE") {
1366 ordered_skip += 1;
1367 }
1368 }
1369 if h.state == "DONE"
1370 && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1371 && doc.headings.iter().any(|c| {
1372 c.parent() == Some(h.id.as_str()) && c.state != "DONE" && c.state != "CANCELLED"
1373 })
1374 {
1375 done_with_open_children += 1;
1376 }
1377 }
1378 if type_not_tagged > 0 {
1379 f.warn(format_args!("{project}: {type_not_tagged} heading(s) have :TYPE: that is a legal Org tag but is not on the heading"));
1380 }
1381 if exclusive_clash > 0 {
1382 f.warn(format_args!("{project}: {exclusive_clash} heading(s) carry more than one tag from a #+TAGS: exclusive group"));
1383 }
1384 if priority_in_drawer > 0 {
1385 f.warn(format_args!("{project}: {priority_in_drawer} heading(s) put :PRIORITY: in the drawer; Org reads the [#A] cookie"));
1386 }
1387 if blockedby_typo > 0 {
1388 f.warn(format_args!(
1389 "{project}: {blockedby_typo} heading(s) use :BLOCKEDBY: instead of :BLOCKED_BY:"
1390 ));
1391 }
1392 if blocker_as_ids > 0 {
1393 f.warn(format_args!("{project}: {blocker_as_ids} heading(s) use :BLOCKER: as a bare id list; a rewrite folds them into :BLOCKED_BY:"));
1394 }
1395 if computed_specials > 0 {
1396 f.warn(format_args!("{project}: {computed_specials} heading(s) set a computed Org special (TODO/ITEM/TAGS/...) in the drawer; Org ignores it"));
1397 }
1398 if bad_effort > 0 {
1399 f.warn(format_args!(
1400 "{project}: {bad_effort} heading(s) have an Effort value Org will not parse"
1401 ));
1402 }
1403 if priority_out_of_range > 0 {
1404 f.warn(format_args!(
1405 "{project}: {priority_out_of_range} heading(s) have a [#prio] outside #+PRIORITIES:"
1406 ));
1407 }
1408 if ordered_skip > 0 {
1409 f.warn(format_args!("{project}: {ordered_skip} heading(s) started or closed before an earlier ORDERED sibling"));
1410 }
1411 if done_with_open_children > 0 {
1412 f.warn(format_args!("{project}: {done_with_open_children} DONE heading(s) still have open children (Org ORDERED / todo-dependencies)"));
1413 }
1414}
1415
1416fn check_issue<'a>(
1419 project: &str,
1420 h: &'a IssueHeading,
1421 resolves: &impl Fn(&str) -> bool,
1422 by_id: &HashMap<String, (String, &'a IssueHeading)>,
1423 f: &mut Findings,
1424) {
1425 if let Some(parent) = h.parent()
1426 && !resolves(parent)
1427 {
1428 f.err(format_args!(
1429 "{} (in {}) :PARENT: {} -> not found",
1430 h.id, project, parent
1431 ));
1432 }
1433 for blk in blocker_ids(h) {
1434 if !by_id.contains_key(blk) {
1435 f.err(format_args!(
1436 "{} (in {}) :BLOCKED_BY: {} -> not found",
1437 h.id, project, blk
1438 ));
1439 }
1440 }
1441 if let Some(d) = h.deadline()
1442 && parse_org_date(d).is_none()
1443 {
1444 f.err(format_args!(
1445 "{} (in {}) :DEADLINE: {} -> unparseable",
1446 h.id, project, d
1447 ));
1448 }
1449 if let Some(s) = h.scheduled()
1450 && parse_org_date(s).is_none()
1451 {
1452 f.err(format_args!(
1453 "{} (in {}) :SCHEDULED: {} -> unparseable",
1454 h.id, project, s
1455 ));
1456 }
1457 if matches!(h.state.as_str(), "TODO" | "STARTED") && !h.properties.contains_key("CREATED") {
1458 f.warn(format_args!(
1459 "{} (in {}) state={} but :CREATED: is missing",
1460 h.id, project, h.state
1461 ));
1462 }
1463 if h.state == "DONE" && looks_like_reject_prose(&h.body) {
1464 f.warn(format_args!(
1465 "{} (in {}) is DONE but the body reads as a reject",
1466 h.id, project
1467 ));
1468 }
1469 if crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_some() {
1470 f.warn(format_args!(
1471 "{} (in {}) holds {} and sibling {}",
1472 h.id,
1473 project,
1474 h.state,
1475 crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).unwrap_or("?")
1476 ));
1477 }
1478}
1479
1480fn check_provenance_links<'a>(
1482 all: &[(String, IssueHeading)],
1483 project: &str,
1484 h: &'a IssueHeading,
1485 known: &HashSet<&'a str>,
1486 f: &mut Findings,
1487) {
1488 for linked in crate::related::org_link_targets(&h.body, known) {
1489 if edge_connects(all, &h.id, &linked) {
1490 continue;
1491 }
1492 if !claims_discovery_or_pivot(&h.body, &linked) {
1493 continue;
1494 }
1495 f.warn(format_args!(
1496 "{} (in {}) mentions [[id:{}]] as discovered or pivoted with no edge either way",
1497 h.id, project, linked
1498 ));
1499 }
1500}
1501
1502fn check_parent_cycle<'a>(
1508 start: &'a IssueHeading,
1509 by_id: &HashMap<String, (String, &'a IssueHeading)>,
1510 settled: &mut HashSet<&'a str>,
1511 f: &mut Findings,
1512) {
1513 if settled.contains(start.id.as_str()) {
1514 return;
1515 }
1516 let mut path: Vec<&str> = Vec::new();
1517 let mut on_path: HashSet<&str> = HashSet::new();
1518 let mut cursor = start.id.as_str();
1519 loop {
1520 if settled.contains(cursor) {
1521 break;
1522 }
1523 if !on_path.insert(cursor) {
1524 let start = path.iter().position(|id| *id == cursor).unwrap_or(0);
1525 let mut loop_ids: Vec<&str> = path[start..].to_vec();
1526 loop_ids.push(cursor);
1527 f.err(format_args!("parent cycle: {}", loop_ids.join(" -> ")));
1528 break;
1529 }
1530 path.push(cursor);
1531 match by_id.get(cursor).and_then(|(_, owner)| owner.parent()) {
1532 Some(parent) if by_id.contains_key(parent) => cursor = parent,
1533 _ => break,
1534 }
1535 }
1536 settled.extend(path);
1537}
1538
1539pub fn backlinks(layout: &Layout, target_id: &str) -> Result<String> {
1547 let all = load_all(layout)?;
1548 let mut out = String::new();
1549 for (project, h) in &all {
1550 if h.id == target_id {
1551 continue;
1552 }
1553 let mut hit = false;
1554 if blocker_ids(h).contains(&target_id) {
1555 let _ = writeln!(out, "{:<22} (blocked-by) ({})", h.id, project);
1556 hit = true;
1557 }
1558 if h.parent() == Some(target_id) {
1559 let _ = writeln!(out, "{:<22} (parent) ({})", h.id, project);
1560 hit = true;
1561 }
1562 if crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(target_id) {
1563 let _ = writeln!(out, "{:<22} (discovered-from) ({})", h.id, project);
1564 hit = true;
1565 }
1566 if crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(target_id) {
1567 let _ = writeln!(out, "{:<22} (pivoted-to) ({})", h.id, project);
1568 hit = true;
1569 }
1570 if !hit && h.body.contains(target_id) {
1571 let _ = writeln!(out, "{:<22} (body mention) ({})", h.id, project);
1572 }
1573 }
1574 Ok(out)
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579 use super::*;
1580
1581 #[test]
1582 fn dot_labels_escape_untrusted_issue_text() {
1583 assert_eq!(dot_quoted(r#"a "quoted" title"#), r#"a \"quoted\" title"#);
1584 assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
1587 assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
1588 }
1589}