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 fn graph(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
758 let all = load_all(layout)?;
759 let graph = GraphIndex::new(&all);
760 let mut out = String::new();
761 writeln!(out, "digraph vissue_graph {{")?;
762 writeln!(out, " rankdir=LR;")?;
763 writeln!(out, " node [shape=box, fontname=\"Jost\", style=filled];")?;
764 writeln!(out, " edge [fontname=\"Jost\"];")?;
765 for (project, h) in &all {
766 if !project_selected(project, project_filter) {
767 continue;
768 }
769 let fill = match h.state.as_str() {
770 "DONE" => "#A5D6A7",
771 "CANCELLED" => "#CFD8DC",
772 "BLOCKED" => "#FFCC80",
773 "STARTED" => "#80CBC4",
774 _ => "#E0F2F1",
775 };
776 let _ = writeln!(
777 out,
778 " \"{}\" [label=\"{}\\n{} [#{}]\", fillcolor=\"{}\"];",
779 dot_quoted(&h.id),
780 dot_quoted(&h.title),
781 dot_quoted(&h.state),
782 dot_quoted(&h.priority.to_string()),
783 fill
784 );
785 }
786 for (project, h) in &all {
787 if !project_selected(project, project_filter) {
788 continue;
789 }
790 if let Some(blockers) = graph.blockers.get(h.id.as_str()) {
791 for b in blockers {
792 writeln!(
793 out,
794 " \"{}\" -> \"{}\" [color=\"#FF7043\"];",
795 dot_quoted(b),
796 dot_quoted(&h.id)
797 )?;
798 }
799 }
800 if let Some(parent) = h.parent() {
801 writeln!(
802 out,
803 " \"{}\" -> \"{}\" [color=\"#00897B\", style=dashed];",
804 dot_quoted(parent),
805 dot_quoted(&h.id)
806 )?;
807 }
808 }
809 writeln!(out, "}}")?;
810 Ok(out)
811}
812
813pub fn roadmap(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
820 let all = load_all(layout)?;
821 let mut by_project: BTreeMap<String, Vec<&IssueHeading>> = BTreeMap::new();
822 for (project, h) in &all {
823 if !project_selected(project, project_filter) {
824 continue;
825 }
826 by_project.entry(project.clone()).or_default().push(h);
827 }
828 let mut out = String::new();
829 writeln!(out, "# Roadmap")?;
830 writeln!(out)?;
831 writeln!(
832 out,
833 "Generated from `vissue roadmap`. Source of truth lives in the per-project issues.org files."
834 )?;
835 writeln!(out)?;
836 for (project, mut headings) in by_project {
837 headings.sort_by(|a, b| {
838 a.priority
839 .cmp(&b.priority)
840 .then_with(|| a.state.cmp(&b.state))
841 .then_with(|| a.id.cmp(&b.id))
842 });
843 let buckets = ["STARTED", "TODO", "BLOCKED"];
844 let active: Vec<&&IssueHeading> = headings
845 .iter()
846 .filter(|h| buckets.contains(&h.state.as_str()))
847 .collect();
848 let closed: Vec<&&IssueHeading> = headings
849 .iter()
850 .filter(|h| h.state == "DONE" || h.state == "CANCELLED")
851 .collect();
852 if active.is_empty() && closed.is_empty() {
853 continue;
854 }
855 writeln!(out, "## {project}")?;
856 writeln!(out)?;
857 for state in buckets {
858 let in_state: Vec<&&IssueHeading> = active
859 .iter()
860 .copied()
861 .filter(|h| h.state == state)
862 .collect();
863 if in_state.is_empty() {
864 continue;
865 }
866 writeln!(out, "### {state}")?;
867 writeln!(out)?;
868 for h in in_state {
869 let deadline = h
870 .deadline()
871 .map(|d| format!(" :: deadline {d}"))
872 .unwrap_or_default();
873 let blockers = blocker_ids(h);
874 let blocked_by = if blockers.is_empty() {
875 String::new()
876 } else {
877 format!(" :: blocked by {}", blockers.join(", "))
878 };
879 writeln!(
880 out,
881 "- **{}** [#{}] {}{}{}",
882 h.id, h.priority, h.title, deadline, blocked_by
883 )?;
884 }
885 writeln!(out)?;
886 }
887 if !closed.is_empty() {
888 writeln!(out, "### Closed ({} items)", closed.len())?;
889 writeln!(out)?;
890 for h in closed.iter().take(10) {
891 writeln!(
892 out,
893 "- {} [#{}] {} ({})",
894 h.id, h.priority, h.title, h.state
895 )?;
896 }
897 if closed.len() > 10 {
898 writeln!(out, "- ... and {} more", closed.len() - 10)?;
899 }
900 writeln!(out)?;
901 }
902 }
903 Ok(out)
904}
905
906fn looks_like_reject_prose(body: &str) -> bool {
907 let lower = body.to_ascii_lowercase();
908 lower.contains("rejected") || lower.contains("vissue reject")
909}
910
911fn edge_connects(all: &[(String, IssueHeading)], a: &str, b: &str) -> bool {
912 all.iter().any(|(_, h)| {
913 if h.id == a {
914 crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(b)
915 || crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(b)
916 } else if h.id == b {
917 crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(a)
918 || crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(a)
919 } else {
920 false
921 }
922 })
923}
924
925#[derive(Debug, Clone)]
927pub struct CheckReport {
928 pub text: String,
930 pub errors: usize,
932 pub warnings: usize,
934}
935
936pub fn check(layout: &Layout) -> Result<CheckReport> {
943 let all = load_all(layout)?;
944
945 let issue_ids: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
950 let unresolved: HashSet<String> = all
951 .iter()
952 .filter_map(|(_, h)| h.parent())
953 .filter(|p| !issue_ids.contains(p))
954 .map(str::to_string)
955 .collect();
956 let elsewhere = find_org_ids(layout, &unresolved)?;
957 let resolves = |id: &str| issue_ids.contains(id) || elsewhere.contains(id);
958
959 let mut out = String::new();
960
961 let mut errors = 0usize;
962 let mut warnings = 0usize;
963
964 let mut by_id: HashMap<String, (String, &IssueHeading)> = HashMap::new();
965 for (project, h) in &all {
966 if let Some(prev) = by_id.insert(h.id.clone(), (project.clone(), h)) {
967 writeln!(
970 out,
971 "[err] duplicate id: {} appears in {} and {}",
972 h.id, prev.0, project
973 )?;
974 errors += 1;
975 }
976 }
977
978 for project in list_projects(layout)? {
979 let path = layout.project_issues_path(&project);
980 let doc = IssueDoc::parse_file(&project, &path)?;
981 match crate::org::protocol_from_preamble(&doc.preamble) {
982 None => {
983 writeln!(
984 out,
985 "[warn] {project}: preamble has no #+VISSUE: protocol stamp"
986 )?;
987 warnings += 1;
988 }
989 Some(n) if n < crate::org::PROTOCOL_VERSION => {
990 writeln!(
991 out,
992 "[warn] {project}: #+VISSUE: {n} is behind protocol {}",
993 crate::org::PROTOCOL_VERSION
994 )?;
995 warnings += 1;
996 }
997 Some(n) if n > crate::org::PROTOCOL_VERSION => {
998 writeln!(
999 out,
1000 "[err] {project}: #+VISSUE: {n} is newer than this vissue (protocol {})",
1001 crate::org::PROTOCOL_VERSION
1002 )?;
1003 errors += 1;
1004 }
1005 Some(_) => {}
1006 }
1007 if !crate::org::preamble_has_keyword(&doc.preamble, "CATEGORY") {
1008 writeln!(
1009 out,
1010 "[warn] {project}: preamble has no #+CATEGORY: (org-agenda labels every row \"issues\")"
1011 )?;
1012 warnings += 1;
1013 }
1014 if !crate::org::preamble_has_keyword(&doc.preamble, "FILETAGS") {
1015 writeln!(out, "[warn] {project}: preamble has no #+FILETAGS:")?;
1016 warnings += 1;
1017 } else if !doc
1018 .tag_settings
1019 .filetags
1020 .iter()
1021 .any(|t| t.eq_ignore_ascii_case("noexport"))
1022 {
1023 writeln!(
1024 out,
1025 "[warn] {project}: #+FILETAGS: has no noexport; a vault publish will export this tracker"
1026 )?;
1027 warnings += 1;
1028 }
1029 if !crate::org::preamble_has_keyword(&doc.preamble, "TAGS") {
1030 writeln!(
1031 out,
1032 "[warn] {project}: preamble has no #+TAGS:; Emacs fast tag selection has no type group"
1033 )?;
1034 warnings += 1;
1035 }
1036 if !crate::org::preamble_has_keyword(
1037 &crate::org::merge_setupfile_settings(&doc.preamble, path.parent()),
1038 "PRIORITIES",
1039 ) {
1040 writeln!(
1041 out,
1042 "[warn] {project}: preamble has no #+PRIORITIES:; cookies default to C and the range is A..C"
1043 )?;
1044 warnings += 1;
1045 }
1046 let spec = doc.priority_spec();
1047 let mut type_not_tagged = 0usize;
1048 let mut exclusive_clash = 0usize;
1049 let mut priority_out_of_range = 0usize;
1050 let mut ordered_skip = 0usize;
1051 let mut done_with_open_children = 0usize;
1052 let mut gcal_ids = 0usize;
1053 let mut priority_in_drawer = 0usize;
1054 let mut blockedby_typo = 0usize;
1055 let mut blocker_as_ids = 0usize;
1056 let mut computed_specials = 0usize;
1057 let mut bad_effort = 0usize;
1058 for h in &doc.headings {
1059 if let Some(kind) = crate::props::get(&h.properties, crate::props::TYPE) {
1060 let kind = kind.trim();
1061 if !kind.is_empty()
1062 && kind.chars().all(crate::model::is_org_tag_char)
1063 && !h.org_tags.iter().any(|t| t == kind)
1064 {
1065 type_not_tagged += 1;
1066 }
1067 }
1068 for group in &doc.tag_settings.exclusive {
1069 let hits = group
1070 .iter()
1071 .filter(|name| h.org_tags.iter().any(|t| t == *name))
1072 .count();
1073 if hits > 1 {
1074 exclusive_clash += 1;
1075 break;
1076 }
1077 }
1078 if !spec.contains(h.priority) {
1079 priority_out_of_range += 1;
1080 }
1081 if crate::org::is_gcal_event_id(&h.id) {
1082 gcal_ids += 1;
1083 }
1084 if h.properties.contains_key("PRIORITY") {
1085 priority_in_drawer += 1;
1086 }
1087 if h.properties.contains_key("BLOCKEDBY") {
1088 blockedby_typo += 1;
1089 }
1090 if let Some(raw) = h.properties.get("BLOCKER")
1091 && !crate::org::is_edna_blocker(raw)
1092 {
1093 blocker_as_ids += 1;
1094 }
1095 if crate::org::COMPUTED_SPECIALS
1096 .iter()
1097 .any(|k| *k != "PRIORITY" && h.properties.contains_key(*k))
1098 {
1099 computed_specials += 1;
1100 }
1101 if let Some(effort) = h.effort()
1102 && !crate::org::is_org_effort(effort)
1103 {
1104 bad_effort += 1;
1105 }
1106 if let Some(pid) = h.parent()
1107 && let Some(parent) = doc.headings.iter().find(|p| p.id == pid)
1108 && crate::org::org_property_is_set(&parent.properties, "ORDERED")
1109 && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1110 {
1111 let earlier_open = doc.headings.iter().any(|sib| {
1112 sib.parent() == Some(pid)
1113 && sib.line_start < h.line_start
1114 && sib.state != "DONE"
1115 && sib.state != "CANCELLED"
1116 });
1117 if earlier_open && (h.state == "STARTED" || h.state == "DONE") {
1118 ordered_skip += 1;
1119 }
1120 }
1121 if h.state == "DONE"
1122 && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1123 && doc.headings.iter().any(|c| {
1124 c.parent() == Some(h.id.as_str()) && c.state != "DONE" && c.state != "CANCELLED"
1125 })
1126 {
1127 done_with_open_children += 1;
1128 }
1129 }
1130 if type_not_tagged > 0 {
1131 writeln!(
1132 out,
1133 "[warn] {project}: {type_not_tagged} heading(s) have :TYPE: that is a legal Org tag but is not on the heading"
1134 )?;
1135 warnings += 1;
1136 }
1137 if exclusive_clash > 0 {
1138 writeln!(
1139 out,
1140 "[warn] {project}: {exclusive_clash} heading(s) carry more than one tag from a #+TAGS: exclusive group"
1141 )?;
1142 warnings += 1;
1143 }
1144 if priority_in_drawer > 0 {
1145 writeln!(
1146 out,
1147 "[warn] {project}: {priority_in_drawer} heading(s) put :PRIORITY: in the drawer; Org reads the [#A] cookie"
1148 )?;
1149 warnings += 1;
1150 }
1151 if blockedby_typo > 0 {
1152 writeln!(
1153 out,
1154 "[warn] {project}: {blockedby_typo} heading(s) use :BLOCKEDBY: instead of :BLOCKED_BY:"
1155 )?;
1156 warnings += 1;
1157 }
1158 if blocker_as_ids > 0 {
1159 writeln!(
1160 out,
1161 "[warn] {project}: {blocker_as_ids} heading(s) use :BLOCKER: as a bare id list; a rewrite folds them into :BLOCKED_BY:"
1162 )?;
1163 warnings += 1;
1164 }
1165 if computed_specials > 0 {
1166 writeln!(
1167 out,
1168 "[warn] {project}: {computed_specials} heading(s) set a computed Org special (TODO/ITEM/TAGS/...) in the drawer; Org ignores it"
1169 )?;
1170 warnings += 1;
1171 }
1172 if bad_effort > 0 {
1173 writeln!(
1174 out,
1175 "[warn] {project}: {bad_effort} heading(s) have an Effort value Org will not parse"
1176 )?;
1177 warnings += 1;
1178 }
1179 if priority_out_of_range > 0 {
1180 writeln!(
1181 out,
1182 "[warn] {project}: {priority_out_of_range} heading(s) have a [#prio] outside #+PRIORITIES:"
1183 )?;
1184 warnings += 1;
1185 }
1186 if ordered_skip > 0 {
1187 writeln!(
1188 out,
1189 "[warn] {project}: {ordered_skip} heading(s) started or closed before an earlier ORDERED sibling"
1190 )?;
1191 warnings += 1;
1192 }
1193 if done_with_open_children > 0 {
1194 writeln!(
1195 out,
1196 "[warn] {project}: {done_with_open_children} DONE heading(s) still have open children (Org ORDERED / todo-dependencies)"
1197 )?;
1198 warnings += 1;
1199 }
1200 if gcal_ids > 0 {
1201 writeln!(
1202 out,
1203 "[err] {project}: {gcal_ids} heading(s) use an org-gcal event id as :ID:"
1204 )?;
1205 errors += 1;
1206 }
1207 }
1208
1209 for (project, h) in &all {
1210 if let Some(parent) = h.parent()
1211 && !resolves(parent)
1212 {
1213 writeln!(
1214 out,
1215 "[err] {} (in {}) :PARENT: {} -> not found",
1216 h.id, project, parent
1217 )?;
1218 errors += 1;
1219 }
1220 for blk in blocker_ids(h) {
1221 if !by_id.contains_key(blk) {
1222 writeln!(
1223 out,
1224 "[err] {} (in {}) :BLOCKED_BY: {} -> not found",
1225 h.id, project, blk
1226 )?;
1227 errors += 1;
1228 }
1229 }
1230 if let Some(d) = h.deadline()
1231 && parse_org_date(d).is_none()
1232 {
1233 writeln!(
1234 out,
1235 "[err] {} (in {}) :DEADLINE: {} -> unparseable",
1236 h.id, project, d
1237 )?;
1238 errors += 1;
1239 }
1240 if let Some(s) = h.scheduled()
1241 && parse_org_date(s).is_none()
1242 {
1243 writeln!(
1244 out,
1245 "[err] {} (in {}) :SCHEDULED: {} -> unparseable",
1246 h.id, project, s
1247 )?;
1248 errors += 1;
1249 }
1250 if matches!(h.state.as_str(), "TODO" | "STARTED") && !h.properties.contains_key("CREATED") {
1251 writeln!(
1252 out,
1253 "[warn] {} (in {}) state={} but :CREATED: is missing",
1254 h.id, project, h.state
1255 )?;
1256 warnings += 1;
1257 }
1258 if h.state == "DONE" && looks_like_reject_prose(&h.body) {
1259 writeln!(
1260 out,
1261 "[warn] {} (in {}) is DONE but the body reads as a reject",
1262 h.id, project
1263 )?;
1264 warnings += 1;
1265 }
1266 if crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_some() {
1267 writeln!(
1268 out,
1269 "[warn] {} (in {}) holds {} and sibling {}",
1270 h.id,
1271 project,
1272 h.state,
1273 crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).unwrap_or("?")
1274 )?;
1275 warnings += 1;
1276 }
1277 }
1278
1279 let known: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1280 for (project, h) in &all {
1281 for linked in crate::related::org_link_targets(&h.body, &known) {
1282 if edge_connects(&all, &h.id, &linked) {
1283 continue;
1284 }
1285 writeln!(
1286 out,
1287 "[warn] {} (in {}) mentions [[id:{}]] with no DISCOVERED_FROM or PIVOTED_TO either way",
1288 h.id, project, linked
1289 )?;
1290 warnings += 1;
1291 }
1292 }
1293
1294 let mut settled: HashSet<&str> = HashSet::new();
1299 for (_, h) in &all {
1300 if settled.contains(h.id.as_str()) {
1301 continue;
1302 }
1303 let mut path: Vec<&str> = Vec::new();
1304 let mut on_path: HashSet<&str> = HashSet::new();
1305 let mut cursor = h.id.as_str();
1306 loop {
1307 if settled.contains(cursor) {
1308 break;
1309 }
1310 if !on_path.insert(cursor) {
1311 let start = path.iter().position(|id| *id == cursor).unwrap_or(0);
1312 let mut loop_ids: Vec<&str> = path[start..].to_vec();
1313 loop_ids.push(cursor);
1314 writeln!(out, "[err] parent cycle: {}", loop_ids.join(" -> "))?;
1315 errors += 1;
1316 break;
1317 }
1318 path.push(cursor);
1319 match by_id.get(cursor).and_then(|(_, owner)| owner.parent()) {
1320 Some(parent) if by_id.contains_key(parent) => cursor = parent,
1321 _ => break,
1322 }
1323 }
1324 settled.extend(path);
1325 }
1326
1327 if errors == 0
1328 && let Err(err) = DependencyGraph::from_issues(&all)
1329 {
1330 writeln!(out, "[err] blocker graph: {err}")?;
1331 errors += 1;
1332 }
1333
1334 writeln!(out)?;
1335 writeln!(
1336 out,
1337 "checked {} issue(s) across {} project(s): {} error(s), {} warning(s)",
1338 all.len(),
1339 list_projects(layout)?.len(),
1340 errors,
1341 warnings
1342 )?;
1343 Ok(CheckReport {
1344 text: out,
1345 errors,
1346 warnings,
1347 })
1348}
1349
1350pub fn backlinks(layout: &Layout, target_id: &str) -> Result<String> {
1358 let all = load_all(layout)?;
1359 let mut out = String::new();
1360 for (project, h) in &all {
1361 if h.id == target_id {
1362 continue;
1363 }
1364 let mut hit = false;
1365 if blocker_ids(h).contains(&target_id) {
1366 let _ = writeln!(out, "{:<22} (blocked-by) ({})", h.id, project);
1367 hit = true;
1368 }
1369 if h.parent() == Some(target_id) {
1370 let _ = writeln!(out, "{:<22} (parent) ({})", h.id, project);
1371 hit = true;
1372 }
1373 if crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(target_id) {
1374 let _ = writeln!(out, "{:<22} (discovered-from) ({})", h.id, project);
1375 hit = true;
1376 }
1377 if crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(target_id) {
1378 let _ = writeln!(out, "{:<22} (pivoted-to) ({})", h.id, project);
1379 hit = true;
1380 }
1381 if !hit && h.body.contains(target_id) {
1382 let _ = writeln!(out, "{:<22} (body mention) ({})", h.id, project);
1383 }
1384 }
1385 Ok(out)
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::*;
1391
1392 #[test]
1393 fn dot_labels_escape_untrusted_issue_text() {
1394 assert_eq!(dot_quoted(r#"a "quoted" title"#), r#"a \"quoted\" title"#);
1395 assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
1398 assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
1399 }
1400}