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 by_id: std::collections::HashMap<&str, &IssueRec> = recs
110 .iter()
111 .map(|rec| (rec.heading.id.as_str(), rec))
112 .collect();
113 let mut out = String::new();
114 for row in rows {
115 let suffix = by_id
116 .get(row.id.as_str())
117 .map(|r| claim_suffix(&r.heading))
118 .unwrap_or_default();
119 let _ = writeln!(
120 out,
121 "{:<22} {:<9} [#{}] {}{}",
122 row.id, row.state, row.priority, row.title, suffix
123 );
124 }
125 out
126}
127
128pub(crate) fn claim_suffix(h: &IssueHeading) -> String {
132 let Some(who) = h.claimed_by() else {
133 return String::new();
134 };
135 match h.claim_age_days(Local::now().date_naive()) {
136 Some(days) => format!(" (claimed {days}d by {who})"),
137 None => format!(" (claimed by {who})"),
138 }
139}
140
141pub fn ready(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
147 let recs = load_recs(layout)?;
148 let rows = CatalogService::from_recs(&recs).ready(project_filter)?;
149 Ok(format_issue_rows(&recs, &rows))
150}
151
152pub fn show(layout: &Layout, id: &str) -> Result<String> {
158 let (h, path, project) =
159 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
160 let mut out = String::new();
161 writeln!(out, "ID: {}", h.id)?;
162 writeln!(out, "Project: {project}")?;
163 writeln!(out, "Title: {}", h.title)?;
164 writeln!(out, "State: {}", h.state)?;
165 writeln!(out, "Priority: [#{}]", h.priority)?;
166 if let Some(who) = h.claimed_by() {
167 match h.claim_age_days(Local::now().date_naive()) {
168 Some(days) => writeln!(
169 out,
170 "Claimed: {who} since {} ({days}d)",
171 h.claimed_at().unwrap_or("?")
172 )?,
173 None => writeln!(out, "Claimed: {who}")?,
174 }
175 }
176 let settings = crate::org::tag_settings_from_preamble(
177 &IssueDoc::parse_file(&project, &path)
178 .map(|d| d.preamble)
179 .unwrap_or_default(),
180 );
181 let tags = settings.all_tags(&h.tags());
182 if !tags.is_empty() {
183 writeln!(out, "Tags: {}", tags.join(", "))?;
184 }
185 if h.properties.iter().any(|(k, _)| k != "ID") {
186 writeln!(out, "Properties:")?;
187 for (k, v) in &h.properties {
188 if k == "ID" {
189 continue;
190 }
191 writeln!(out, " {k}: {v}")?;
192 }
193 }
194 writeln!(
195 out,
196 "File: {}:{}-{}",
197 path.display(),
198 h.line_start,
199 h.line_end
200 )?;
201 writeln!(out)?;
202 let body = h.body.trim_end();
205 if body.is_empty() {
206 writeln!(out, "(no body; edit the range above to add one)")?;
207 } else {
208 writeln!(out, "Body:")?;
209 writeln!(out, "{body}")?;
210 }
211 Ok(out)
212}
213
214pub fn plan_consensus(layout: &Layout, id: &str) -> Result<String> {
228 let roll = crate::consensus::of_plan(layout, id)?;
229 let mut out = String::new();
230 writeln!(out, "{} {}", roll.plan, roll.title)?;
231 if roll.children.is_empty() {
232 writeln!(out, " no children: nothing to roll up")?;
233 return Ok(out);
234 }
235
236 let voted = roll.children.iter().filter(|c| c.ballots > 0).count();
237 writeln!(
238 out,
239 " {} child{}, {voted} with ballots",
240 roll.children.len(),
241 if roll.children.len() == 1 { "" } else { "ren" }
242 )?;
243 for child in &roll.children {
244 let held = match (&child.holds, child.settling) {
245 (Some((choice, share)), _) => format!("{choice} {share:.3}"),
246 (None, Some(crate::consensus::Settling::Split)) => "split".to_string(),
247 (None, Some(crate::consensus::Settling::Oscillating)) => "never settles".to_string(),
248 (None, Some(_)) => "no lead".to_string(),
249 (None, None) => "no ballots".to_string(),
250 };
251 writeln!(
252 out,
253 " {:<22} {:<9} {:<16} {}",
254 child.id, child.state, held, child.title
255 )?;
256 }
257
258 let positions = roll.positions();
259 match positions.len() {
260 0 => writeln!(out, " nothing holds a position yet")?,
261 1 => writeln!(
262 out,
263 " the children that were voted on all hold {}",
264 positions[0]
265 )?,
266 n => writeln!(
267 out,
268 " the children disagree with each other: {n} positions ({})",
269 positions.join(", ")
270 )?,
271 }
272 let split = roll.split();
273 if !split.is_empty() {
274 writeln!(
275 out,
276 " {} child(ren) settled split and need a person: {}",
277 split.len(),
278 split
279 .iter()
280 .map(|c| c.id.as_str())
281 .collect::<Vec<_>>()
282 .join(", ")
283 )?;
284 }
285 let unvoted = roll.unvoted();
286 if !unvoted.is_empty() {
287 writeln!(out, " {} child(ren) carry no ballots", unvoted.len())?;
291 }
292 Ok(out)
293}
294
295pub fn recall(layout: &Layout, id: &str, depth: usize, excerpts: bool) -> Result<String> {
309 let set = CatalogService::from_recs(&load_recs(layout)?).recall(id, depth, excerpts)?;
310 let mut out = String::new();
311 writeln!(
312 out,
313 "{:<22} {:<9} {} ({})",
314 set.id, set.state, set.title, set.project
315 )?;
316
317 if !set.plan.is_empty() {
318 writeln!(out, "\nPlan")?;
319 for step in &set.plan {
320 writeln!(out, " {:<22} {:<9} {}", step.id, step.state, step.title)?;
321 }
322 }
323
324 writeln!(out, "\nInputs")?;
325 if set.inputs.is_empty() {
326 writeln!(
327 out,
328 " (none declared: nothing blocks this and it was not bounced)"
329 )?;
330 }
331 for input in &set.inputs {
332 writeln!(
333 out,
334 " {:<22} {:<9} {} [{}]",
335 input.id, input.state, input.title, input.relation
336 )?;
337 if input.deeds.is_empty() {
338 writeln!(out, " (no deeds cited)")?;
342 }
343 for deed in &input.deeds {
344 writeln!(out, " {deed}")?;
345 }
346 if let Some(excerpt) = &input.excerpt {
347 for line in excerpt.lines() {
350 writeln!(out, " {line}")?;
351 }
352 }
353 if let Some(note) = &input.last_note {
354 writeln!(
357 out,
358 " note: {}",
359 note.lines().next().unwrap_or_default().trim()
360 )?;
361 }
362 }
363
364 writeln!(out, "\nProduced")?;
365 if set.produced.is_empty() {
366 writeln!(out, " (nothing cited yet)")?;
367 }
368 for deed in &set.produced {
369 writeln!(out, " {deed}")?;
370 }
371
372 writeln!(out, "\nBody")?;
373 if set.body.is_empty() {
374 writeln!(out, " (no body)")?;
375 } else {
376 for line in set.body.lines() {
377 writeln!(out, " {line}")?;
378 }
379 }
380 Ok(out)
381}
382
383pub fn recall_deeds(layout: &Layout, id: &str, depth: usize) -> Result<String> {
392 let set = CatalogService::from_recs(&load_recs(layout)?).recall(id, depth, false)?;
393 let mut out = String::new();
394 let mut seen: HashSet<&str> = HashSet::new();
398 for deed in set
399 .inputs
400 .iter()
401 .flat_map(|i| i.deeds.iter())
402 .chain(set.produced.iter())
403 {
404 if seen.insert(deed.as_str()) {
405 writeln!(out, "{deed}")?;
406 }
407 }
408 Ok(out)
409}
410
411pub fn consensus(layout: &Layout, id: &str) -> Result<String> {
423 let ballots = crate::ops::ballots(layout, id)?;
424 let outcome = crate::consensus::of_issue(layout, id)?;
425 Ok(consensus_text(id, &ballots, &outcome))
426}
427
428fn consensus_text(
429 id: &str,
430 ballots: &[crate::ops::Ballot],
431 outcome: &crate::consensus::Outcome,
432) -> String {
433 use crate::consensus::{Settling, TrustSource};
434
435 if ballots.is_empty() {
436 return format!("{id}: no votes\n");
437 }
438 let mut out = format!(
439 "{id}: {} ballot{} over {} option{}, trust {}\n",
440 ballots.len(),
441 if ballots.len() == 1 { "" } else { "s" },
442 outcome.choices.len(),
443 if outcome.choices.len() == 1 { "" } else { "s" },
444 match outcome.trust {
445 TrustSource::Default => "default (equal weight)",
446 TrustSource::Configured => "configured",
447 }
448 );
449
450 let counts = crate::consensus::tally(ballots);
451 let mut ranked: Vec<(&String, &Vec<String>)> = counts.iter().collect();
452 ranked.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0)));
453 let _ = writeln!(out, " count");
454 for (choice, who) in &ranked {
455 let _ = writeln!(out, " {:<24} {} ({})", choice, who.len(), who.join(", "));
456 }
457
458 match outcome.settling {
459 Settling::Agreed => {
460 let consensus = outcome.consensus.as_ref().expect("agreed carries a limit");
461 let mut shares: Vec<(&str, f64)> = outcome
462 .choices
463 .iter()
464 .map(String::as_str)
465 .zip(consensus.iter().copied())
466 .collect();
467 shares.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(b.0)));
468 let _ = writeln!(
469 out,
470 " consensus after {} round(s){}",
471 outcome.rounds,
472 if outcome.budget_reached {
473 ", which is the whole budget: the shares are an estimate"
474 } else {
475 ""
476 }
477 );
478 for (choice, share) in &shares {
479 let _ = writeln!(out, " {choice:<24} {share:.3}");
480 }
481 let mut power: Vec<(&str, f64)> = outcome
482 .agents
483 .iter()
484 .map(|a| (a.agent.as_str(), a.power.unwrap_or_default()))
485 .collect();
486 power.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(b.0)));
487 let _ = writeln!(out, " social power");
488 for (agent, weight) in &power {
489 let _ = writeln!(out, " {agent:<24} {weight:.3}");
490 }
491 match outcome.leader() {
492 Some(_) if ballots.len() < 2 => {
495 let _ = writeln!(
496 out,
497 " one ballot only: {}, which nobody has agreed with yet",
498 ranked[0].0
499 );
500 }
501 Some((choice, share)) => {
502 let _ = writeln!(out, " holds: {choice} ({share:.3} of the group's weight)");
503 if ranked[0].0 != choice {
504 let _ = writeln!(
506 out,
507 " the count leads with {} and the group's weight does not",
508 ranked[0].0
509 );
510 }
511 }
512 None => {
513 let _ = writeln!(
514 out,
515 " no lead: the group's weight is split evenly across the options"
516 );
517 }
518 }
519 }
520 Settling::Split => {
521 let _ = writeln!(
522 out,
523 " no consensus: the trust graph holds {} group(s) that do not listen to each other",
524 outcome.factions.len()
525 );
526 for faction in &outcome.factions {
527 let held = faction
531 .first()
532 .and_then(|who| outcome.agents.iter().find(|a| a.agent == *who))
533 .and_then(|row| {
534 row.limit
535 .iter()
536 .enumerate()
537 .max_by(|a, b| a.1.total_cmp(b.1))
538 .map(|(at, share)| format!("{} {share:.3}", outcome.choices[at]))
539 })
540 .unwrap_or_default();
541 let _ = writeln!(out, " {:<32} {held}", faction.join(", "));
542 }
543 }
544 Settling::Anchored => {
545 let uniform = outcome
553 .agents
554 .windows(2)
555 .all(|pair| (pair[0].susceptibility - pair[1].susceptibility).abs() < f64::EPSILON);
556 if uniform {
557 let _ = writeln!(
558 out,
559 " anchored after {} round(s), susceptibility {:.2}",
560 outcome.rounds,
561 outcome
562 .agents
563 .first()
564 .map_or(outcome.susceptibility, |a| a.susceptibility)
565 );
566 } else {
567 let _ = writeln!(out, " anchored after {} round(s)", outcome.rounds);
568 }
569 for row in &outcome.agents {
570 let held = row
571 .limit
572 .iter()
573 .enumerate()
574 .max_by(|a, b| a.1.total_cmp(b.1))
575 .map(|(at, share)| format!("{} {share:.3}", outcome.choices[at]))
576 .unwrap_or_default();
577 if uniform {
578 let _ = writeln!(out, " {:<24} {held}", row.agent);
579 } else {
580 let _ = writeln!(
581 out,
582 " {:<24} {held:<16} susceptibility {:.2}",
583 row.agent, row.susceptibility
584 );
585 }
586 }
587 let _ = writeln!(
588 out,
589 " spread {:.3}: what the group keeps disagreeing about after listening",
590 outcome.spread
591 );
592 let mut mean: Vec<(&str, f64)> = outcome
595 .choices
596 .iter()
597 .enumerate()
598 .map(|(at, choice)| {
599 let total: f64 = outcome.agents.iter().map(|a| a.limit[at]).sum();
600 (choice.as_str(), total / outcome.agents.len() as f64)
601 })
602 .collect();
603 mean.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(b.0)));
604 let _ = writeln!(out, " mean of those positions");
605 for (choice, share) in &mean {
606 let _ = writeln!(out, " {choice:<24} {share:.3}");
607 }
608 }
609 Settling::Oscillating => {
610 let _ = writeln!(
611 out,
612 " no consensus: {} rounds did not settle, which is a trust graph with no \
613 weight on its own opinions",
614 outcome.rounds
615 );
616 }
617 }
618 out
619}
620
621pub fn search(layout: &Layout, query: &str, limit: usize) -> Result<String> {
628 let recs = load_recs(layout)?;
629 let hits = CatalogService::from_recs(&recs).search(query, limit)?;
630 let mut out = String::new();
631 for h in hits {
632 let _ = writeln!(
633 out,
634 "{:<22} {:<9} [#{}] {} ({})",
635 h.id, h.state, h.priority, h.title, h.project
636 );
637 }
638 Ok(out)
639}
640
641pub fn children(layout: &Layout, parent_id: &str) -> Result<String> {
647 let mut rows: Vec<(String, IssueHeading)> = load_all(layout)?
648 .into_iter()
649 .filter(|(_, h)| h.parent() == Some(parent_id))
650 .collect();
651 rows.sort_by(|a, b| {
652 a.1.priority
653 .cmp(&b.1.priority)
654 .then_with(|| a.1.state.cmp(&b.1.state))
655 .then_with(|| a.1.id.cmp(&b.1.id))
656 });
657 let mut out = String::new();
658 for (project, h) in rows {
659 let _ = writeln!(
660 out,
661 "{:<22} {:<9} [#{}] {} ({})",
662 h.id, h.state, h.priority, h.title, project
663 );
664 }
665 Ok(out)
666}
667
668pub fn stale(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
675 let today = Local::now().date_naive();
676 let cutoff = today - chrono::Duration::days(days);
677 let mut rows: Vec<(String, IssueHeading, NaiveDate)> = Vec::new();
678 for (project, h) in load_all(layout)? {
679 if !project_selected(&project, project_filter) {
680 continue;
681 }
682 if !READY_STATES.contains(&h.state.as_str()) {
683 continue;
684 }
685 let Some(created) = h.properties.get("CREATED") else {
686 continue;
687 };
688 let Some(parsed) = parse_org_date(created) else {
689 continue;
690 };
691 if parsed <= cutoff {
692 rows.push((project, h, parsed));
693 }
694 }
695 rows.sort_by_key(|r| r.2);
696 let mut out = String::new();
697 for (project, h, created) in rows {
698 let age = (today - created).num_days();
699 let _ = writeln!(
700 out,
701 "{:<22} {:<9} [#{}] {} ({}d, {})",
702 h.id, h.state, h.priority, h.title, age, project
703 );
704 }
705 Ok(out)
706}
707
708pub fn claims(
717 layout: &Layout,
718 holder_filter: Option<&str>,
719 project_filter: Option<&str>,
720 json: bool,
721) -> Result<String> {
722 let recs = load_recs(layout)?;
723 let rows = CatalogService::from_recs(&recs).claims(holder_filter, project_filter)?;
724
725 if json {
726 return Ok(format!("{}\n", serde_json::to_value(&rows)?));
727 }
728
729 let mut out = String::new();
730 for row in &rows {
731 let age_txt = if row.age_days < 0 {
732 "?d".to_string()
733 } else {
734 format!("{}d", row.age_days)
735 };
736 let _ = writeln!(
737 out,
738 "{:<22} {:<9} [#{}] {:>4} {} {} ({})",
739 row.id,
740 row.state,
741 row.priority,
742 age_txt,
743 row.holder.as_deref().unwrap_or("?"),
744 row.title,
745 row.project
746 );
747 }
748 if rows.is_empty() {
749 out.push_str("no live claims\n");
750 }
751 Ok(out)
752}
753
754pub fn agenda(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
762 let today = Local::now().date_naive();
763 let recs = load_recs(layout)?;
764 let rows = crate::catalog::agenda_rows_from(&recs, days, project_filter)?;
765
766 let mut out = String::new();
767 let mut last_kind: Option<&str> = None;
768 for row in &rows {
769 if last_kind != Some(row.kind.as_str()) {
770 let _ = writeln!(out, "{}", row.kind);
771 last_kind = Some(row.kind.as_str());
772 }
773 let date = chrono::NaiveDate::parse_from_str(&row.date, "%Y-%m-%d").ok();
774 let when = match date.map(|d| (d - today).num_days()) {
775 Some(d) if d < 0 => format!("{}d overdue", -d),
776 Some(0) => "today".to_string(),
777 Some(d) => format!("in {d}d"),
778 None => String::new(),
779 };
780 let label = if row.kind == "appointment" {
781 "on"
782 } else {
783 row.kind.as_str()
784 };
785 let _ = writeln!(
786 out,
787 "{} {label:<9} {when:<11} {:<22} {:<9} [#{}] {} ({})",
788 row.date, row.id, row.state, row.priority, row.title, row.project
789 );
790 }
791 if out.is_empty() {
792 out.push_str("nothing dated in range\n");
793 }
794 Ok(out)
795}
796
797pub(crate) fn parse_org_date(s: &str) -> Option<NaiveDate> {
798 let inner = s
799 .trim_start_matches(['<', '['])
800 .trim_end_matches(['>', ']']);
801 let token = inner.split_whitespace().next()?;
802 NaiveDate::parse_from_str(token, "%Y-%m-%d").ok()
803}
804
805pub fn count(
811 layout: &Layout,
812 project_filter: Option<&str>,
813 state_filter: Option<&str>,
814 ready_only: bool,
815) -> Result<String> {
816 let all = load_all(layout)?;
817 let active_blockers: HashSet<String> = if ready_only {
818 all.iter()
819 .filter(|(_, h)| h.state != "DONE" && h.state != "CANCELLED")
820 .map(|(_, h)| h.id.clone())
821 .collect()
822 } else {
823 HashSet::new()
824 };
825 let n = all
826 .iter()
827 .filter(|(project, h)| {
828 if !project_selected(project, project_filter) {
829 return false;
830 }
831 if let Some(s) = state_filter
832 && h.state != s
833 {
834 return false;
835 }
836 if ready_only {
837 if !READY_STATES.contains(&h.state.as_str()) {
838 return false;
839 }
840 if blocker_ids(h).iter().any(|b| active_blockers.contains(*b)) {
841 return false;
842 }
843 }
844 true
845 })
846 .count();
847 Ok(format!("{n}\n"))
848}
849
850pub fn export(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
857 let mut out = String::new();
858 for rec in load_recs(layout)? {
859 if !project_selected(&rec.project, project_filter) {
860 continue;
861 }
862 let _ = writeln!(
863 out,
864 "{}",
865 export_row(&rec.project, rec.heading, &rec.tag_settings)
866 );
867 }
868 Ok(out)
869}
870
871pub fn export_by_project(layout: &Layout) -> Result<BTreeMap<String, String>> {
884 let mut out: BTreeMap<String, String> = BTreeMap::new();
885 for rec in load_recs(layout)? {
886 let row = export_row(&rec.project, rec.heading, &rec.tag_settings);
887 let _ = writeln!(out.entry(rec.project).or_default(), "{row}");
888 }
889 Ok(out)
890}
891
892fn export_row(
893 project: &str,
894 h: IssueHeading,
895 settings: &crate::org::TagSettings,
896) -> serde_json::Value {
897 let logbook: Vec<serde_json::Value> = h
898 .logbook
899 .iter()
900 .map(|e| {
901 let mut row = serde_json::json!({
902 "timestamp": e.timestamp,
903 "from": e.from_state,
904 "to": e.to_state,
905 "note": e.note,
906 });
907 if let Some(raw) = &e.raw {
908 row["raw"] = serde_json::Value::String(raw.clone());
909 }
910 row
911 })
912 .collect();
913 serde_json::json!({
914 "id": h.id,
915 "project": project,
916 "title": h.title,
917 "state": h.state,
918 "priority": h.priority.to_string(),
919 "properties": h.properties,
920 "deeds": h.deeds(),
926 "org_tags": h.org_tags,
927 "tags": h.tags(),
928 "all_tags": settings.all_tags(&h.tags()),
929 "logbook": logbook,
930 "body": h.body,
931 "line_start": h.line_start,
932 "line_end": h.line_end,
933 })
934}
935
936pub fn tree(layout: &Layout, root_id: &str, format: &str) -> Result<String> {
943 let all = load_all(layout)?;
944 let graph = GraphIndex::new(&all);
945 let Some(root_heading) = graph.by_id.get(root_id) else {
946 return Err(Error::IssueNotFound {
947 id: root_id.to_string(),
948 });
949 };
950 let mut out = String::new();
951 let root = root_heading.id.as_str();
952 match format {
953 "ascii" | "text" => tree_ascii(&graph, root, 0, &mut HashSet::new(), &mut out),
954 "dot" => tree_dot(&graph, root, &mut out),
955 _ => return Err(anyhow!("unknown format {format:?}; allowed: ascii, dot").into()),
956 }
957 Ok(out)
958}
959
960fn tree_ascii<'a>(
961 graph: &GraphIndex<'a>,
962 id: &'a str,
963 depth: usize,
964 seen: &mut HashSet<&'a str>,
965 out: &mut String,
966) {
967 if !seen.insert(id) {
968 let _ = writeln!(out, "{}{id} (cycle, stopping)", " ".repeat(depth));
969 return;
970 }
971 let Some(h) = graph.by_id.get(id) else {
972 let _ = writeln!(out, "{}{id} (missing)", " ".repeat(depth));
973 return;
974 };
975 let _ = writeln!(
976 out,
977 "{}{id} {:<9} [#{}] {}",
978 " ".repeat(depth),
979 h.state,
980 h.priority,
981 h.title
982 );
983 if let Some(blockers) = graph.blockers.get(id) {
984 for blocker in blockers {
985 let _ = writeln!(out, "{}* blocked-by {blocker}", " ".repeat(depth + 1));
986 }
987 }
988 if let Some(kids) = graph.children.get(id) {
989 for k in kids {
990 tree_ascii(graph, k, depth + 1, seen, out);
991 }
992 }
993}
994
995pub(crate) fn dot_quoted(text: &str) -> String {
1000 text.replace('\\', "\\\\")
1001 .replace('"', "\\\"")
1002 .replace('\n', "\\n")
1003 .replace('\r', "")
1004}
1005
1006fn tree_dot<'a>(graph: &GraphIndex<'a>, root_id: &str, out: &mut String) {
1007 let _ = writeln!(out, "digraph vissue_tree {{");
1008 let _ = writeln!(out, " rankdir=LR;");
1009 let _ = writeln!(
1010 out,
1011 " node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
1012 );
1013 let mut visited: HashSet<&str> = HashSet::new();
1014 let mut stack = vec![graph.by_id.get(root_id).unwrap().id.as_str()];
1015 while let Some(id) = stack.pop() {
1016 if !visited.insert(id) {
1017 continue;
1018 }
1019 if let Some(h) = graph.by_id.get(id) {
1020 let _ = writeln!(
1021 out,
1022 " \"{}\" [label=\"{}\\n{} [#{}]\"];",
1023 dot_quoted(&h.id),
1024 dot_quoted(&h.title),
1025 dot_quoted(&h.state),
1026 dot_quoted(&h.priority.to_string())
1027 );
1028 if let Some(kids) = graph.children.get(id) {
1029 for k in kids {
1030 let _ = writeln!(
1031 out,
1032 " \"{}\" -> \"{}\" [color=\"#00897B\"];",
1033 dot_quoted(&h.id),
1034 dot_quoted(k)
1035 );
1036 stack.push(k);
1037 }
1038 }
1039 if let Some(blockers) = graph.blockers.get(id) {
1040 for b in blockers {
1041 let _ = writeln!(
1042 out,
1043 " \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
1044 dot_quoted(b),
1045 dot_quoted(&h.id)
1046 );
1047 stack.push(b);
1048 }
1049 }
1050 }
1051 }
1052 let _ = writeln!(out, "}}");
1053}
1054
1055pub fn cycles(layout: &Layout) -> Result<String> {
1061 let all = load_all(layout)?;
1062 let graph = GraphIndex::new(&all);
1063
1064 const WHITE: u8 = 0;
1068 const GREY: u8 = 1;
1069 const BLACK: u8 = 2;
1070 let mut color: HashMap<&str, u8> = HashMap::new();
1071 let mut found: Vec<Vec<String>> = Vec::new();
1072
1073 fn dfs<'a>(
1074 id: &'a str,
1075 graph: &GraphIndex<'a>,
1076 color: &mut HashMap<&'a str, u8>,
1077 path: &mut Vec<&'a str>,
1078 found: &mut Vec<Vec<String>>,
1079 ) {
1080 color.insert(id, GREY);
1081 path.push(id);
1082 if let Some(blockers) = graph.blockers.get(id) {
1083 for b in blockers {
1084 if !graph.by_id.contains_key(b) {
1085 continue; }
1087 match color.get(b).copied().unwrap_or(WHITE) {
1088 GREY => {
1089 let start = path.iter().position(|&x| x == *b).unwrap();
1090 let mut cycle: Vec<String> =
1091 path[start..].iter().map(|s| s.to_string()).collect();
1092 let min = cycle
1095 .iter()
1096 .enumerate()
1097 .min_by(|a, b| a.1.cmp(b.1))
1098 .map(|(i, _)| i)
1099 .unwrap();
1100 cycle.rotate_left(min);
1101 cycle.push(cycle[0].clone());
1102 if !found.contains(&cycle) {
1103 found.push(cycle);
1104 }
1105 }
1106 WHITE => dfs(b, graph, color, path, found),
1107 _ => {}
1108 }
1109 }
1110 }
1111 path.pop();
1112 color.insert(id, BLACK);
1113 }
1114
1115 for (_, start) in &all {
1116 if color.get(start.id.as_str()).copied().unwrap_or(WHITE) == WHITE {
1117 let mut path = Vec::new();
1118 dfs(start.id.as_str(), &graph, &mut color, &mut path, &mut found);
1119 }
1120 }
1121
1122 let mut out = String::new();
1123 if found.is_empty() {
1124 let _ = writeln!(out, "no cycles");
1125 } else {
1126 for cycle in found {
1127 let _ = writeln!(out, "{}", cycle.join(" -> "));
1128 }
1129 }
1130 Ok(out)
1131}
1132
1133pub fn ancestors(layout: &Layout, id: &str, depth: usize) -> Result<String> {
1140 let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
1141 let mut out = String::new();
1142 for (distance, ancestor) in graph.ancestors(id, depth)? {
1143 writeln!(out, "{distance} {ancestor}")?;
1144 }
1145 Ok(out)
1146}
1147
1148pub fn impact(layout: &Layout, id: &str, depth: usize) -> Result<String> {
1155 let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
1156 let mut out = String::new();
1157 for (distance, descendant) in graph.descendants(id, depth)? {
1158 writeln!(out, "{distance} {descendant}")?;
1159 }
1160 Ok(out)
1161}
1162
1163pub const GRAPH_HEADER: &str = concat!(
1173 "digraph vissue_graph {\n",
1174 " rankdir=LR;\n",
1175 " node [shape=box, fontname=\"Jost\", style=filled];\n",
1176 " edge [fontname=\"Jost\"];\n"
1177);
1178
1179pub const GRAPH_FOOTER: &str = "}\n";
1181
1182pub fn graph(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1188 Ok(format!(
1189 "{GRAPH_HEADER}{}{GRAPH_FOOTER}",
1190 graph_body(layout, project_filter)?
1191 ))
1192}
1193
1194pub fn graph_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1200 let all = load_all(layout)?;
1201 let graph = GraphIndex::new(&all);
1202 let mut out = String::new();
1203 for (project, h) in &all {
1204 if !project_selected(project, project_filter) {
1205 continue;
1206 }
1207 let fill = match h.state.as_str() {
1208 "DONE" => "#A5D6A7",
1209 "CANCELLED" => "#CFD8DC",
1210 "BLOCKED" => "#FFCC80",
1211 "STARTED" => "#80CBC4",
1212 _ => "#E0F2F1",
1213 };
1214 let _ = writeln!(
1215 out,
1216 " \"{}\" [label=\"{}\\n{} [#{}]\", fillcolor=\"{}\"];",
1217 dot_quoted(&h.id),
1218 dot_quoted(&h.title),
1219 dot_quoted(&h.state),
1220 dot_quoted(&h.priority.to_string()),
1221 fill
1222 );
1223 }
1224 for (project, h) in &all {
1225 if !project_selected(project, project_filter) {
1226 continue;
1227 }
1228 if let Some(blockers) = graph.blockers.get(h.id.as_str()) {
1229 for b in blockers {
1230 writeln!(
1231 out,
1232 " \"{}\" -> \"{}\" [color=\"#FF7043\"];",
1233 dot_quoted(b),
1234 dot_quoted(&h.id)
1235 )?;
1236 }
1237 }
1238 if let Some(parent) = h.parent() {
1239 writeln!(
1240 out,
1241 " \"{}\" -> \"{}\" [color=\"#00897B\", style=dashed];",
1242 dot_quoted(parent),
1243 dot_quoted(&h.id)
1244 )?;
1245 }
1246 }
1247 Ok(out)
1248}
1249
1250pub const ROADMAP_HEADER: &str = concat!(
1261 "# Roadmap\n\n",
1262 "Generated from `vissue roadmap`. Source of truth lives in the per-project issues.org files.\n\n"
1263);
1264
1265pub fn roadmap(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1271 Ok(format!(
1272 "{ROADMAP_HEADER}{}",
1273 roadmap_body(layout, project_filter)?
1274 ))
1275}
1276
1277pub fn roadmap_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1283 let all = load_all(layout)?;
1284 let mut by_project: BTreeMap<String, Vec<&IssueHeading>> = BTreeMap::new();
1285 for (project, h) in &all {
1286 if !project_selected(project, project_filter) {
1287 continue;
1288 }
1289 by_project.entry(project.clone()).or_default().push(h);
1290 }
1291 let mut out = String::new();
1292 for (project, mut headings) in by_project {
1293 headings.sort_by(|a, b| {
1294 a.priority
1295 .cmp(&b.priority)
1296 .then_with(|| a.state.cmp(&b.state))
1297 .then_with(|| a.id.cmp(&b.id))
1298 });
1299 let buckets = ["STARTED", "TODO", "BLOCKED"];
1300 let active: Vec<&&IssueHeading> = headings
1301 .iter()
1302 .filter(|h| buckets.contains(&h.state.as_str()))
1303 .collect();
1304 let closed: Vec<&&IssueHeading> = headings
1305 .iter()
1306 .filter(|h| h.state == "DONE" || h.state == "CANCELLED")
1307 .collect();
1308 if active.is_empty() && closed.is_empty() {
1309 continue;
1310 }
1311 writeln!(out, "## {project}")?;
1312 writeln!(out)?;
1313 for state in buckets {
1314 let in_state: Vec<&&IssueHeading> = active
1315 .iter()
1316 .copied()
1317 .filter(|h| h.state == state)
1318 .collect();
1319 if in_state.is_empty() {
1320 continue;
1321 }
1322 writeln!(out, "### {state}")?;
1323 writeln!(out)?;
1324 for h in in_state {
1325 let deadline = h
1326 .deadline()
1327 .map(|d| format!(" :: deadline {d}"))
1328 .unwrap_or_default();
1329 let blockers = blocker_ids(h);
1330 let blocked_by = if blockers.is_empty() {
1331 String::new()
1332 } else {
1333 format!(" :: blocked by {}", blockers.join(", "))
1334 };
1335 writeln!(
1336 out,
1337 "- **{}** [#{}] {}{}{}",
1338 h.id, h.priority, h.title, deadline, blocked_by
1339 )?;
1340 }
1341 writeln!(out)?;
1342 }
1343 if !closed.is_empty() {
1344 writeln!(out, "### Closed ({} items)", closed.len())?;
1345 writeln!(out)?;
1346 for h in closed.iter().take(10) {
1347 writeln!(
1348 out,
1349 "- {} [#{}] {} ({})",
1350 h.id, h.priority, h.title, h.state
1351 )?;
1352 }
1353 if closed.len() > 10 {
1354 writeln!(out, "- ... and {} more", closed.len() - 10)?;
1355 }
1356 writeln!(out)?;
1357 }
1358 }
1359 Ok(out)
1360}
1361
1362fn looks_like_reject_prose(body: &str) -> bool {
1375 let lower = body.to_ascii_lowercase();
1376 const CLOSING: &[&str] = &[
1377 "vissue reject",
1378 "superseded by",
1379 "rejected in favour",
1380 "rejected in favor",
1381 "rejected as a duplicate",
1382 "closed as a duplicate",
1383 "closed as duplicate",
1384 "not doing this",
1385 "rejected this",
1386 "rejected: ",
1387 ];
1388 if CLOSING.iter().any(|phrase| lower.contains(phrase)) {
1389 return true;
1390 }
1391 lower.lines().any(|line| {
1400 line.starts_with('*')
1401 && (line.contains("rejected")
1402 || line.contains("superseded")
1403 || line.contains("reject:"))
1404 })
1405}
1406
1407fn claims_discovery_or_pivot(body: &str, linked: &str) -> bool {
1418 const CLAIMS: &[&str] = &[
1419 "discovered from",
1420 "discovered while",
1421 "discovered during",
1422 "found while",
1423 "filed from",
1424 "split from",
1425 "pivoted to",
1426 "pivots to",
1427 "pivoted from",
1428 "replaced by",
1429 "moved to",
1430 ];
1431 let needle = format!("id:{linked}");
1432 let lower = body.to_ascii_lowercase();
1433 let lower_needle = needle.to_ascii_lowercase();
1434 let window = 240;
1438 let mut from = 0;
1439 while let Some(at) = lower[from..].find(&lower_needle) {
1440 let hit = from + at;
1441 let start = hit.saturating_sub(window);
1442 let end = (hit + lower_needle.len() + window).min(lower.len());
1443 let near = &lower[floor_char_boundary(&lower, start)..ceil_char_boundary(&lower, end)];
1444 if CLAIMS.iter().any(|phrase| near.contains(phrase)) {
1445 return true;
1446 }
1447 from = hit + lower_needle.len();
1448 }
1449 false
1450}
1451
1452fn floor_char_boundary(s: &str, mut i: usize) -> usize {
1453 while i > 0 && !s.is_char_boundary(i) {
1454 i -= 1;
1455 }
1456 i
1457}
1458
1459fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
1460 while i < s.len() && !s.is_char_boundary(i) {
1461 i += 1;
1462 }
1463 i
1464}
1465
1466fn edge_connects(all: &[(String, IssueHeading)], a: &str, b: &str) -> bool {
1475 all.iter().any(|(_, h)| {
1476 let far = if h.id == a {
1477 b
1478 } else if h.id == b {
1479 a
1480 } else {
1481 return false;
1482 };
1483 [
1484 crate::props::DISCOVERED_FROM,
1485 crate::props::PIVOTED_TO,
1486 crate::props::PARENT,
1487 crate::props::BLOCKED_BY,
1488 crate::props::EDNA_BLOCKER,
1489 ]
1490 .iter()
1491 .any(|key| {
1492 crate::props::get(&h.properties, key)
1493 .is_some_and(|value| value.split(&[',', ' '][..]).any(|part| part.trim() == far))
1494 })
1495 })
1496}
1497
1498#[derive(Debug, Clone)]
1500pub struct CheckReport {
1501 pub text: String,
1503 pub errors: usize,
1505 pub warnings: usize,
1507}
1508
1509#[derive(Default)]
1516struct Findings {
1517 text: String,
1518 errors: usize,
1519 warnings: usize,
1520}
1521
1522impl Findings {
1523 fn err(&mut self, what: std::fmt::Arguments) {
1525 let _ = writeln!(self.text, "[err] {what}");
1526 self.errors += 1;
1527 }
1528
1529 fn warn(&mut self, what: std::fmt::Arguments) {
1531 let _ = writeln!(self.text, "[warn] {what}");
1532 self.warnings += 1;
1533 }
1534}
1535
1536pub fn check(layout: &Layout) -> Result<CheckReport> {
1543 let all = load_all(layout)?;
1544
1545 let issue_ids: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1550 let unresolved: HashSet<String> = all
1551 .iter()
1552 .filter_map(|(_, h)| h.parent())
1553 .filter(|p| !issue_ids.contains(p))
1554 .map(str::to_string)
1555 .collect();
1556 let elsewhere = find_org_ids(layout, &unresolved)?;
1557 let resolves = |id: &str| issue_ids.contains(id) || elsewhere.contains(id);
1558
1559 let mut f = Findings::default();
1560
1561 let mut by_id: HashMap<String, (String, &IssueHeading)> = HashMap::new();
1562 for (project, h) in &all {
1563 if let Some(prev) = by_id.insert(h.id.clone(), (project.clone(), h)) {
1564 f.err(format_args!(
1567 "duplicate id: {} appears in {} and {}",
1568 h.id, prev.0, project
1569 ));
1570 }
1571 }
1572
1573 for project in list_projects(layout)? {
1574 check_project(&project, layout, &mut f)?;
1575 }
1576
1577 for (project, h) in &all {
1578 check_issue(project, h, &resolves, &by_id, &mut f);
1579 }
1580
1581 let known: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1582 for (project, h) in &all {
1583 check_provenance_links(&all, project, h, &known, &mut f);
1584 }
1585
1586 let mut settled: HashSet<&str> = HashSet::new();
1591 for (_, h) in &all {
1592 check_parent_cycle(h, &by_id, &mut settled, &mut f);
1593 }
1594
1595 if f.errors == 0
1596 && let Err(err) = DependencyGraph::from_issues(&all)
1597 {
1598 f.err(format_args!("blocker graph: {err}"));
1599 }
1600
1601 let _ = writeln!(f.text);
1602 let projects = list_projects(layout)?.len();
1603 let _ = writeln!(
1604 f.text,
1605 "checked {} issue(s) across {projects} project(s): {} error(s), {} warning(s)",
1606 all.len(),
1607 f.errors,
1608 f.warnings
1609 );
1610 Ok(CheckReport {
1611 text: f.text,
1612 errors: f.errors,
1613 warnings: f.warnings,
1614 })
1615}
1616
1617fn check_project(project: &str, layout: &Layout, f: &mut Findings) -> Result<()> {
1627 let path = layout.project_issues_path(project);
1628 let doc = IssueDoc::parse_file(project, &path)?;
1629 check_preamble(project, &doc, &path, f);
1630 let gcal_ids = crate::store::org_ids(&std::fs::read_to_string(&path)?)
1635 .filter(|id| crate::org::is_gcal_event_id(id))
1636 .count();
1637 if gcal_ids > 0 {
1638 f.err(format_args!(
1639 "{project}: {gcal_ids} heading(s) use an org-gcal event id as :ID:"
1640 ));
1641 }
1642 check_headings(project, &doc, f);
1643 Ok(())
1644}
1645
1646fn check_preamble(project: &str, doc: &IssueDoc, path: &std::path::Path, f: &mut Findings) {
1652 match crate::org::protocol_from_preamble(&doc.preamble) {
1653 None => {
1654 f.warn(format_args!(
1655 "{project}: preamble has no #+VISSUE: protocol stamp"
1656 ));
1657 }
1658 Some(n) if n < crate::org::PROTOCOL_VERSION => {
1659 f.warn(format_args!(
1660 "{project}: #+VISSUE: {n} is behind protocol {}",
1661 crate::org::PROTOCOL_VERSION
1662 ));
1663 }
1664 Some(n) if n > crate::org::PROTOCOL_VERSION => {
1665 f.err(format_args!(
1666 "{project}: #+VISSUE: {n} is newer than this vissue (protocol {})",
1667 crate::org::PROTOCOL_VERSION
1668 ));
1669 }
1670 Some(_) => {}
1671 }
1672 if !crate::org::preamble_has_keyword(&doc.preamble, "CATEGORY") {
1673 f.warn(format_args!(
1674 "{project}: preamble has no #+CATEGORY: (org-agenda labels every row \"issues\")"
1675 ));
1676 }
1677 if !crate::org::preamble_has_keyword(&doc.preamble, "FILETAGS") {
1678 f.warn(format_args!("{project}: preamble has no #+FILETAGS:"));
1679 } else if !doc
1680 .tag_settings
1681 .filetags
1682 .iter()
1683 .any(|t| t.eq_ignore_ascii_case("noexport"))
1684 {
1685 f.warn(format_args!(
1686 "{project}: #+FILETAGS: has no noexport; a vault publish will export this tracker"
1687 ));
1688 }
1689 if !crate::org::preamble_has_keyword(&doc.preamble, "TAGS") {
1690 f.warn(format_args!(
1691 "{project}: preamble has no #+TAGS:; Emacs fast tag selection has no type group"
1692 ));
1693 }
1694 if !crate::org::preamble_has_keyword(
1695 &crate::org::merge_setupfile_settings(&doc.preamble, path.parent()),
1696 "PRIORITIES",
1697 ) {
1698 f.warn(format_args!(
1699 "{project}: preamble has no #+PRIORITIES:; cookies default to C and the range is A..C"
1700 ));
1701 }
1702}
1703
1704fn check_headings(project: &str, doc: &IssueDoc, f: &mut Findings) {
1709 let spec = doc.priority_spec();
1710 let mut type_not_tagged = 0usize;
1711 let mut exclusive_clash = 0usize;
1712 let mut priority_out_of_range = 0usize;
1713 let mut ordered_skip = 0usize;
1714 let mut done_with_open_children = 0usize;
1715 let mut priority_in_drawer = 0usize;
1716 let mut blockedby_typo = 0usize;
1717 let mut blocker_as_ids = 0usize;
1718 let mut computed_specials = 0usize;
1719 let mut bad_effort = 0usize;
1720 for h in &doc.headings {
1721 if let Some(kind) = crate::props::get(&h.properties, crate::props::TYPE) {
1722 let kind = kind.trim();
1723 if !kind.is_empty()
1724 && kind.chars().all(crate::model::is_org_tag_char)
1725 && !h.org_tags.iter().any(|t| t == kind)
1726 {
1727 type_not_tagged += 1;
1728 }
1729 }
1730 for group in &doc.tag_settings.exclusive {
1731 let hits = group
1732 .iter()
1733 .filter(|name| h.org_tags.iter().any(|t| t == *name))
1734 .count();
1735 if hits > 1 {
1736 exclusive_clash += 1;
1737 break;
1738 }
1739 }
1740 if !spec.contains(h.priority) {
1741 priority_out_of_range += 1;
1742 }
1743 if h.properties.contains_key("PRIORITY") {
1744 priority_in_drawer += 1;
1745 }
1746 if h.properties.contains_key("BLOCKEDBY") {
1747 blockedby_typo += 1;
1748 }
1749 if let Some(raw) = h.properties.get("BLOCKER")
1750 && !crate::org::is_edna_blocker(raw)
1751 {
1752 blocker_as_ids += 1;
1753 }
1754 if crate::org::COMPUTED_SPECIALS
1755 .iter()
1756 .any(|k| *k != "PRIORITY" && h.properties.contains_key(*k))
1757 {
1758 computed_specials += 1;
1759 }
1760 if let Some(effort) = h.effort()
1761 && !crate::org::is_org_effort(effort)
1762 {
1763 bad_effort += 1;
1764 }
1765 if let Some(pid) = h.parent()
1766 && let Some(parent) = doc.headings.iter().find(|p| p.id == pid)
1767 && crate::org::org_property_is_set(&parent.properties, "ORDERED")
1768 && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1769 {
1770 let earlier_open = doc.headings.iter().any(|sib| {
1771 sib.parent() == Some(pid)
1772 && sib.line_start < h.line_start
1773 && sib.state != "DONE"
1774 && sib.state != "CANCELLED"
1775 });
1776 if earlier_open && (h.state == "STARTED" || h.state == "DONE") {
1777 ordered_skip += 1;
1778 }
1779 }
1780 if h.state == "DONE"
1781 && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1782 && doc.headings.iter().any(|c| {
1783 c.parent() == Some(h.id.as_str()) && c.state != "DONE" && c.state != "CANCELLED"
1784 })
1785 {
1786 done_with_open_children += 1;
1787 }
1788 }
1789 if type_not_tagged > 0 {
1790 f.warn(format_args!("{project}: {type_not_tagged} heading(s) have :TYPE: that is a legal Org tag but is not on the heading"));
1791 }
1792 if exclusive_clash > 0 {
1793 f.warn(format_args!("{project}: {exclusive_clash} heading(s) carry more than one tag from a #+TAGS: exclusive group"));
1794 }
1795 if priority_in_drawer > 0 {
1796 f.warn(format_args!("{project}: {priority_in_drawer} heading(s) put :PRIORITY: in the drawer; Org reads the [#A] cookie"));
1797 }
1798 if blockedby_typo > 0 {
1799 f.warn(format_args!(
1800 "{project}: {blockedby_typo} heading(s) use :BLOCKEDBY: instead of :BLOCKED_BY:"
1801 ));
1802 }
1803 if blocker_as_ids > 0 {
1804 f.warn(format_args!("{project}: {blocker_as_ids} heading(s) use :BLOCKER: as a bare id list; a rewrite folds them into :BLOCKED_BY:"));
1805 }
1806 if computed_specials > 0 {
1807 f.warn(format_args!("{project}: {computed_specials} heading(s) set a computed Org special (TODO/ITEM/TAGS/...) in the drawer; Org ignores it"));
1808 }
1809 if bad_effort > 0 {
1810 f.warn(format_args!(
1811 "{project}: {bad_effort} heading(s) have an Effort value Org will not parse"
1812 ));
1813 }
1814 if priority_out_of_range > 0 {
1815 f.warn(format_args!(
1816 "{project}: {priority_out_of_range} heading(s) have a [#prio] outside #+PRIORITIES:"
1817 ));
1818 }
1819 if ordered_skip > 0 {
1820 f.warn(format_args!("{project}: {ordered_skip} heading(s) started or closed before an earlier ORDERED sibling"));
1821 }
1822 if done_with_open_children > 0 {
1823 f.warn(format_args!("{project}: {done_with_open_children} DONE heading(s) still have open children (Org ORDERED / todo-dependencies)"));
1824 }
1825}
1826
1827fn check_issue<'a>(
1830 project: &str,
1831 h: &'a IssueHeading,
1832 resolves: &impl Fn(&str) -> bool,
1833 by_id: &HashMap<String, (String, &'a IssueHeading)>,
1834 f: &mut Findings,
1835) {
1836 if let Some(parent) = h.parent()
1837 && !resolves(parent)
1838 {
1839 f.err(format_args!(
1840 "{} (in {}) :PARENT: {} -> not found",
1841 h.id, project, parent
1842 ));
1843 }
1844 for blk in blocker_ids(h) {
1845 if !by_id.contains_key(blk) {
1846 f.err(format_args!(
1847 "{} (in {}) :BLOCKED_BY: {} -> not found",
1848 h.id, project, blk
1849 ));
1850 }
1851 }
1852 for cited in h.deeds() {
1856 if !crate::ops::is_deed_accession(&cited) {
1857 f.warn(format_args!(
1858 "{} (in {}) :DEEDS: {} -> not a deed accession",
1859 h.id, project, cited
1860 ));
1861 }
1862 }
1863 if let Some(d) = h.deadline()
1864 && parse_org_date(d).is_none()
1865 {
1866 f.err(format_args!(
1867 "{} (in {}) :DEADLINE: {} -> unparseable",
1868 h.id, project, d
1869 ));
1870 }
1871 if let Some(s) = h.scheduled()
1872 && parse_org_date(s).is_none()
1873 {
1874 f.err(format_args!(
1875 "{} (in {}) :SCHEDULED: {} -> unparseable",
1876 h.id, project, s
1877 ));
1878 }
1879 if matches!(h.state.as_str(), "TODO" | "STARTED") && !h.properties.contains_key("CREATED") {
1880 f.warn(format_args!(
1881 "{} (in {}) state={} but :CREATED: is missing",
1882 h.id, project, h.state
1883 ));
1884 }
1885 if h.state == "DONE" && looks_like_reject_prose(&h.body) {
1886 f.warn(format_args!(
1887 "{} (in {}) is DONE but the body reads as a reject",
1888 h.id, project
1889 ));
1890 }
1891 if crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_some() {
1892 f.warn(format_args!(
1893 "{} (in {}) holds {} and sibling {}",
1894 h.id,
1895 project,
1896 h.state,
1897 crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).unwrap_or("?")
1898 ));
1899 }
1900}
1901
1902fn check_provenance_links<'a>(
1904 all: &[(String, IssueHeading)],
1905 project: &str,
1906 h: &'a IssueHeading,
1907 known: &HashSet<&'a str>,
1908 f: &mut Findings,
1909) {
1910 for linked in crate::related::org_link_targets(&h.body, known) {
1911 if edge_connects(all, &h.id, &linked) {
1912 continue;
1913 }
1914 if !claims_discovery_or_pivot(&h.body, &linked) {
1915 continue;
1916 }
1917 f.warn(format_args!(
1918 "{} (in {}) mentions [[id:{}]] as discovered or pivoted with no edge either way",
1919 h.id, project, linked
1920 ));
1921 }
1922}
1923
1924fn check_parent_cycle<'a>(
1930 start: &'a IssueHeading,
1931 by_id: &HashMap<String, (String, &'a IssueHeading)>,
1932 settled: &mut HashSet<&'a str>,
1933 f: &mut Findings,
1934) {
1935 if settled.contains(start.id.as_str()) {
1936 return;
1937 }
1938 let mut path: Vec<&str> = Vec::new();
1939 let mut on_path: HashSet<&str> = HashSet::new();
1940 let mut cursor = start.id.as_str();
1941 loop {
1942 if settled.contains(cursor) {
1943 break;
1944 }
1945 if !on_path.insert(cursor) {
1946 let start = path.iter().position(|id| *id == cursor).unwrap_or(0);
1947 let mut loop_ids: Vec<&str> = path[start..].to_vec();
1948 loop_ids.push(cursor);
1949 f.err(format_args!("parent cycle: {}", loop_ids.join(" -> ")));
1950 break;
1951 }
1952 path.push(cursor);
1953 match by_id.get(cursor).and_then(|(_, owner)| owner.parent()) {
1954 Some(parent) if by_id.contains_key(parent) => cursor = parent,
1955 _ => break,
1956 }
1957 }
1958 settled.extend(path);
1959}
1960
1961pub fn backlinks(layout: &Layout, target_id: &str) -> Result<String> {
1969 let all = load_all(layout)?;
1970 let mut out = String::new();
1971
1972 let known = all.iter().any(|(_, h)| h.id == target_id);
1978 if !known && crate::ops::is_deed_accession(target_id) {
1979 for (project, h) in &all {
1980 let relation = if h.deeds().iter().any(|cited| cited == target_id) {
1981 "cites"
1982 } else if h.body.contains(target_id) {
1983 "body mention"
1984 } else {
1985 continue;
1986 };
1987 let _ = writeln!(out, "{:<22} ({relation}) ({project})", h.id);
1988 }
1989 return Ok(out);
1990 }
1991
1992 for (project, h) in &all {
1993 if h.id == target_id {
1994 continue;
1995 }
1996 let mut hit = false;
1997 if blocker_ids(h).contains(&target_id) {
1998 let _ = writeln!(out, "{:<22} (blocked-by) ({})", h.id, project);
1999 hit = true;
2000 }
2001 if h.parent() == Some(target_id) {
2002 let _ = writeln!(out, "{:<22} (parent) ({})", h.id, project);
2003 hit = true;
2004 }
2005 if crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(target_id) {
2006 let _ = writeln!(out, "{:<22} (discovered-from) ({})", h.id, project);
2007 hit = true;
2008 }
2009 if crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(target_id) {
2010 let _ = writeln!(out, "{:<22} (pivoted-to) ({})", h.id, project);
2011 hit = true;
2012 }
2013 if !hit && h.body.contains(target_id) {
2014 let _ = writeln!(out, "{:<22} (body mention) ({})", h.id, project);
2015 }
2016 }
2017 Ok(out)
2018}
2019
2020#[cfg(test)]
2021mod tests {
2022 use super::*;
2023
2024 #[test]
2025 fn dot_labels_escape_untrusted_issue_text() {
2026 assert_eq!(dot_quoted(r#"a "quoted" title"#), r#"a \"quoted\" title"#);
2027 assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
2030 assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
2031 }
2032}