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 list_in(&recs, project_filter, state_filter, ready_only)
97}
98
99pub fn list_in(
106 recs: &[IssueRec],
107 project_filter: Option<&str>,
108 state_filter: Option<&str>,
109 ready_only: bool,
110) -> Result<String> {
111 let rows = CatalogService::from_recs(recs).issues_rows(ListQuery {
112 project: project_filter.map(str::to_string),
113 state: state_filter.map(str::to_string),
114 ready: ready_only,
115 ..ListQuery::default()
116 })?;
117 Ok(format_issue_rows(recs, &rows))
118}
119
120fn format_issue_rows(recs: &[IssueRec], rows: &[IssueRow]) -> String {
121 let by_id: std::collections::HashMap<&str, &IssueRec> = recs
125 .iter()
126 .map(|rec| (rec.heading.id.as_str(), rec))
127 .collect();
128 let mut out = String::new();
129 for row in rows {
130 let suffix = by_id
131 .get(row.id.as_str())
132 .map(|r| claim_suffix(&r.heading))
133 .unwrap_or_default();
134 let _ = writeln!(
135 out,
136 "{:<22} {:<9} [#{}] {}{}",
137 row.id, row.state, row.priority, row.title, suffix
138 );
139 }
140 out
141}
142
143pub(crate) fn claim_suffix(h: &IssueHeading) -> String {
147 let Some(who) = h.claimed_by() else {
148 return String::new();
149 };
150 match h.claim_age_days(Local::now().date_naive()) {
151 Some(days) => format!(" (claimed {days}d by {who})"),
152 None => format!(" (claimed by {who})"),
153 }
154}
155
156pub fn ready(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
162 let recs = load_recs(layout)?;
163 ready_in(&recs, project_filter)
164}
165
166pub fn ready_in(recs: &[IssueRec], project_filter: Option<&str>) -> Result<String> {
172 let rows = CatalogService::from_recs(recs).ready(project_filter)?;
173 Ok(format_issue_rows(recs, &rows))
174}
175
176pub fn show(layout: &Layout, id: &str) -> Result<String> {
182 let (h, path, project) =
183 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
184 let mut out = String::new();
185 writeln!(out, "ID: {}", h.id)?;
186 writeln!(out, "Project: {project}")?;
187 writeln!(out, "Title: {}", h.title)?;
188 writeln!(out, "State: {}", h.state)?;
189 writeln!(out, "Priority: [#{}]", h.priority)?;
190 if let Some(who) = h.claimed_by() {
191 match h.claim_age_days(Local::now().date_naive()) {
192 Some(days) => writeln!(
193 out,
194 "Claimed: {who} since {} ({days}d)",
195 h.claimed_at().unwrap_or("?")
196 )?,
197 None => writeln!(out, "Claimed: {who}")?,
198 }
199 }
200 let settings = crate::org::tag_settings_from_preamble(
201 &IssueDoc::parse_file(&project, &path)
202 .map(|d| d.preamble)
203 .unwrap_or_default(),
204 );
205 let tags = settings.all_tags(&h.tags());
206 if !tags.is_empty() {
207 writeln!(out, "Tags: {}", tags.join(", "))?;
208 }
209 if h.properties.iter().any(|(k, _)| k != "ID") {
210 writeln!(out, "Properties:")?;
211 for (k, v) in &h.properties {
212 if k == "ID" {
213 continue;
214 }
215 writeln!(out, " {k}: {v}")?;
216 }
217 }
218 writeln!(
219 out,
220 "File: {}:{}-{}",
221 path.display(),
222 h.line_start,
223 h.line_end
224 )?;
225 writeln!(out)?;
226 let body = h.body.trim_end();
229 if body.is_empty() {
230 writeln!(out, "(no body; edit the range above to add one)")?;
231 } else {
232 writeln!(out, "Body:")?;
233 writeln!(out, "{body}")?;
234 }
235 Ok(out)
236}
237
238pub fn plan_consensus(layout: &Layout, id: &str) -> Result<String> {
246 let roll = crate::consensus::of_plan(layout, id)?;
247 let mut out = String::new();
248 writeln!(out, "{} {}", roll.plan, roll.title)?;
249 if roll.children.is_empty() {
250 writeln!(out, " no children: nothing to roll up")?;
251 return Ok(out);
252 }
253
254 let voted = roll.children.iter().filter(|c| c.ballots > 0).count();
255 writeln!(
256 out,
257 " {} child{}, {voted} with ballots",
258 roll.children.len(),
259 if roll.children.len() == 1 { "" } else { "ren" }
260 )?;
261 for child in &roll.children {
262 let held = match (&child.holds, child.settling) {
263 (Some((choice, share)), _) => format!("{choice} {share:.3}"),
264 (None, Some(crate::consensus::Settling::Split)) => "split".to_string(),
265 (None, Some(crate::consensus::Settling::Oscillating)) => "never settles".to_string(),
266 (None, Some(_)) => "no lead".to_string(),
267 (None, None) => "no ballots".to_string(),
268 };
269 writeln!(
270 out,
271 " {:<22} {:<9} {:<16} {}",
272 child.id, child.state, held, child.title
273 )?;
274 }
275
276 let positions = roll.positions();
277 match positions.len() {
278 0 => writeln!(out, " nothing holds a position yet")?,
279 1 => writeln!(
280 out,
281 " the children that were voted on all hold {}",
282 positions[0]
283 )?,
284 n => writeln!(
285 out,
286 " the children disagree with each other: {n} positions ({})",
287 positions.join(", ")
288 )?,
289 }
290 let split = roll.split();
291 if !split.is_empty() {
292 writeln!(
293 out,
294 " {} child(ren) settled split and need a person: {}",
295 split.len(),
296 split
297 .iter()
298 .map(|c| c.id.as_str())
299 .collect::<Vec<_>>()
300 .join(", ")
301 )?;
302 }
303 let unvoted = roll.unvoted();
304 if !unvoted.is_empty() {
305 writeln!(out, " {} child(ren) carry no ballots", unvoted.len())?;
309 }
310 Ok(out)
311}
312
313pub fn recall(layout: &Layout, id: &str, depth: usize, excerpts: bool) -> Result<String> {
322 let set = CatalogService::from_recs(&load_recs(layout)?).recall(id, depth, excerpts)?;
323 let mut out = String::new();
324 writeln!(
325 out,
326 "{:<22} {:<9} {} ({})",
327 set.id, set.state, set.title, set.project
328 )?;
329 if let Some(d) = &set.deadline {
330 writeln!(out, "DEADLINE {d}")?;
331 }
332 if let Some(s) = &set.scheduled {
333 writeln!(out, "SCHEDULED {s}")?;
334 }
335
336 if !set.plan.is_empty() {
337 writeln!(out, "\nPlan")?;
338 for step in &set.plan {
339 writeln!(out, " {:<22} {:<9} {}", step.id, step.state, step.title)?;
340 }
341 }
342
343 writeln!(out, "\nInputs")?;
344 if set.inputs.is_empty() {
345 writeln!(
346 out,
347 " (none declared: nothing blocks this and it was not bounced)"
348 )?;
349 }
350 for input in &set.inputs {
351 writeln!(
352 out,
353 " {:<22} {:<9} {} [{}]",
354 input.id, input.state, input.title, input.relation
355 )?;
356 if input.deeds.is_empty() {
357 writeln!(out, " (no deeds cited)")?;
361 }
362 for deed in &input.deeds {
363 writeln!(out, " {deed}")?;
364 }
365 if let Some(excerpt) = &input.excerpt {
366 for line in excerpt.lines() {
369 writeln!(out, " {line}")?;
370 }
371 }
372 if let Some(note) = &input.last_note {
373 writeln!(
376 out,
377 " note: {}",
378 note.lines().next().unwrap_or_default().trim()
379 )?;
380 }
381 }
382
383 writeln!(out, "\nProduced")?;
384 if set.produced.is_empty() {
385 writeln!(out, " (nothing cited yet)")?;
386 }
387 for deed in &set.produced {
388 writeln!(out, " {deed}")?;
389 }
390
391 writeln!(out, "\nBody")?;
392 if set.body.is_empty() {
393 writeln!(out, " (no body)")?;
394 } else {
395 for line in set.body.lines() {
396 writeln!(out, " {line}")?;
397 }
398 }
399 Ok(out)
400}
401
402pub fn recall_deeds(layout: &Layout, id: &str, depth: usize) -> Result<String> {
409 let set = CatalogService::from_recs(&load_recs(layout)?).recall(id, depth, false)?;
410 let mut out = String::new();
411 let mut seen: HashSet<&str> = HashSet::new();
415 for deed in set
416 .inputs
417 .iter()
418 .flat_map(|i| i.deeds.iter())
419 .chain(set.produced.iter())
420 {
421 if seen.insert(deed.as_str()) {
422 writeln!(out, "{deed}")?;
423 }
424 }
425 Ok(out)
426}
427
428pub fn consensus(layout: &Layout, id: &str) -> Result<String> {
436 consensus_with(layout, id, &[])
437}
438
439pub fn consensus_with(layout: &Layout, id: &str, rows: &[(String, String, f64)]) -> Result<String> {
445 consensus_anchored(layout, id, rows, &[])
446}
447
448pub fn consensus_anchored(
455 layout: &Layout,
456 id: &str,
457 rows: &[(String, String, f64)],
458 anchors: &[(String, f64)],
459) -> Result<String> {
460 let ballots = crate::ops::ballots(layout, id)?;
461 let outcome = crate::consensus::of_issue_anchored(layout, id, rows, anchors)?;
462 Ok(consensus_text(id, &ballots, &outcome))
463}
464
465fn consensus_text(
466 id: &str,
467 ballots: &[crate::ops::Ballot],
468 outcome: &crate::consensus::Outcome,
469) -> String {
470 use crate::consensus::{Settling, TrustSource};
471
472 if ballots.is_empty() {
473 return format!("{id}: no votes\n");
474 }
475 let mut out = format!(
476 "{id}: {} ballot{} over {} option{}, trust {}\n",
477 ballots.len(),
478 if ballots.len() == 1 { "" } else { "s" },
479 outcome.choices.len(),
480 if outcome.choices.len() == 1 { "" } else { "s" },
481 match outcome.trust {
482 TrustSource::Default => "default (equal weight)",
483 TrustSource::Configured => "configured",
484 }
485 );
486
487 let counts = crate::consensus::tally(ballots);
488 let mut ranked: Vec<(&String, &Vec<String>)> = counts.iter().collect();
489 ranked.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0)));
490 let _ = writeln!(out, " count");
491 for (choice, who) in &ranked {
492 let _ = writeln!(out, " {:<24} {} ({})", choice, who.len(), who.join(", "));
493 }
494
495 match outcome.settling {
496 Settling::Agreed => {
497 let consensus = outcome.consensus.as_ref().expect("agreed carries a limit");
498 let mut shares: Vec<(&str, f64)> = outcome
499 .choices
500 .iter()
501 .map(String::as_str)
502 .zip(consensus.iter().copied())
503 .collect();
504 shares.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(b.0)));
505 let _ = writeln!(
506 out,
507 " consensus after {} round(s){}",
508 outcome.rounds,
509 if outcome.budget_reached {
510 ", which is the whole budget: the shares are an estimate"
511 } else {
512 ""
513 }
514 );
515 for (choice, share) in &shares {
516 let _ = writeln!(out, " {choice:<24} {share:.3}");
517 }
518 let mut power: Vec<(&str, f64)> = outcome
519 .agents
520 .iter()
521 .map(|a| (a.agent.as_str(), a.power.unwrap_or_default()))
522 .collect();
523 power.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(b.0)));
524 let _ = writeln!(out, " social power");
525 for (agent, weight) in &power {
526 let _ = writeln!(out, " {agent:<24} {weight:.3}");
527 }
528 match outcome.leader() {
529 Some(_) if ballots.len() < 2 => {
532 let _ = writeln!(
533 out,
534 " one ballot only: {}, which nobody has agreed with yet",
535 ranked[0].0
536 );
537 }
538 Some((choice, share)) => {
539 let _ = writeln!(out, " holds: {choice} ({share:.3} of the group's weight)");
540 if ranked[0].0 != choice {
541 let _ = writeln!(
543 out,
544 " the count leads with {} and the group's weight does not",
545 ranked[0].0
546 );
547 }
548 }
549 None => {
550 let _ = writeln!(
551 out,
552 " no lead: the group's weight is split evenly across the options"
553 );
554 }
555 }
556 }
557 Settling::Split => {
558 let _ = writeln!(
559 out,
560 " no consensus: the trust graph holds {} group(s) that do not listen to each other",
561 outcome.factions.len()
562 );
563 for faction in &outcome.factions {
564 let held = faction
568 .first()
569 .and_then(|who| outcome.agents.iter().find(|a| a.agent == *who))
570 .and_then(|row| {
571 row.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 })
577 .unwrap_or_default();
578 let _ = writeln!(out, " {:<32} {held}", faction.join(", "));
579 }
580 }
581 Settling::Anchored => {
582 let uniform = outcome
585 .agents
586 .windows(2)
587 .all(|pair| (pair[0].susceptibility - pair[1].susceptibility).abs() < f64::EPSILON);
588 if uniform {
589 let _ = writeln!(
590 out,
591 " anchored after {} round(s), susceptibility {:.2}",
592 outcome.rounds,
593 outcome
594 .agents
595 .first()
596 .map_or(outcome.susceptibility, |a| a.susceptibility)
597 );
598 } else {
599 let _ = writeln!(out, " anchored after {} round(s)", outcome.rounds);
600 }
601 for row in &outcome.agents {
602 let held = row
603 .limit
604 .iter()
605 .enumerate()
606 .max_by(|a, b| a.1.total_cmp(b.1))
607 .map(|(at, share)| format!("{} {share:.3}", outcome.choices[at]))
608 .unwrap_or_default();
609 if uniform {
610 let _ = writeln!(out, " {:<24} {held}", row.agent);
611 } else {
612 let _ = writeln!(
613 out,
614 " {:<24} {held:<16} susceptibility {:.2}",
615 row.agent, row.susceptibility
616 );
617 }
618 }
619 let _ = writeln!(
620 out,
621 " spread {:.3}: what the group keeps disagreeing about after listening",
622 outcome.spread
623 );
624 let mut mean: Vec<(&str, f64)> = outcome
627 .choices
628 .iter()
629 .enumerate()
630 .map(|(at, choice)| {
631 let total: f64 = outcome.agents.iter().map(|a| a.limit[at]).sum();
632 (choice.as_str(), total / outcome.agents.len() as f64)
633 })
634 .collect();
635 mean.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(b.0)));
636 let _ = writeln!(out, " mean of those positions");
637 for (choice, share) in &mean {
638 let _ = writeln!(out, " {choice:<24} {share:.3}");
639 }
640 }
641 Settling::Oscillating => {
642 let _ = writeln!(
643 out,
644 " no consensus: {} rounds did not settle, which is a trust graph with no \
645 weight on its own opinions",
646 outcome.rounds
647 );
648 }
649 }
650 out
651}
652
653pub fn search(layout: &Layout, query: &str, limit: usize) -> Result<String> {
659 let recs = load_recs(layout)?;
660 let hits = CatalogService::from_recs(&recs).search(query, limit)?;
661 let mut out = String::new();
662 for h in hits {
663 let _ = writeln!(
664 out,
665 "{:<22} {:<9} [#{}] {} ({})",
666 h.id, h.state, h.priority, h.title, h.project
667 );
668 }
669 Ok(out)
670}
671
672pub fn children(layout: &Layout, parent_id: &str) -> Result<String> {
678 let mut rows: Vec<(String, IssueHeading)> = load_all(layout)?
679 .into_iter()
680 .filter(|(_, h)| h.parent() == Some(parent_id))
681 .collect();
682 rows.sort_by(|a, b| {
683 a.1.priority
684 .cmp(&b.1.priority)
685 .then_with(|| a.1.state.cmp(&b.1.state))
686 .then_with(|| a.1.id.cmp(&b.1.id))
687 });
688 let mut out = String::new();
689 for (project, h) in rows {
690 let _ = writeln!(
691 out,
692 "{:<22} {:<9} [#{}] {} ({})",
693 h.id, h.state, h.priority, h.title, project
694 );
695 }
696 Ok(out)
697}
698
699pub fn stale(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
706 let today = Local::now().date_naive();
707 let cutoff = today - chrono::Duration::days(days);
708 let mut rows: Vec<(String, IssueHeading, NaiveDate)> = Vec::new();
709 for (project, h) in load_all(layout)? {
710 if !project_selected(&project, project_filter) {
711 continue;
712 }
713 if !READY_STATES.contains(&h.state.as_str()) {
714 continue;
715 }
716 let Some(created) = h.properties.get("CREATED") else {
717 continue;
718 };
719 let Some(parsed) = parse_org_date(created) else {
720 continue;
721 };
722 if parsed <= cutoff {
723 rows.push((project, h, parsed));
724 }
725 }
726 rows.sort_by_key(|r| r.2);
727 let mut out = String::new();
728 for (project, h, created) in rows {
729 let age = (today - created).num_days();
730 let _ = writeln!(
731 out,
732 "{:<22} {:<9} [#{}] {} ({}d, {})",
733 h.id, h.state, h.priority, h.title, age, project
734 );
735 }
736 Ok(out)
737}
738
739pub fn claims(
748 layout: &Layout,
749 holder_filter: Option<&str>,
750 project_filter: Option<&str>,
751 json: bool,
752) -> Result<String> {
753 let recs = load_recs(layout)?;
754 let rows = CatalogService::from_recs(&recs).claims(holder_filter, project_filter)?;
755
756 if json {
757 return Ok(format!("{}\n", serde_json::to_value(&rows)?));
758 }
759
760 let mut out = String::new();
761 for row in &rows {
762 let age_txt = if row.age_days < 0 {
763 "?d".to_string()
764 } else {
765 format!("{}d", row.age_days)
766 };
767 let _ = writeln!(
768 out,
769 "{:<22} {:<9} [#{}] {:>4} {} {} ({})",
770 row.id,
771 row.state,
772 row.priority,
773 age_txt,
774 row.holder.as_deref().unwrap_or("?"),
775 row.title,
776 row.project
777 );
778 }
779 if rows.is_empty() {
780 out.push_str("no live claims\n");
781 }
782 Ok(out)
783}
784
785pub fn agenda(layout: &Layout, days: i64, project_filter: Option<&str>) -> Result<String> {
792 let recs = load_recs(layout)?;
793 agenda_in(&recs, days, project_filter)
794}
795
796pub fn agenda_in(recs: &[IssueRec], days: i64, project_filter: Option<&str>) -> Result<String> {
802 let today = Local::now().date_naive();
803 let rows = crate::catalog::agenda_rows_from(recs, days, project_filter)?;
804
805 let mut out = String::new();
806 let mut last_kind: Option<&str> = None;
807 for row in &rows {
808 if last_kind != Some(row.kind.as_str()) {
809 let _ = writeln!(out, "{}", row.kind);
810 last_kind = Some(row.kind.as_str());
811 }
812 let date = chrono::NaiveDate::parse_from_str(&row.date, "%Y-%m-%d").ok();
813 let when = match date.map(|d| (d - today).num_days()) {
814 Some(d) if d < 0 => format!("{}d overdue", -d),
815 Some(0) => "today".to_string(),
816 Some(d) => format!("in {d}d"),
817 None => String::new(),
818 };
819 let label = if row.kind == "appointment" {
820 "on"
821 } else {
822 row.kind.as_str()
823 };
824 let _ = writeln!(
825 out,
826 "{} {label:<9} {when:<11} {:<22} {:<9} [#{}] {} ({})",
827 row.date, row.id, row.state, row.priority, row.title, row.project
828 );
829 }
830 if out.is_empty() {
831 out.push_str("nothing dated in range\n");
832 }
833 Ok(out)
834}
835
836pub(crate) fn parse_org_date(s: &str) -> Option<NaiveDate> {
837 let inner = s
838 .trim_start_matches(['<', '['])
839 .trim_end_matches(['>', ']']);
840 let token = inner.split_whitespace().next()?;
841 NaiveDate::parse_from_str(token, "%Y-%m-%d").ok()
842}
843
844pub fn count(
850 layout: &Layout,
851 project_filter: Option<&str>,
852 state_filter: Option<&str>,
853 ready_only: bool,
854) -> Result<String> {
855 let recs = load_recs(layout)?;
856 count_in(&recs, project_filter, state_filter, ready_only)
857}
858
859pub fn count_in(
865 recs: &[IssueRec],
866 project_filter: Option<&str>,
867 state_filter: Option<&str>,
868 ready_only: bool,
869) -> Result<String> {
870 let active_blockers: HashSet<&str> = if ready_only {
871 recs.iter()
872 .map(|r| &r.heading)
873 .filter(|h| h.state != "DONE" && h.state != "CANCELLED")
874 .map(|h| h.id.as_str())
875 .collect()
876 } else {
877 HashSet::new()
878 };
879 let n = recs
880 .iter()
881 .map(|r| (r.project.as_str(), &r.heading))
882 .filter(|(project, h)| {
883 if !project_selected(project, project_filter) {
884 return false;
885 }
886 if let Some(s) = state_filter
887 && h.state != s
888 {
889 return false;
890 }
891 if ready_only {
892 if !READY_STATES.contains(&h.state.as_str()) {
893 return false;
894 }
895 if blocker_ids(h).iter().any(|b| active_blockers.contains(b)) {
896 return false;
897 }
898 }
899 true
900 })
901 .count();
902 Ok(format!("{n}\n"))
903}
904
905pub fn export(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
912 let mut out = String::new();
913 for rec in load_recs(layout)? {
914 if !project_selected(&rec.project, project_filter) {
915 continue;
916 }
917 let _ = writeln!(
918 out,
919 "{}",
920 export_row(&rec.project, rec.heading, &rec.tag_settings)
921 );
922 }
923 Ok(out)
924}
925
926pub fn export_by_project(layout: &Layout) -> Result<BTreeMap<String, String>> {
933 let mut out: BTreeMap<String, String> = BTreeMap::new();
934 for rec in load_recs(layout)? {
935 let row = export_row(&rec.project, rec.heading, &rec.tag_settings);
936 let _ = writeln!(out.entry(rec.project).or_default(), "{row}");
937 }
938 Ok(out)
939}
940
941fn export_row(
942 project: &str,
943 h: IssueHeading,
944 settings: &crate::org::TagSettings,
945) -> serde_json::Value {
946 let logbook: Vec<serde_json::Value> = h
947 .logbook
948 .iter()
949 .map(|e| {
950 let mut row = serde_json::json!({
951 "timestamp": e.timestamp,
952 "from": e.from_state,
953 "to": e.to_state,
954 "note": e.note,
955 });
956 if let Some(raw) = &e.raw {
957 row["raw"] = serde_json::Value::String(raw.clone());
958 }
959 row
960 })
961 .collect();
962 serde_json::json!({
963 "id": h.id,
964 "project": project,
965 "title": h.title,
966 "state": h.state,
967 "priority": h.priority.to_string(),
968 "properties": h.properties,
969 "deeds": h.deeds(),
971 "org_tags": h.org_tags,
972 "tags": h.tags(),
973 "all_tags": settings.all_tags(&h.tags()),
974 "logbook": logbook,
975 "body": h.body,
976 "line_start": h.line_start,
977 "line_end": h.line_end,
978 })
979}
980
981pub fn tree(layout: &Layout, root_id: &str, format: &str) -> Result<String> {
988 let all = load_all(layout)?;
989 let graph = GraphIndex::new(&all);
990 let Some(root_heading) = graph.by_id.get(root_id) else {
991 return Err(Error::IssueNotFound {
992 id: root_id.to_string(),
993 });
994 };
995 let mut out = String::new();
996 let root = root_heading.id.as_str();
997 match format {
998 "ascii" | "text" => tree_ascii(&graph, root, 0, &mut HashSet::new(), &mut out),
999 "dot" => tree_dot(&graph, root, &mut out),
1000 _ => return Err(anyhow!("unknown format {format:?}; allowed: ascii, dot").into()),
1001 }
1002 Ok(out)
1003}
1004
1005fn tree_ascii<'a>(
1006 graph: &GraphIndex<'a>,
1007 id: &'a str,
1008 depth: usize,
1009 seen: &mut HashSet<&'a str>,
1010 out: &mut String,
1011) {
1012 if !seen.insert(id) {
1013 let _ = writeln!(out, "{}{id} (cycle, stopping)", " ".repeat(depth));
1014 return;
1015 }
1016 let Some(h) = graph.by_id.get(id) else {
1017 let _ = writeln!(out, "{}{id} (missing)", " ".repeat(depth));
1018 return;
1019 };
1020 let _ = writeln!(
1021 out,
1022 "{}{id} {:<9} [#{}] {}",
1023 " ".repeat(depth),
1024 h.state,
1025 h.priority,
1026 h.title
1027 );
1028 if let Some(blockers) = graph.blockers.get(id) {
1029 for blocker in blockers {
1030 let _ = writeln!(out, "{}* blocked-by {blocker}", " ".repeat(depth + 1));
1031 }
1032 }
1033 if let Some(kids) = graph.children.get(id) {
1034 for k in kids {
1035 tree_ascii(graph, k, depth + 1, seen, out);
1036 }
1037 }
1038}
1039
1040pub(crate) fn dot_quoted(text: &str) -> String {
1045 text.replace('\\', "\\\\")
1046 .replace('"', "\\\"")
1047 .replace('\n', "\\n")
1048 .replace('\r', "")
1049}
1050
1051fn tree_dot<'a>(graph: &GraphIndex<'a>, root_id: &str, out: &mut String) {
1052 let _ = writeln!(out, "digraph vissue_tree {{");
1053 let _ = writeln!(out, " rankdir=LR;");
1054 let _ = writeln!(
1055 out,
1056 " node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
1057 );
1058 let mut visited: HashSet<&str> = HashSet::new();
1059 let mut stack = vec![graph.by_id.get(root_id).unwrap().id.as_str()];
1060 while let Some(id) = stack.pop() {
1061 if !visited.insert(id) {
1062 continue;
1063 }
1064 if let Some(h) = graph.by_id.get(id) {
1065 let _ = writeln!(
1066 out,
1067 " \"{}\" [label=\"{}\\n{} [#{}]\"];",
1068 dot_quoted(&h.id),
1069 dot_quoted(&h.title),
1070 dot_quoted(&h.state),
1071 dot_quoted(&h.priority.to_string())
1072 );
1073 if let Some(kids) = graph.children.get(id) {
1074 for k in kids {
1075 let _ = writeln!(
1076 out,
1077 " \"{}\" -> \"{}\" [color=\"#00897B\"];",
1078 dot_quoted(&h.id),
1079 dot_quoted(k)
1080 );
1081 stack.push(k);
1082 }
1083 }
1084 if let Some(blockers) = graph.blockers.get(id) {
1085 for b in blockers {
1086 let _ = writeln!(
1087 out,
1088 " \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
1089 dot_quoted(b),
1090 dot_quoted(&h.id)
1091 );
1092 stack.push(b);
1093 }
1094 }
1095 }
1096 }
1097 let _ = writeln!(out, "}}");
1098}
1099
1100pub fn cycles(layout: &Layout) -> Result<String> {
1106 let all = load_all(layout)?;
1107 let graph = GraphIndex::new(&all);
1108
1109 const WHITE: u8 = 0;
1113 const GREY: u8 = 1;
1114 const BLACK: u8 = 2;
1115 let mut color: HashMap<&str, u8> = HashMap::new();
1116 let mut found: Vec<Vec<String>> = Vec::new();
1117
1118 fn dfs<'a>(
1119 id: &'a str,
1120 graph: &GraphIndex<'a>,
1121 color: &mut HashMap<&'a str, u8>,
1122 path: &mut Vec<&'a str>,
1123 found: &mut Vec<Vec<String>>,
1124 ) {
1125 color.insert(id, GREY);
1126 path.push(id);
1127 if let Some(blockers) = graph.blockers.get(id) {
1128 for b in blockers {
1129 if !graph.by_id.contains_key(b) {
1130 continue; }
1132 match color.get(b).copied().unwrap_or(WHITE) {
1133 GREY => {
1134 let start = path.iter().position(|&x| x == *b).unwrap();
1135 let mut cycle: Vec<String> =
1136 path[start..].iter().map(|s| s.to_string()).collect();
1137 let min = cycle
1140 .iter()
1141 .enumerate()
1142 .min_by(|a, b| a.1.cmp(b.1))
1143 .map(|(i, _)| i)
1144 .unwrap();
1145 cycle.rotate_left(min);
1146 cycle.push(cycle[0].clone());
1147 if !found.contains(&cycle) {
1148 found.push(cycle);
1149 }
1150 }
1151 WHITE => dfs(b, graph, color, path, found),
1152 _ => {}
1153 }
1154 }
1155 }
1156 path.pop();
1157 color.insert(id, BLACK);
1158 }
1159
1160 for (_, start) in &all {
1161 if color.get(start.id.as_str()).copied().unwrap_or(WHITE) == WHITE {
1162 let mut path = Vec::new();
1163 dfs(start.id.as_str(), &graph, &mut color, &mut path, &mut found);
1164 }
1165 }
1166
1167 let mut out = String::new();
1168 if found.is_empty() {
1169 let _ = writeln!(out, "no cycles");
1170 } else {
1171 for cycle in found {
1172 let _ = writeln!(out, "{}", cycle.join(" -> "));
1173 }
1174 }
1175 Ok(out)
1176}
1177
1178pub fn ancestors(layout: &Layout, id: &str, depth: usize) -> Result<String> {
1185 let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
1186 let mut out = String::new();
1187 for (distance, ancestor) in graph.ancestors(id, depth)? {
1188 writeln!(out, "{distance} {ancestor}")?;
1189 }
1190 Ok(out)
1191}
1192
1193pub fn impact(layout: &Layout, id: &str, depth: usize) -> Result<String> {
1200 let graph = DependencyGraph::from_issues(&load_all(layout)?)?;
1201 let mut out = String::new();
1202 for (distance, descendant) in graph.descendants(id, depth)? {
1203 writeln!(out, "{distance} {descendant}")?;
1204 }
1205 Ok(out)
1206}
1207
1208pub const GRAPH_HEADER: &str = concat!(
1218 "digraph vissue_graph {\n",
1219 " rankdir=LR;\n",
1220 " node [shape=box, fontname=\"Jost\", style=filled];\n",
1221 " edge [fontname=\"Jost\"];\n"
1222);
1223
1224pub const GRAPH_FOOTER: &str = "}\n";
1226
1227pub fn graph(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1233 Ok(format!(
1234 "{GRAPH_HEADER}{}{GRAPH_FOOTER}",
1235 graph_body(layout, project_filter)?
1236 ))
1237}
1238
1239pub fn graph_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1245 let all = load_all(layout)?;
1246 let graph = GraphIndex::new(&all);
1247 let mut out = String::new();
1248 for (project, h) in &all {
1249 if !project_selected(project, project_filter) {
1250 continue;
1251 }
1252 let fill = match h.state.as_str() {
1253 "DONE" => "#A5D6A7",
1254 "CANCELLED" => "#CFD8DC",
1255 "BLOCKED" => "#FFCC80",
1256 "STARTED" => "#80CBC4",
1257 _ => "#E0F2F1",
1258 };
1259 let _ = writeln!(
1260 out,
1261 " \"{}\" [label=\"{}\\n{} [#{}]\", fillcolor=\"{}\"];",
1262 dot_quoted(&h.id),
1263 dot_quoted(&h.title),
1264 dot_quoted(&h.state),
1265 dot_quoted(&h.priority.to_string()),
1266 fill
1267 );
1268 }
1269 for (project, h) in &all {
1270 if !project_selected(project, project_filter) {
1271 continue;
1272 }
1273 if let Some(blockers) = graph.blockers.get(h.id.as_str()) {
1274 for b in blockers {
1275 writeln!(
1276 out,
1277 " \"{}\" -> \"{}\" [color=\"#FF7043\"];",
1278 dot_quoted(b),
1279 dot_quoted(&h.id)
1280 )?;
1281 }
1282 }
1283 if let Some(parent) = h.parent() {
1284 writeln!(
1285 out,
1286 " \"{}\" -> \"{}\" [color=\"#00897B\", style=dashed];",
1287 dot_quoted(parent),
1288 dot_quoted(&h.id)
1289 )?;
1290 }
1291 }
1292 Ok(out)
1293}
1294
1295pub const ROADMAP_HEADER: &str = concat!(
1306 "# Roadmap\n\n",
1307 "Generated from `vissue roadmap`. Source of truth lives in the per-project issues.org files.\n\n"
1308);
1309
1310pub fn roadmap(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1316 Ok(format!(
1317 "{ROADMAP_HEADER}{}",
1318 roadmap_body(layout, project_filter)?
1319 ))
1320}
1321
1322pub fn roadmap_body(layout: &Layout, project_filter: Option<&str>) -> Result<String> {
1328 let all = load_all(layout)?;
1329 let mut by_project: BTreeMap<String, Vec<&IssueHeading>> = BTreeMap::new();
1330 for (project, h) in &all {
1331 if !project_selected(project, project_filter) {
1332 continue;
1333 }
1334 by_project.entry(project.clone()).or_default().push(h);
1335 }
1336 let mut out = String::new();
1337 for (project, mut headings) in by_project {
1338 headings.sort_by(|a, b| {
1339 a.priority
1340 .cmp(&b.priority)
1341 .then_with(|| a.state.cmp(&b.state))
1342 .then_with(|| a.id.cmp(&b.id))
1343 });
1344 let buckets = ["STARTED", "TODO", "BLOCKED"];
1345 let active: Vec<&&IssueHeading> = headings
1346 .iter()
1347 .filter(|h| buckets.contains(&h.state.as_str()))
1348 .collect();
1349 let closed: Vec<&&IssueHeading> = headings
1350 .iter()
1351 .filter(|h| h.state == "DONE" || h.state == "CANCELLED")
1352 .collect();
1353 if active.is_empty() && closed.is_empty() {
1354 continue;
1355 }
1356 writeln!(out, "## {project}")?;
1357 writeln!(out)?;
1358 for state in buckets {
1359 let in_state: Vec<&&IssueHeading> = active
1360 .iter()
1361 .copied()
1362 .filter(|h| h.state == state)
1363 .collect();
1364 if in_state.is_empty() {
1365 continue;
1366 }
1367 writeln!(out, "### {state}")?;
1368 writeln!(out)?;
1369 for h in in_state {
1370 let deadline = h
1371 .deadline()
1372 .map(|d| format!(" :: deadline {d}"))
1373 .unwrap_or_default();
1374 let blockers = blocker_ids(h);
1375 let blocked_by = if blockers.is_empty() {
1376 String::new()
1377 } else {
1378 format!(" :: blocked by {}", blockers.join(", "))
1379 };
1380 writeln!(
1381 out,
1382 "- **{}** [#{}] {}{}{}",
1383 h.id, h.priority, h.title, deadline, blocked_by
1384 )?;
1385 }
1386 writeln!(out)?;
1387 }
1388 if !closed.is_empty() {
1389 writeln!(out, "### Closed ({} items)", closed.len())?;
1390 writeln!(out)?;
1391 for h in closed.iter().take(10) {
1392 writeln!(
1393 out,
1394 "- {} [#{}] {} ({})",
1395 h.id, h.priority, h.title, h.state
1396 )?;
1397 }
1398 if closed.len() > 10 {
1399 writeln!(out, "- ... and {} more", closed.len() - 10)?;
1400 }
1401 writeln!(out)?;
1402 }
1403 }
1404 Ok(out)
1405}
1406
1407fn looks_like_reject_prose(body: &str) -> bool {
1420 let lower = body.to_ascii_lowercase();
1421 const CLOSING: &[&str] = &[
1422 "vissue reject",
1423 "superseded by",
1424 "rejected in favour",
1425 "rejected in favor",
1426 "rejected as a duplicate",
1427 "closed as a duplicate",
1428 "closed as duplicate",
1429 "not doing this",
1430 "rejected this",
1431 "rejected: ",
1432 ];
1433 if CLOSING.iter().any(|phrase| lower.contains(phrase)) {
1434 return true;
1435 }
1436 lower.lines().any(|line| {
1445 line.starts_with('*')
1446 && (line.contains("rejected")
1447 || line.contains("superseded")
1448 || line.contains("reject:"))
1449 })
1450}
1451
1452fn claims_discovery_or_pivot(body: &str, linked: &str) -> bool {
1463 const CLAIMS: &[&str] = &[
1464 "discovered from",
1465 "discovered while",
1466 "discovered during",
1467 "found while",
1468 "filed from",
1469 "split from",
1470 "pivoted to",
1471 "pivots to",
1472 "pivoted from",
1473 "replaced by",
1474 "moved to",
1475 ];
1476 let needle = format!("id:{linked}");
1477 let lower = body.to_ascii_lowercase();
1478 let lower_needle = needle.to_ascii_lowercase();
1479 let window = 240;
1483 let mut from = 0;
1484 while let Some(at) = lower[from..].find(&lower_needle) {
1485 let hit = from + at;
1486 let start = hit.saturating_sub(window);
1487 let end = (hit + lower_needle.len() + window).min(lower.len());
1488 let near = &lower[floor_char_boundary(&lower, start)..ceil_char_boundary(&lower, end)];
1489 if CLAIMS.iter().any(|phrase| near.contains(phrase)) {
1490 return true;
1491 }
1492 from = hit + lower_needle.len();
1493 }
1494 false
1495}
1496
1497fn floor_char_boundary(s: &str, mut i: usize) -> usize {
1498 while i > 0 && !s.is_char_boundary(i) {
1499 i -= 1;
1500 }
1501 i
1502}
1503
1504fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
1505 while i < s.len() && !s.is_char_boundary(i) {
1506 i += 1;
1507 }
1508 i
1509}
1510
1511fn edge_connects(all: &[(String, IssueHeading)], a: &str, b: &str) -> bool {
1520 all.iter().any(|(_, h)| {
1521 let far = if h.id == a {
1522 b
1523 } else if h.id == b {
1524 a
1525 } else {
1526 return false;
1527 };
1528 [
1529 crate::props::DISCOVERED_FROM,
1530 crate::props::PIVOTED_TO,
1531 crate::props::PARENT,
1532 crate::props::BLOCKED_BY,
1533 crate::props::EDNA_BLOCKER,
1534 ]
1535 .iter()
1536 .any(|key| {
1537 crate::props::get(&h.properties, key)
1538 .is_some_and(|value| value.split(&[',', ' '][..]).any(|part| part.trim() == far))
1539 })
1540 })
1541}
1542
1543#[derive(Debug, Clone)]
1545pub struct CheckReport {
1546 pub text: String,
1548 pub errors: usize,
1550 pub warnings: usize,
1552}
1553
1554#[derive(Default)]
1561struct Findings {
1562 text: String,
1563 errors: usize,
1564 warnings: usize,
1565}
1566
1567impl Findings {
1568 fn err(&mut self, what: std::fmt::Arguments) {
1570 let _ = writeln!(self.text, "[err] {what}");
1571 self.errors += 1;
1572 }
1573
1574 fn warn(&mut self, what: std::fmt::Arguments) {
1576 let _ = writeln!(self.text, "[warn] {what}");
1577 self.warnings += 1;
1578 }
1579}
1580
1581pub fn check(layout: &Layout) -> Result<CheckReport> {
1588 let all = load_all(layout)?;
1589
1590 let issue_ids: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1595 let unresolved: HashSet<String> = all
1596 .iter()
1597 .filter_map(|(_, h)| h.parent())
1598 .filter(|p| !issue_ids.contains(p))
1599 .map(str::to_string)
1600 .collect();
1601 let elsewhere = find_org_ids(layout, &unresolved)?;
1602 let resolves = |id: &str| issue_ids.contains(id) || elsewhere.contains(id);
1603
1604 let mut f = Findings::default();
1605
1606 let mut by_id: HashMap<String, (String, &IssueHeading)> = HashMap::new();
1607 for (project, h) in &all {
1608 if let Some(prev) = by_id.insert(h.id.clone(), (project.clone(), h)) {
1609 f.err(format_args!(
1612 "duplicate id: {} appears in {} and {}",
1613 h.id, prev.0, project
1614 ));
1615 }
1616 }
1617
1618 for project in list_projects(layout)? {
1619 check_project(&project, layout, &mut f)?;
1620 }
1621
1622 for (project, h) in &all {
1623 check_issue(project, h, &resolves, &by_id, &mut f);
1624 }
1625
1626 let known: HashSet<&str> = all.iter().map(|(_, h)| h.id.as_str()).collect();
1627 for (project, h) in &all {
1628 check_provenance_links(&all, project, h, &known, &mut f);
1629 }
1630
1631 let mut settled: HashSet<&str> = HashSet::new();
1636 for (_, h) in &all {
1637 check_parent_cycle(h, &by_id, &mut settled, &mut f);
1638 }
1639
1640 if f.errors == 0
1641 && let Err(err) = DependencyGraph::from_issues(&all)
1642 {
1643 f.err(format_args!("blocker graph: {err}"));
1644 }
1645
1646 let _ = writeln!(f.text);
1647 let projects = list_projects(layout)?.len();
1648 let _ = writeln!(
1649 f.text,
1650 "checked {} issue(s) across {projects} project(s): {} error(s), {} warning(s)",
1651 all.len(),
1652 f.errors,
1653 f.warnings
1654 );
1655 Ok(CheckReport {
1656 text: f.text,
1657 errors: f.errors,
1658 warnings: f.warnings,
1659 })
1660}
1661
1662fn check_project(project: &str, layout: &Layout, f: &mut Findings) -> Result<()> {
1672 let path = layout.project_issues_path(project);
1673 let doc = IssueDoc::parse_file(project, &path)?;
1674 check_preamble(project, &doc, &path, f);
1675 let gcal_ids = crate::store::org_ids(&std::fs::read_to_string(&path)?)
1680 .filter(|id| crate::org::is_gcal_event_id(id))
1681 .count();
1682 if gcal_ids > 0 {
1683 f.err(format_args!(
1684 "{project}: {gcal_ids} heading(s) use an org-gcal event id as :ID:"
1685 ));
1686 }
1687 check_headings(project, &doc, f);
1688 Ok(())
1689}
1690
1691fn check_preamble(project: &str, doc: &IssueDoc, path: &std::path::Path, f: &mut Findings) {
1697 match crate::org::protocol_from_preamble(&doc.preamble) {
1698 None => {
1699 f.warn(format_args!(
1700 "{project}: preamble has no #+VISSUE: protocol stamp"
1701 ));
1702 }
1703 Some(n) if n < crate::org::PROTOCOL_VERSION => {
1704 f.warn(format_args!(
1705 "{project}: #+VISSUE: {n} is behind protocol {}",
1706 crate::org::PROTOCOL_VERSION
1707 ));
1708 }
1709 Some(n) if n > crate::org::PROTOCOL_VERSION => {
1710 f.err(format_args!(
1711 "{project}: #+VISSUE: {n} is newer than this vissue (protocol {})",
1712 crate::org::PROTOCOL_VERSION
1713 ));
1714 }
1715 Some(_) => {}
1716 }
1717 if !crate::org::preamble_has_keyword(&doc.preamble, "CATEGORY") {
1718 f.warn(format_args!(
1719 "{project}: preamble has no #+CATEGORY: (org-agenda labels every row \"issues\")"
1720 ));
1721 }
1722 if !crate::org::preamble_has_keyword(&doc.preamble, "FILETAGS") {
1723 f.warn(format_args!("{project}: preamble has no #+FILETAGS:"));
1724 } else if !doc
1725 .tag_settings
1726 .filetags
1727 .iter()
1728 .any(|t| t.eq_ignore_ascii_case("noexport"))
1729 {
1730 f.warn(format_args!(
1731 "{project}: #+FILETAGS: has no noexport; a vault publish will export this tracker"
1732 ));
1733 }
1734 if !crate::org::preamble_has_keyword(&doc.preamble, "TAGS") {
1735 f.warn(format_args!(
1736 "{project}: preamble has no #+TAGS:; Emacs fast tag selection has no type group"
1737 ));
1738 }
1739 if !crate::org::preamble_has_keyword(
1740 &crate::org::merge_setupfile_settings(&doc.preamble, path.parent()),
1741 "PRIORITIES",
1742 ) {
1743 f.warn(format_args!(
1744 "{project}: preamble has no #+PRIORITIES:; cookies default to C and the range is A..C"
1745 ));
1746 }
1747}
1748
1749fn check_headings(project: &str, doc: &IssueDoc, f: &mut Findings) {
1754 let spec = doc.priority_spec();
1755 let mut type_not_tagged = 0usize;
1756 let mut exclusive_clash = 0usize;
1757 let mut priority_out_of_range = 0usize;
1758 let mut ordered_skip = 0usize;
1759 let mut done_with_open_children = 0usize;
1760 let mut priority_in_drawer = 0usize;
1761 let mut blockedby_typo = 0usize;
1762 let mut blocker_as_ids = 0usize;
1763 let mut computed_specials = 0usize;
1764 let mut bad_effort = 0usize;
1765 for h in &doc.headings {
1766 if let Some(kind) = crate::props::get(&h.properties, crate::props::TYPE) {
1767 let kind = kind.trim();
1768 if !kind.is_empty()
1769 && kind.chars().all(crate::model::is_org_tag_char)
1770 && !h.org_tags.iter().any(|t| t == kind)
1771 {
1772 type_not_tagged += 1;
1773 }
1774 }
1775 for group in &doc.tag_settings.exclusive {
1776 let hits = group
1777 .iter()
1778 .filter(|name| h.org_tags.iter().any(|t| t == *name))
1779 .count();
1780 if hits > 1 {
1781 exclusive_clash += 1;
1782 break;
1783 }
1784 }
1785 if !spec.contains(h.priority) {
1786 priority_out_of_range += 1;
1787 }
1788 if h.properties.contains_key("PRIORITY") {
1789 priority_in_drawer += 1;
1790 }
1791 if h.properties.contains_key("BLOCKEDBY") {
1792 blockedby_typo += 1;
1793 }
1794 if let Some(raw) = h.properties.get("BLOCKER")
1795 && !crate::org::is_edna_blocker(raw)
1796 {
1797 blocker_as_ids += 1;
1798 }
1799 if crate::org::COMPUTED_SPECIALS
1800 .iter()
1801 .any(|k| *k != "PRIORITY" && h.properties.contains_key(*k))
1802 {
1803 computed_specials += 1;
1804 }
1805 if let Some(effort) = h.effort()
1806 && !crate::org::is_org_effort(effort)
1807 {
1808 bad_effort += 1;
1809 }
1810 if let Some(pid) = h.parent()
1811 && let Some(parent) = doc.headings.iter().find(|p| p.id == pid)
1812 && crate::org::org_property_is_set(&parent.properties, "ORDERED")
1813 && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1814 {
1815 let earlier_open = doc.headings.iter().any(|sib| {
1816 sib.parent() == Some(pid)
1817 && sib.line_start < h.line_start
1818 && sib.state != "DONE"
1819 && sib.state != "CANCELLED"
1820 });
1821 if earlier_open && (h.state == "STARTED" || h.state == "DONE") {
1822 ordered_skip += 1;
1823 }
1824 }
1825 if h.state == "DONE"
1826 && !crate::org::org_property_is_set(&h.properties, "NOBLOCKING")
1827 && doc.headings.iter().any(|c| {
1828 c.parent() == Some(h.id.as_str()) && c.state != "DONE" && c.state != "CANCELLED"
1829 })
1830 {
1831 done_with_open_children += 1;
1832 }
1833 }
1834 if type_not_tagged > 0 {
1835 f.warn(format_args!("{project}: {type_not_tagged} heading(s) have :TYPE: that is a legal Org tag but is not on the heading"));
1836 }
1837 if exclusive_clash > 0 {
1838 f.warn(format_args!("{project}: {exclusive_clash} heading(s) carry more than one tag from a #+TAGS: exclusive group"));
1839 }
1840 if priority_in_drawer > 0 {
1841 f.warn(format_args!("{project}: {priority_in_drawer} heading(s) put :PRIORITY: in the drawer; Org reads the [#A] cookie"));
1842 }
1843 if blockedby_typo > 0 {
1844 f.warn(format_args!(
1845 "{project}: {blockedby_typo} heading(s) use :BLOCKEDBY: instead of :BLOCKED_BY:"
1846 ));
1847 }
1848 if blocker_as_ids > 0 {
1849 f.warn(format_args!("{project}: {blocker_as_ids} heading(s) use :BLOCKER: as a bare id list; a rewrite folds them into :BLOCKED_BY:"));
1850 }
1851 if computed_specials > 0 {
1852 f.warn(format_args!("{project}: {computed_specials} heading(s) set a computed Org special (TODO/ITEM/TAGS/...) in the drawer; Org ignores it"));
1853 }
1854 if bad_effort > 0 {
1855 f.warn(format_args!(
1856 "{project}: {bad_effort} heading(s) have an Effort value Org will not parse"
1857 ));
1858 }
1859 if priority_out_of_range > 0 {
1860 f.warn(format_args!(
1861 "{project}: {priority_out_of_range} heading(s) have a [#prio] outside #+PRIORITIES:"
1862 ));
1863 }
1864 if ordered_skip > 0 {
1865 f.warn(format_args!("{project}: {ordered_skip} heading(s) started or closed before an earlier ORDERED sibling"));
1866 }
1867 if done_with_open_children > 0 {
1868 f.warn(format_args!("{project}: {done_with_open_children} DONE heading(s) still have open children (Org ORDERED / todo-dependencies)"));
1869 }
1870}
1871
1872fn check_issue<'a>(
1875 project: &str,
1876 h: &'a IssueHeading,
1877 resolves: &impl Fn(&str) -> bool,
1878 by_id: &HashMap<String, (String, &'a IssueHeading)>,
1879 f: &mut Findings,
1880) {
1881 if let Some(parent) = h.parent()
1882 && !resolves(parent)
1883 {
1884 f.err(format_args!(
1885 "{} (in {}) :PARENT: {} -> not found",
1886 h.id, project, parent
1887 ));
1888 }
1889 for blk in blocker_ids(h) {
1890 if !by_id.contains_key(blk) {
1891 f.err(format_args!(
1892 "{} (in {}) :BLOCKED_BY: {} -> not found",
1893 h.id, project, blk
1894 ));
1895 }
1896 }
1897 for cited in h.deeds() {
1901 if !crate::ops::is_deed_accession(&cited) {
1902 f.warn(format_args!(
1903 "{} (in {}) :DEEDS: {} -> not a deed accession",
1904 h.id, project, cited
1905 ));
1906 }
1907 }
1908 if let Some(d) = h.deadline()
1909 && parse_org_date(d).is_none()
1910 {
1911 f.err(format_args!(
1912 "{} (in {}) :DEADLINE: {} -> unparseable",
1913 h.id, project, d
1914 ));
1915 }
1916 if let Some(s) = h.scheduled()
1917 && parse_org_date(s).is_none()
1918 {
1919 f.err(format_args!(
1920 "{} (in {}) :SCHEDULED: {} -> unparseable",
1921 h.id, project, s
1922 ));
1923 }
1924 if matches!(h.state.as_str(), "TODO" | "STARTED") && !h.properties.contains_key("CREATED") {
1925 f.warn(format_args!(
1926 "{} (in {}) state={} but :CREATED: is missing",
1927 h.id, project, h.state
1928 ));
1929 }
1930 if h.state == "DONE" && looks_like_reject_prose(&h.body) {
1931 f.warn(format_args!(
1932 "{} (in {}) is DONE but the body reads as a reject",
1933 h.id, project
1934 ));
1935 }
1936 if crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_some() {
1937 f.warn(format_args!(
1938 "{} (in {}) holds {} and sibling {}",
1939 h.id,
1940 project,
1941 h.state,
1942 crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).unwrap_or("?")
1943 ));
1944 }
1945}
1946
1947fn check_provenance_links<'a>(
1949 all: &[(String, IssueHeading)],
1950 project: &str,
1951 h: &'a IssueHeading,
1952 known: &HashSet<&'a str>,
1953 f: &mut Findings,
1954) {
1955 for linked in crate::related::org_link_targets(&h.body, known) {
1956 if edge_connects(all, &h.id, &linked) {
1957 continue;
1958 }
1959 if !claims_discovery_or_pivot(&h.body, &linked) {
1960 continue;
1961 }
1962 f.warn(format_args!(
1963 "{} (in {}) mentions [[id:{}]] as discovered or pivoted with no edge either way",
1964 h.id, project, linked
1965 ));
1966 }
1967}
1968
1969fn check_parent_cycle<'a>(
1975 start: &'a IssueHeading,
1976 by_id: &HashMap<String, (String, &'a IssueHeading)>,
1977 settled: &mut HashSet<&'a str>,
1978 f: &mut Findings,
1979) {
1980 if settled.contains(start.id.as_str()) {
1981 return;
1982 }
1983 let mut path: Vec<&str> = Vec::new();
1984 let mut on_path: HashSet<&str> = HashSet::new();
1985 let mut cursor = start.id.as_str();
1986 loop {
1987 if settled.contains(cursor) {
1988 break;
1989 }
1990 if !on_path.insert(cursor) {
1991 let start = path.iter().position(|id| *id == cursor).unwrap_or(0);
1992 let mut loop_ids: Vec<&str> = path[start..].to_vec();
1993 loop_ids.push(cursor);
1994 f.err(format_args!("parent cycle: {}", loop_ids.join(" -> ")));
1995 break;
1996 }
1997 path.push(cursor);
1998 match by_id.get(cursor).and_then(|(_, owner)| owner.parent()) {
1999 Some(parent) if by_id.contains_key(parent) => cursor = parent,
2000 _ => break,
2001 }
2002 }
2003 settled.extend(path);
2004}
2005
2006pub fn backlinks(layout: &Layout, target_id: &str) -> Result<String> {
2014 let all = load_all(layout)?;
2015 let mut out = String::new();
2016
2017 let known = all.iter().any(|(_, h)| h.id == target_id);
2023 if !known && crate::ops::is_deed_accession(target_id) {
2024 for (project, h) in &all {
2025 let relation = if h.deeds().iter().any(|cited| cited == target_id) {
2026 "cites"
2027 } else if h.body.contains(target_id) {
2028 "body mention"
2029 } else {
2030 continue;
2031 };
2032 let _ = writeln!(out, "{:<22} ({relation}) ({project})", h.id);
2033 }
2034 return Ok(out);
2035 }
2036
2037 for (project, h) in &all {
2038 if h.id == target_id {
2039 continue;
2040 }
2041 let mut hit = false;
2042 if blocker_ids(h).contains(&target_id) {
2043 let _ = writeln!(out, "{:<22} (blocked-by) ({})", h.id, project);
2044 hit = true;
2045 }
2046 if h.parent() == Some(target_id) {
2047 let _ = writeln!(out, "{:<22} (parent) ({})", h.id, project);
2048 hit = true;
2049 }
2050 if crate::props::get(&h.properties, crate::props::DISCOVERED_FROM) == Some(target_id) {
2051 let _ = writeln!(out, "{:<22} (discovered-from) ({})", h.id, project);
2052 hit = true;
2053 }
2054 if crate::props::get(&h.properties, crate::props::PIVOTED_TO) == Some(target_id) {
2055 let _ = writeln!(out, "{:<22} (pivoted-to) ({})", h.id, project);
2056 hit = true;
2057 }
2058 if !hit && h.body.contains(target_id) {
2059 let _ = writeln!(out, "{:<22} (body mention) ({})", h.id, project);
2060 }
2061 }
2062 Ok(out)
2063}
2064
2065#[cfg(test)]
2066mod tests {
2067 use super::*;
2068
2069 #[test]
2070 fn dot_labels_escape_untrusted_issue_text() {
2071 assert_eq!(dot_quoted(r#"a "quoted" title"#), r#"a \"quoted\" title"#);
2072 assert_eq!(dot_quoted(r"ends with\"), r"ends with\\");
2075 assert_eq!(dot_quoted("two\nlines"), "two\\nlines");
2076 }
2077}