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 horizon = today + chrono::Duration::days(days);
764 let mut rows: Vec<(NaiveDate, char, String, IssueHeading)> = Vec::new();
766 for (project, h) in load_all(layout)? {
767 if !project_selected(&project, project_filter) {
768 continue;
769 }
770 if !READY_STATES.contains(&h.state.as_str()) && h.state != "BLOCKED" {
771 continue;
772 }
773 for (kind, value) in [('D', h.deadline()), ('S', h.scheduled())] {
774 let Some(parsed) = value.and_then(parse_org_date) else {
775 continue;
776 };
777 if parsed <= horizon {
778 rows.push((parsed, kind, project.clone(), h.clone()));
779 }
780 }
781 }
782 rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.3.id.cmp(&b.3.id)));
783
784 let mut out = String::new();
785 for (date, kind, project, h) in rows {
786 let delta = (date - today).num_days();
787 let when = match delta {
788 d if d < 0 => format!("{}d overdue", -d),
789 0 => "today".to_string(),
790 d => format!("in {d}d"),
791 };
792 let label = if kind == 'D' { "deadline" } else { "scheduled" };
793 let _ = writeln!(
794 out,
795 "{date} {label:<9} {when:<11} {:<22} {:<9} [#{}] {} ({})",
796 h.id, h.state, h.priority, h.title, project
797 );
798 }
799 if out.is_empty() {
800 out.push_str("nothing dated in range\n");
801 }
802 Ok(out)
803}
804
805pub(crate) fn parse_org_date(s: &str) -> Option<NaiveDate> {
806 let inner = s
807 .trim_start_matches(['<', '['])
808 .trim_end_matches(['>', ']']);
809 let token = inner.split_whitespace().next()?;
810 NaiveDate::parse_from_str(token, "%Y-%m-%d").ok()
811}
812
813pub fn count(
819 layout: &Layout,
820 project_filter: Option<&str>,
821 state_filter: Option<&str>,
822 ready_only: bool,
823) -> Result<String> {
824 let all = load_all(layout)?;
825 let active_blockers: HashSet<String> = if ready_only {
826 all.iter()
827 .filter(|(_, h)| h.state != "DONE" && h.state != "CANCELLED")
828 .map(|(_, h)| h.id.clone())
829 .collect()
830 } else {
831 HashSet::new()
832 };
833 let n = all
834 .iter()
835 .filter(|(project, h)| {
836 if !project_selected(project, project_filter) {
837 return false;
838 }
839 if let Some(s) = state_filter
840 && h.state != s
841 {
842 return false;
843 }
844 if ready_only {
845 if !READY_STATES.contains(&h.state.as_str()) {
846 return false;
847 }
848 if blocker_ids(h).iter().any(|b| active_blockers.contains(*b)) {
849 return false;
850 }
851 }
852 true
853 })
854 .count();
855 Ok(format!("{n}\n"))
856}
857
858pub fn export(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
865 let mut out = String::new();
866 for rec in load_recs(layout)? {
867 if !project_selected(&rec.project, project_filter) {
868 continue;
869 }
870 let _ = writeln!(
871 out,
872 "{}",
873 export_row(&rec.project, rec.heading, &rec.tag_settings)
874 );
875 }
876 Ok(out)
877}
878
879pub fn export_by_project(layout: &Layout) -> Result<BTreeMap<String, String>> {
892 let mut out: BTreeMap<String, String> = BTreeMap::new();
893 for rec in load_recs(layout)? {
894 let row = export_row(&rec.project, rec.heading, &rec.tag_settings);
895 let _ = writeln!(out.entry(rec.project).or_default(), "{row}");
896 }
897 Ok(out)
898}
899
900fn export_row(
901 project: &str,
902 h: IssueHeading,
903 settings: &crate::org::TagSettings,
904) -> serde_json::Value {
905 let logbook: Vec<serde_json::Value> = h
906 .logbook
907 .iter()
908 .map(|e| {
909 let mut row = serde_json::json!({
910 "timestamp": e.timestamp,
911 "from": e.from_state,
912 "to": e.to_state,
913 "note": e.note,
914 });
915 if let Some(raw) = &e.raw {
916 row["raw"] = serde_json::Value::String(raw.clone());
917 }
918 row
919 })
920 .collect();
921 serde_json::json!({
922 "id": h.id,
923 "project": project,
924 "title": h.title,
925 "state": h.state,
926 "priority": h.priority.to_string(),
927 "properties": h.properties,
928 "deeds": h.deeds(),
934 "org_tags": h.org_tags,
935 "tags": h.tags(),
936 "all_tags": settings.all_tags(&h.tags()),
937 "logbook": logbook,
938 "body": h.body,
939 "line_start": h.line_start,
940 "line_end": h.line_end,
941 })
942}
943
944pub fn tree(layout: &Layout, root_id: &str, format: &str) -> Result<String> {
951 let all = load_all(layout)?;
952 let graph = GraphIndex::new(&all);
953 let Some(root_heading) = graph.by_id.get(root_id) else {
954 return Err(Error::IssueNotFound {
955 id: root_id.to_string(),
956 });
957 };
958 let mut out = String::new();
959 let root = root_heading.id.as_str();
960 match format {
961 "ascii" | "text" => tree_ascii(&graph, root, 0, &mut HashSet::new(), &mut out),
962 "dot" => tree_dot(&graph, root, &mut out),
963 _ => return Err(anyhow!("unknown format {format:?}; allowed: ascii, dot").into()),
964 }
965 Ok(out)
966}
967
968fn tree_ascii<'a>(
969 graph: &GraphIndex<'a>,
970 id: &'a str,
971 depth: usize,
972 seen: &mut HashSet<&'a str>,
973 out: &mut String,
974) {
975 if !seen.insert(id) {
976 let _ = writeln!(out, "{}{id} (cycle, stopping)", " ".repeat(depth));
977 return;
978 }
979 let Some(h) = graph.by_id.get(id) else {
980 let _ = writeln!(out, "{}{id} (missing)", " ".repeat(depth));
981 return;
982 };
983 let _ = writeln!(
984 out,
985 "{}{id} {:<9} [#{}] {}",
986 " ".repeat(depth),
987 h.state,
988 h.priority,
989 h.title
990 );
991 if let Some(blockers) = graph.blockers.get(id) {
992 for blocker in blockers {
993 let _ = writeln!(out, "{}* blocked-by {blocker}", " ".repeat(depth + 1));
994 }
995 }
996 if let Some(kids) = graph.children.get(id) {
997 for k in kids {
998 tree_ascii(graph, k, depth + 1, seen, out);
999 }
1000 }
1001}
1002
1003pub(crate) fn dot_quoted(text: &str) -> String {
1008 text.replace('\\', "\\\\")
1009 .replace('"', "\\\"")
1010 .replace('\n', "\\n")
1011 .replace('\r', "")
1012}
1013
1014fn tree_dot<'a>(graph: &GraphIndex<'a>, root_id: &str, out: &mut String) {
1015 let _ = writeln!(out, "digraph vissue_tree {{");
1016 let _ = writeln!(out, " rankdir=LR;");
1017 let _ = writeln!(
1018 out,
1019 " node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
1020 );
1021 let mut visited: HashSet<&str> = HashSet::new();
1022 let mut stack = vec![graph.by_id.get(root_id).unwrap().id.as_str()];
1023 while let Some(id) = stack.pop() {
1024 if !visited.insert(id) {
1025 continue;
1026 }
1027 if let Some(h) = graph.by_id.get(id) {
1028 let _ = writeln!(
1029 out,
1030 " \"{}\" [label=\"{}\\n{} [#{}]\"];",
1031 dot_quoted(&h.id),
1032 dot_quoted(&h.title),
1033 dot_quoted(&h.state),
1034 dot_quoted(&h.priority.to_string())
1035 );
1036 if let Some(kids) = graph.children.get(id) {
1037 for k in kids {
1038 let _ = writeln!(
1039 out,
1040 " \"{}\" -> \"{}\" [color=\"#00897B\"];",
1041 dot_quoted(&h.id),
1042 dot_quoted(k)
1043 );
1044 stack.push(k);
1045 }
1046 }
1047 if let Some(blockers) = graph.blockers.get(id) {
1048 for b in blockers {
1049 let _ = writeln!(
1050 out,
1051 " \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
1052 dot_quoted(b),
1053 dot_quoted(&h.id)
1054 );
1055 stack.push(b);
1056 }
1057 }
1058 }
1059 }
1060 let _ = writeln!(out, "}}");
1061}
1062
1063pub fn cycles(layout: &Layout) -> Result<String> {
1069 let all = load_all(layout)?;
1070 let graph = GraphIndex::new(&all);
1071
1072 const WHITE: u8 = 0;
1076 const GREY: u8 = 1;
1077 const BLACK: u8 = 2;
1078 let mut color: HashMap<&str, u8> = HashMap::new();
1079 let mut found: Vec<Vec<String>> = Vec::new();
1080
1081 fn dfs<'a>(
1082 id: &'a str,
1083 graph: &GraphIndex<'a>,
1084 color: &mut HashMap<&'a str, u8>,
1085 path: &mut Vec<&'a str>,
1086 found: &mut Vec<Vec<String>>,
1087 ) {
1088 color.insert(id, GREY);
1089 path.push(id);
1090 if let Some(blockers) = graph.blockers.get(id) {
1091 for b in blockers {
1092 if !graph.by_id.contains_key(b) {
1093 continue; }
1095 match color.get(b).copied().unwrap_or(WHITE) {
1096 GREY => {
1097 let start = path.iter().position(|&x| x == *b).unwrap();
1098 let mut cycle: Vec<String> =
1099 path[start..].iter().map(|s| s.to_string()).collect();
1100 let min = cycle
1103 .iter()
1104 .enumerate()
1105 .min_by(|a, b| a.1.cmp(b.1))
1106 .map(|(i, _)| i)
1107 .unwrap();
1108 cycle.rotate_left(min);
1109 cycle.push(cycle[0].clone());
1110 if !found.contains(&cycle) {
1111 found.push(cycle);
1112 }
1113 }
1114 WHITE => dfs(b, graph, color, path, found),
1115 _ => {}
1116 }
1117 }
1118 }
1119 path.pop();
1120 color.insert(id, BLACK);
1121 }
1122
1123 for (_, start) in &all {
1124 if color.get(start.id.as_str()).copied().unwrap_or(WHITE) == WHITE {
1125 let mut path = Vec::new();
1126 dfs(start.id.as_str(), &graph, &mut color, &mut path, &mut found);
1127 }
1128 }
1129
1130 let mut out = String::new();
1131 if found.is_empty() {
1132 let _ = writeln!(out, "no cycles");
1133 } else {
1134 for cycle in found {
1135 let _ = writeln!(out, "{}", cycle.join(" -> "));
1136 }
1137 }
1138 Ok(out)
1139}
1140
1141pub fn ancestors(layout: &Layout, id: &str, depth: usize) -> Result<String> {
1148 let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
1149 let mut out = String::new();
1150 for (distance, ancestor) in graph.ancestors(id, depth)? {
1151 writeln!(out, "{distance} {ancestor}")?;
1152 }
1153 Ok(out)
1154}
1155
1156pub fn impact(layout: &Layout, id: &str, depth: usize) -> Result<String> {
1163 let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
1164 let mut out = String::new();
1165 for (distance, descendant) in graph.descendants(id, depth)? {
1166 writeln!(out, "{distance} {descendant}")?;
1167 }
1168 Ok(out)
1169}
1170
1171pub const GRAPH_HEADER: &str = concat!(
1181 "digraph vissue_graph {\n",
1182 " rankdir=LR;\n",
1183 " node [shape=box, fontname=\"Jost\", style=filled];\n",
1184 " edge [fontname=\"Jost\"];\n"
1185);
1186
1187pub const GRAPH_FOOTER: &str = "}\n";
1189
1190pub fn graph(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1196 Ok(format!(
1197 "{GRAPH_HEADER}{}{GRAPH_FOOTER}",
1198 graph_body(layout, project_filter)?
1199 ))
1200}
1201
1202pub fn graph_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1208 let all = load_all(layout)?;
1209 let graph = GraphIndex::new(&all);
1210 let mut out = String::new();
1211 for (project, h) in &all {
1212 if !project_selected(project, project_filter) {
1213 continue;
1214 }
1215 let fill = match h.state.as_str() {
1216 "DONE" => "#A5D6A7",
1217 "CANCELLED" => "#CFD8DC",
1218 "BLOCKED" => "#FFCC80",
1219 "STARTED" => "#80CBC4",
1220 _ => "#E0F2F1",
1221 };
1222 let _ = writeln!(
1223 out,
1224 " \"{}\" [label=\"{}\\n{} [#{}]\", fillcolor=\"{}\"];",
1225 dot_quoted(&h.id),
1226 dot_quoted(&h.title),
1227 dot_quoted(&h.state),
1228 dot_quoted(&h.priority.to_string()),
1229 fill
1230 );
1231 }
1232 for (project, h) in &all {
1233 if !project_selected(project, project_filter) {
1234 continue;
1235 }
1236 if let Some(blockers) = graph.blockers.get(h.id.as_str()) {
1237 for b in blockers {
1238 writeln!(
1239 out,
1240 " \"{}\" -> \"{}\" [color=\"#FF7043\"];",
1241 dot_quoted(b),
1242 dot_quoted(&h.id)
1243 )?;
1244 }
1245 }
1246 if let Some(parent) = h.parent() {
1247 writeln!(
1248 out,
1249 " \"{}\" -> \"{}\" [color=\"#00897B\", style=dashed];",
1250 dot_quoted(parent),
1251 dot_quoted(&h.id)
1252 )?;
1253 }
1254 }
1255 Ok(out)
1256}
1257
1258pub const ROADMAP_HEADER: &str = concat!(
1269 "# Roadmap\n\n",
1270 "Generated from `vissue roadmap`. Source of truth lives in the per-project issues.org files.\n\n"
1271);
1272
1273pub fn roadmap(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1279 Ok(format!(
1280 "{ROADMAP_HEADER}{}",
1281 roadmap_body(layout, project_filter)?
1282 ))
1283}
1284
1285pub fn roadmap_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1291 let all = load_all(layout)?;
1292 let mut by_project: BTreeMap<String, Vec<&IssueHeading>> = BTreeMap::new();
1293 for (project, h) in &all {
1294 if !project_selected(project, project_filter) {
1295 continue;
1296 }
1297 by_project.entry(project.clone()).or_default().push(h);
1298 }
1299 let mut out = String::new();
1300 for (project, mut headings) in by_project {
1301 headings.sort_by(|a, b| {
1302 a.priority
1303 .cmp(&b.priority)
1304 .then_with(|| a.state.cmp(&b.state))
1305 .then_with(|| a.id.cmp(&b.id))
1306 });
1307 let buckets = ["STARTED", "TODO", "BLOCKED"];
1308 let active: Vec<&&IssueHeading> = headings
1309 .iter()
1310 .filter(|h| buckets.contains(&h.state.as_str()))
1311 .collect();
1312 let closed: Vec<&&IssueHeading> = headings
1313 .iter()
1314 .filter(|h| h.state == "DONE" || h.state == "CANCELLED")
1315 .collect();
1316 if active.is_empty() && closed.is_empty() {
1317 continue;
1318 }
1319 writeln!(out, "## {project}")?;
1320 writeln!(out)?;
1321 for state in buckets {
1322 let in_state: Vec<&&IssueHeading> = active
1323 .iter()
1324 .copied()
1325 .filter(|h| h.state == state)
1326 .collect();
1327 if in_state.is_empty() {
1328 continue;
1329 }
1330 writeln!(out, "### {state}")?;
1331 writeln!(out)?;
1332 for h in in_state {
1333 let deadline = h
1334 .deadline()
1335 .map(|d| format!(" :: deadline {d}"))
1336 .unwrap_or_default();
1337 let blockers = blocker_ids(h);
1338 let blocked_by = if blockers.is_empty() {
1339 String::new()
1340 } else {
1341 format!(" :: blocked by {}", blockers.join(", "))
1342 };
1343 writeln!(
1344 out,
1345 "- **{}** [#{}] {}{}{}",
1346 h.id, h.priority, h.title, deadline, blocked_by
1347 )?;
1348 }
1349 writeln!(out)?;
1350 }
1351 if !closed.is_empty() {
1352 writeln!(out, "### Closed ({} items)", closed.len())?;
1353 writeln!(out)?;
1354 for h in closed.iter().take(10) {
1355 writeln!(
1356 out,
1357 "- {} [#{}] {} ({})",
1358 h.id, h.priority, h.title, h.state
1359 )?;
1360 }
1361 if closed.len() > 10 {
1362 writeln!(out, "- ... and {} more", closed.len() - 10)?;
1363 }
1364 writeln!(out)?;
1365 }
1366 }
1367 Ok(out)
1368}
1369
1370fn looks_like_reject_prose(body: &str) -> bool {
1383 let lower = body.to_ascii_lowercase();
1384 const CLOSING: &[&str] = &[
1385 "vissue reject",
1386 "superseded by",
1387 "rejected in favour",
1388 "rejected in favor",
1389 "rejected as a duplicate",
1390 "closed as a duplicate",
1391 "closed as duplicate",
1392 "not doing this",
1393 "rejected this",
1394 "rejected: ",
1395 ];
1396 if CLOSING.iter().any(|phrase| lower.contains(phrase)) {
1397 return true;
1398 }
1399 lower.lines().any(|line| {
1408 line.starts_with('*')
1409 && (line.contains("rejected")
1410 || line.contains("superseded")
1411 || line.contains("reject:"))
1412 })
1413}
1414
1415fn claims_discovery_or_pivot(body: &str, linked: &str) -> bool {
1426 const CLAIMS: &[&str] = &[
1427 "discovered from",
1428 "discovered while",
1429 "discovered during",
1430 "found while",
1431 "filed from",
1432 "split from",
1433 "pivoted to",
1434 "pivots to",
1435 "pivoted from",
1436 "replaced by",
1437 "moved to",
1438 ];
1439 let needle = format!("id:{linked}");
1440 let lower = body.to_ascii_lowercase();
1441 let lower_needle = needle.to_ascii_lowercase();
1442 let window = 240;
1446 let mut from = 0;
1447 while let Some(at) = lower[from..].find(&lower_needle) {
1448 let hit = from + at;
1449 let start = hit.saturating_sub(window);
1450 let end = (hit + lower_needle.len() + window).min(lower.len());
1451 let near = &lower[floor_char_boundary(&lower, start)..ceil_char_boundary(&lower, end)];
1452 if CLAIMS.iter().any(|phrase| near.contains(phrase)) {
1453 return true;
1454 }
1455 from = hit + lower_needle.len();
1456 }
1457 false
1458}
1459
1460fn floor_char_boundary(s: &str, mut i: usize) -> usize {
1461 while i > 0 && !s.is_char_boundary(i) {
1462 i -= 1;
1463 }
1464 i
1465}
1466
1467fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
1468 while i < s.len() && !s.is_char_boundary(i) {
1469 i += 1;
1470 }
1471 i
1472}
1473
1474fn edge_connects(all: &[(String, IssueHeading)], a: &str, b: &str) -> bool {
1483 all.iter().any(|(_, h)| {
1484 let far = if h.id == a {
1485 b
1486 } else if h.id == b {
1487 a
1488 } else {
1489 return false;
1490 };
1491 [
1492 crate::props::DISCOVERED_FROM,
1493 crate::props::PIVOTED_TO,
1494 crate::props::PARENT,
1495 crate::props::BLOCKED_BY,
1496 crate::props::EDNA_BLOCKER,
1497 ]
1498 .iter()
1499 .any(|key| {
1500 crate::props::get(&h.properties, key)
1501 .is_some_and(|value| value.split(&[',', ' '][..]).any(|part| part.trim() == far))
1502 })
1503 })
1504}
1505
1506#[derive(Debug, Clone)]
1508pub struct CheckReport {
1509 pub text: String,
1511 pub errors: usize,
1513 pub warnings: usize,
1515}
1516
1517#[derive(Default)]
1524struct Findings {
1525 text: String,
1526 errors: usize,
1527 warnings: usize,
1528}
1529
1530impl Findings {
1531 fn err(&mut self, what: std::fmt::Arguments) {
1533 let _ = writeln!(self.text, "[err] {what}");
1534 self.errors += 1;
1535 }
1536
1537 fn warn(&mut self, what: std::fmt::Arguments) {
1539 let _ = writeln!(self.text, "[warn] {what}");
1540 self.warnings += 1;
1541 }
1542}
1543
1544pub fn check(layout: &Layout) -> Result<CheckReport> {
1551 let all = load_all(layout)?;
1552
1553 let issue_ids: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1558 let unresolved: HashSet<String> = all
1559 .iter()
1560 .filter_map(|(_, h)| h.parent())
1561 .filter(|p| !issue_ids.contains(p))
1562 .map(str::to_string)
1563 .collect();
1564 let elsewhere = find_org_ids(layout, &unresolved)?;
1565 let resolves = |id: &str| issue_ids.contains(id) || elsewhere.contains(id);
1566
1567 let mut f = Findings::default();
1568
1569 let mut by_id: HashMap<String, (String, &IssueHeading)> = HashMap::new();
1570 for (project, h) in &all {
1571 if let Some(prev) = by_id.insert(h.id.clone(), (project.clone(), h)) {
1572 f.err(format_args!(
1575 "duplicate id: {} appears in {} and {}",
1576 h.id, prev.0, project
1577 ));
1578 }
1579 }
1580
1581 for project in list_projects(layout)? {
1582 check_project(&project, layout, &mut f)?;
1583 }
1584
1585 for (project, h) in &all {
1586 check_issue(project, h, &resolves, &by_id, &mut f);
1587 }
1588
1589 let known: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1590 for (project, h) in &all {
1591 check_provenance_links(&all, project, h, &known, &mut f);
1592 }
1593
1594 let mut settled: HashSet<&str> = HashSet::new();
1599 for (_, h) in &all {
1600 check_parent_cycle(h, &by_id, &mut settled, &mut f);
1601 }
1602
1603 if f.errors == 0
1604 && let Err(err) = DependencyGraph::from_issues(&all)
1605 {
1606 f.err(format_args!("blocker graph: {err}"));
1607 }
1608
1609 let _ = writeln!(f.text);
1610 let projects = list_projects(layout)?.len();
1611 let _ = writeln!(
1612 f.text,
1613 "checked {} issue(s) across {projects} project(s): {} error(s), {} warning(s)",
1614 all.len(),
1615 f.errors,
1616 f.warnings
1617 );
1618 Ok(CheckReport {
1619 text: f.text,
1620 errors: f.errors,
1621 warnings: f.warnings,
1622 })
1623}
1624
1625fn check_project(project: &str, layout: &Layout, f: &mut Findings) -> Result<()> {
1635 let path = layout.project_issues_path(project);
1636 let doc = IssueDoc::parse_file(project, &path)?;
1637 check_preamble(project, &doc, &path, f);
1638 let gcal_ids = crate::store::org_ids(&std::fs::read_to_string(&path)?)
1643 .filter(|id| crate::org::is_gcal_event_id(id))
1644 .count();
1645 if gcal_ids > 0 {
1646 f.err(format_args!(
1647 "{project}: {gcal_ids} heading(s) use an org-gcal event id as :ID:"
1648 ));
1649 }
1650 check_headings(project, &doc, f);
1651 Ok(())
1652}
1653
1654fn check_preamble(project: &str, doc: &IssueDoc, path: &std::path::Path, f: &mut Findings) {
1660 match crate::org::protocol_from_preamble(&doc.preamble) {
1661 None => {
1662 f.warn(format_args!(
1663 "{project}: preamble has no #+VISSUE: protocol stamp"
1664 ));
1665 }
1666 Some(n) if n < crate::org::PROTOCOL_VERSION => {
1667 f.warn(format_args!(
1668 "{project}: #+VISSUE: {n} is behind protocol {}",
1669 crate::org::PROTOCOL_VERSION
1670 ));
1671 }
1672 Some(n) if n > crate::org::PROTOCOL_VERSION => {
1673 f.err(format_args!(
1674 "{project}: #+VISSUE: {n} is newer than this vissue (protocol {})",
1675 crate::org::PROTOCOL_VERSION
1676 ));
1677 }
1678 Some(_) => {}
1679 }
1680 if !crate::org::preamble_has_keyword(&doc.preamble, "CATEGORY") {
1681 f.warn(format_args!(
1682 "{project}: preamble has no #+CATEGORY: (org-agenda labels every row \"issues\")"
1683 ));
1684 }
1685 if !crate::org::preamble_has_keyword(&doc.preamble, "FILETAGS") {
1686 f.warn(format_args!("{project}: preamble has no #+FILETAGS:"));
1687 } else if !doc
1688 .tag_settings
1689 .filetags
1690 .iter()
1691 .any(|t| t.eq_ignore_ascii_case("noexport"))
1692 {
1693 f.warn(format_args!(
1694 "{project}: #+FILETAGS: has no noexport; a vault publish will export this tracker"
1695 ));
1696 }
1697 if !crate::org::preamble_has_keyword(&doc.preamble, "TAGS") {
1698 f.warn(format_args!(
1699 "{project}: preamble has no #+TAGS:; Emacs fast tag selection has no type group"
1700 ));
1701 }
1702 if !crate::org::preamble_has_keyword(
1703 &crate::org::merge_setupfile_settings(&doc.preamble, path.parent()),
1704 "PRIORITIES",
1705 ) {
1706 f.warn(format_args!(
1707 "{project}: preamble has no #+PRIORITIES:; cookies default to C and the range is A..C"
1708 ));
1709 }
1710}
1711
1712fn check_headings(project: &str, doc: &IssueDoc, f: &mut Findings) {
1717 let spec = doc.priority_spec();
1718 let mut type_not_tagged = 0usize;
1719 let mut exclusive_clash = 0usize;
1720 let mut priority_out_of_range = 0usize;
1721 let mut ordered_skip = 0usize;
1722 let mut done_with_open_children = 0usize;
1723 let mut priority_in_drawer = 0usize;
1724 let mut blockedby_typo = 0usize;
1725 let mut blocker_as_ids = 0usize;
1726 let mut computed_specials = 0usize;
1727 let mut bad_effort = 0usize;
1728 for h in &doc.headings {
1729 if let Some(kind) = crate::props::get(&h.properties, crate::props::TYPE) {
1730 let kind = kind.trim();
1731 if !kind.is_empty()
1732 && kind.chars().all(crate::model::is_org_tag_char)
1733 && !h.org_tags.iter().any(|t| t == kind)
1734 {
1735 type_not_tagged += 1;
1736 }
1737 }
1738 for group in &doc.tag_settings.exclusive {
1739 let hits = group
1740 .iter()
1741 .filter(|name| h.org_tags.iter().any(|t| t == *name))
1742 .count();
1743 if hits > 1 {
1744 exclusive_clash += 1;
1745 break;
1746 }
1747 }
1748 if !spec.contains(h.priority) {
1749 priority_out_of_range += 1;
1750 }
1751 if h.properties.contains_key("PRIORITY") {
1752 priority_in_drawer += 1;
1753 }
1754 if h.properties.contains_key("BLOCKEDBY") {
1755 blockedby_typo += 1;
1756 }
1757 if let Some(raw) = h.properties.get("BLOCKER")
1758 && !crate::org::is_edna_blocker(raw)
1759 {
1760 blocker_as_ids += 1;
1761 }
1762 if crate::org::COMPUTED_SPECIALS
1763 .iter()
1764 .any(|k| *k != "PRIORITY" && h.properties.contains_key(*k))
1765 {
1766 computed_specials += 1;
1767 }
1768 if let Some(effort) = h.effort()
1769 && !crate::org::is_org_effort(effort)
1770 {
1771 bad_effort += 1;
1772 }
1773 if let Some(pid) = h.parent()
1774 && let Some(parent) = doc.headings.iter().find(|p| p.id == pid)
1775 && crate::org::org_property_is_set(&parent.properties, "ORDERED")
1776 && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1777 {
1778 let earlier_open = doc.headings.iter().any(|sib| {
1779 sib.parent() == Some(pid)
1780 && sib.line_start < h.line_start
1781 && sib.state != "DONE"
1782 && sib.state != "CANCELLED"
1783 });
1784 if earlier_open && (h.state == "STARTED" || h.state == "DONE") {
1785 ordered_skip += 1;
1786 }
1787 }
1788 if h.state == "DONE"
1789 && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1790 && doc.headings.iter().any(|c| {
1791 c.parent() == Some(h.id.as_str()) && c.state != "DONE" && c.state != "CANCELLED"
1792 })
1793 {
1794 done_with_open_children += 1;
1795 }
1796 }
1797 if type_not_tagged > 0 {
1798 f.warn(format_args!("{project}: {type_not_tagged} heading(s) have :TYPE: that is a legal Org tag but is not on the heading"));
1799 }
1800 if exclusive_clash > 0 {
1801 f.warn(format_args!("{project}: {exclusive_clash} heading(s) carry more than one tag from a #+TAGS: exclusive group"));
1802 }
1803 if priority_in_drawer > 0 {
1804 f.warn(format_args!("{project}: {priority_in_drawer} heading(s) put :PRIORITY: in the drawer; Org reads the [#A] cookie"));
1805 }
1806 if blockedby_typo > 0 {
1807 f.warn(format_args!(
1808 "{project}: {blockedby_typo} heading(s) use :BLOCKEDBY: instead of :BLOCKED_BY:"
1809 ));
1810 }
1811 if blocker_as_ids > 0 {
1812 f.warn(format_args!("{project}: {blocker_as_ids} heading(s) use :BLOCKER: as a bare id list; a rewrite folds them into :BLOCKED_BY:"));
1813 }
1814 if computed_specials > 0 {
1815 f.warn(format_args!("{project}: {computed_specials} heading(s) set a computed Org special (TODO/ITEM/TAGS/...) in the drawer; Org ignores it"));
1816 }
1817 if bad_effort > 0 {
1818 f.warn(format_args!(
1819 "{project}: {bad_effort} heading(s) have an Effort value Org will not parse"
1820 ));
1821 }
1822 if priority_out_of_range > 0 {
1823 f.warn(format_args!(
1824 "{project}: {priority_out_of_range} heading(s) have a [#prio] outside #+PRIORITIES:"
1825 ));
1826 }
1827 if ordered_skip > 0 {
1828 f.warn(format_args!("{project}: {ordered_skip} heading(s) started or closed before an earlier ORDERED sibling"));
1829 }
1830 if done_with_open_children > 0 {
1831 f.warn(format_args!("{project}: {done_with_open_children} DONE heading(s) still have open children (Org ORDERED / todo-dependencies)"));
1832 }
1833}
1834
1835fn check_issue<'a>(
1838 project: &str,
1839 h: &'a IssueHeading,
1840 resolves: &impl Fn(&str) -> bool,
1841 by_id: &HashMap<String, (String, &'a IssueHeading)>,
1842 f: &mut Findings,
1843) {
1844 if let Some(parent) = h.parent()
1845 && !resolves(parent)
1846 {
1847 f.err(format_args!(
1848 "{} (in {}) :PARENT: {} -> not found",
1849 h.id, project, parent
1850 ));
1851 }
1852 for blk in blocker_ids(h) {
1853 if !by_id.contains_key(blk) {
1854 f.err(format_args!(
1855 "{} (in {}) :BLOCKED_BY: {} -> not found",
1856 h.id, project, blk
1857 ));
1858 }
1859 }
1860 for cited in h.deeds() {
1864 if !crate::ops::is_deed_accession(&cited) {
1865 f.warn(format_args!(
1866 "{} (in {}) :DEEDS: {} -> not a deed accession",
1867 h.id, project, cited
1868 ));
1869 }
1870 }
1871 if let Some(d) = h.deadline()
1872 && parse_org_date(d).is_none()
1873 {
1874 f.err(format_args!(
1875 "{} (in {}) :DEADLINE: {} -> unparseable",
1876 h.id, project, d
1877 ));
1878 }
1879 if let Some(s) = h.scheduled()
1880 && parse_org_date(s).is_none()
1881 {
1882 f.err(format_args!(
1883 "{} (in {}) :SCHEDULED: {} -> unparseable",
1884 h.id, project, s
1885 ));
1886 }
1887 if matches!(h.state.as_str(), "TODO" | "STARTED") && !h.properties.contains_key("CREATED") {
1888 f.warn(format_args!(
1889 "{} (in {}) state={} but :CREATED: is missing",
1890 h.id, project, h.state
1891 ));
1892 }
1893 if h.state == "DONE" && looks_like_reject_prose(&h.body) {
1894 f.warn(format_args!(
1895 "{} (in {}) is DONE but the body reads as a reject",
1896 h.id, project
1897 ));
1898 }
1899 if crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_some() {
1900 f.warn(format_args!(
1901 "{} (in {}) holds {} and sibling {}",
1902 h.id,
1903 project,
1904 h.state,
1905 crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).unwrap_or("?")
1906 ));
1907 }
1908}
1909
1910fn check_provenance_links<'a>(
1912 all: &[(String, IssueHeading)],
1913 project: &str,
1914 h: &'a IssueHeading,
1915 known: &HashSet<&'a str>,
1916 f: &mut Findings,
1917) {
1918 for linked in crate::related::org_link_targets(&h.body, known) {
1919 if edge_connects(all, &h.id, &linked) {
1920 continue;
1921 }
1922 if !claims_discovery_or_pivot(&h.body, &linked) {
1923 continue;
1924 }
1925 f.warn(format_args!(
1926 "{} (in {}) mentions [[id:{}]] as discovered or pivoted with no edge either way",
1927 h.id, project, linked
1928 ));
1929 }
1930}
1931
1932fn check_parent_cycle<'a>(
1938 start: &'a IssueHeading,
1939 by_id: &HashMap<String, (String, &'a IssueHeading)>,
1940 settled: &mut HashSet<&'a str>,
1941 f: &mut Findings,
1942) {
1943 if settled.contains(start.id.as_str()) {
1944 return;
1945 }
1946 let mut path: Vec<&str> = Vec::new();
1947 let mut on_path: HashSet<&str> = HashSet::new();
1948 let mut cursor = start.id.as_str();
1949 loop {
1950 if settled.contains(cursor) {
1951 break;
1952 }
1953 if !on_path.insert(cursor) {
1954 let start = path.iter().position(|id| *id == cursor).unwrap_or(0);
1955 let mut loop_ids: Vec<&str> = path[start..].to_vec();
1956 loop_ids.push(cursor);
1957 f.err(format_args!("parent cycle: {}", loop_ids.join(" -> ")));
1958 break;
1959 }
1960 path.push(cursor);
1961 match by_id.get(cursor).and_then(|(_, owner)| owner.parent()) {
1962 Some(parent) if by_id.contains_key(parent) => cursor = parent,
1963 _ => break,
1964 }
1965 }
1966 settled.extend(path);
1967}
1968
1969pub fn backlinks(layout: &Layout, target_id: &str) -> Result<String> {
1977 let all = load_all(layout)?;
1978 let mut out = String::new();
1979
1980 let known = all.iter().any(|(_, h)| h.id == target_id);
1986 if !known && crate::ops::is_deed_accession(target_id) {
1987 for (project, h) in &all {
1988 let relation = if h.deeds().iter().any(|cited| cited == target_id) {
1989 "cites"
1990 } else if h.body.contains(target_id) {
1991 "body mention"
1992 } else {
1993 continue;
1994 };
1995 let _ = writeln!(out, "{:<22} ({relation}) ({project})", h.id);
1996 }
1997 return Ok(out);
1998 }
1999
2000 for (project, h) in &all {
2001 if h.id == target_id {
2002 continue;
2003 }
2004 let mut hit = false;
2005 if blocker_ids(h).contains(&target_id) {
2006 let _ = writeln!(out, "{:<22} (blocked-by) ({})", h.id, project);
2007 hit = true;
2008 }
2009 if h.parent() == Some(target_id) {
2010 let _ = writeln!(out, "{:<22} (parent) ({})", h.id, project);
2011 hit = true;
2012 }
2013 if crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(target_id) {
2014 let _ = writeln!(out, "{:<22} (discovered-from) ({})", h.id, project);
2015 hit = true;
2016 }
2017 if crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(target_id) {
2018 let _ = writeln!(out, "{:<22} (pivoted-to) ({})", h.id, project);
2019 hit = true;
2020 }
2021 if !hit && h.body.contains(target_id) {
2022 let _ = writeln!(out, "{:<22} (body mention) ({})", h.id, project);
2023 }
2024 }
2025 Ok(out)
2026}
2027
2028#[cfg(test)]
2029mod tests {
2030 use super::*;
2031
2032 #[test]
2033 fn dot_labels_escape_untrusted_issue_text() {
2034 assert_eq!(dot_quoted(r#"a "quoted" title"#), r#"a \"quoted\" title"#);
2035 assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
2038 assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
2039 }
2040}