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