1use anyhow::{Context, anyhow};
4
5use crate::error::Result;
6use chrono::NaiveDate;
7use std::collections::BTreeMap;
8use std::fmt::Write as _;
9use std::path::{Path, PathBuf};
10
11use crate::config::{Layout, VissueConfig};
12use crate::error::Error;
13use crate::graph::DependencyGraph;
14use crate::model::{IssueHeading, LogEntry, TODO_KEYWORDS, today_inactive_bracket};
15use crate::store::{
16 IssueDoc, collect_org_ids, detect_project_from_ctx, find_by_id, generate_id, load_all,
17 resolve_existing_project_case, with_issues_lock, with_issues_locks,
18};
19
20pub fn resolve_project(layout: &Layout, explicit: Option<&str>) -> Result<String> {
30 if let Some(p) = explicit {
31 if p.is_empty() {
32 return Err(anyhow!("--project given but empty").into());
33 }
34 return resolve_existing_project_case(layout, p);
35 }
36 let cwd = std::env::current_dir()?;
37 let detected = detect_project_from_ctx(&cwd).ok_or_else(|| {
38 anyhow!(
39 "no --project given and no .project-ctx.toml found walking up from {}",
40 cwd.display()
41 )
42 })?;
43 resolve_existing_project_case(layout, &detected)
44}
45
46#[derive(Debug, Default, Clone, Copy)]
48pub struct CreateOpts<'a> {
49 pub priority: Option<char>,
51 pub issue_type: Option<&'a str>,
53 pub deadline: Option<&'a str>,
55 pub scheduled: Option<&'a str>,
57 pub tags: Option<&'a str>,
59 pub parent: Option<&'a str>,
61 pub quiet: bool,
63 pub body: Option<&'a str>,
65 pub extra_ids: &'a [String],
68 pub extra_id_paths: &'a [PathBuf],
71 pub id: Option<&'a str>,
74}
75
76pub fn create(layout: &Layout, project: &str, title: &str, opts: CreateOpts<'_>) -> Result<String> {
88 let project = resolve_existing_project_case(layout, project)?;
89 let cfg = VissueConfig::load(layout)?;
90 let path = layout.project_issues_path(&project);
91 let (spec, named) = match IssueDoc::parse_file(&project, &path) {
92 Ok(doc) => (doc.priority_spec(), doc.priorities_are_named()),
93 Err(_) => (crate::org::PrioritySpec::default(), false),
94 };
95 let house_new = !path.exists();
96 let priority = opts.priority.unwrap_or(if named || house_new {
97 spec.default
98 } else {
99 cfg.issues.default_priority
100 });
101 if !spec.contains(priority) {
102 return Err(anyhow!(
103 "invalid priority {priority:?}; file allows [#{}]..[#{}]",
104 spec.highest,
105 spec.lowest
106 )
107 .into());
108 }
109
110 let known_ids = if opts.parent.is_some() || opts.body.is_some() {
112 collect_org_ids(layout)?
113 } else {
114 std::collections::HashSet::new()
115 };
116 if let Some(p) = opts.parent
117 && !known_ids.contains(p)
118 {
119 return Err(anyhow!("--parent {p} does not refer to any known id").into());
120 }
121
122 let mut lock_paths: Vec<PathBuf> = vec![path.clone()];
124 lock_paths.extend(opts.extra_id_paths.iter().cloned());
125 let lock_refs: Vec<&Path> = lock_paths.iter().map(PathBuf::as_path).collect();
126 with_issues_locks(&lock_refs, || {
127 let mut doc = IssueDoc::parse_file(&project, &path)?;
128 let mut taken = doc.known_ids();
129 taken.extend(opts.extra_ids.iter().cloned());
130 for twin in opts.extra_id_paths {
131 if twin == &path {
132 continue;
133 }
134 if let Ok(doc) = IssueDoc::parse_file(&project, twin) {
135 taken.extend(doc.known_ids());
136 }
137 }
138 let id = if let Some(want) = opts.id {
139 validate_explicit_id(&project, want)?;
140 if taken.iter().any(|seen| seen == want) {
141 return Err(anyhow!("--id {want} already exists").into());
142 }
143 want.to_string()
144 } else {
145 generate_id(&project, title, &taken, cfg.issues.id_length)?
146 };
147
148 let mut props = BTreeMap::new();
149 props.insert("ID".into(), id.clone());
150 props.insert("CREATED".into(), today_inactive_bracket());
151 if crate::props::get(&props, crate::props::DISCOVERED_FROM).is_none()
152 && let Some(body) = opts.body
153 && let Some(origin) = first_existing_id_link(body, &known_ids)
154 {
155 crate::props::insert(&mut props, crate::props::DISCOVERED_FROM, origin);
156 }
157 let mut org_tags: Vec<String> = Vec::new();
158 if let Some(t) = opts.issue_type {
159 crate::props::insert(&mut props, crate::props::TYPE, t.into());
160 if t.chars().all(crate::model::is_org_tag_char)
163 && !t.is_empty()
164 && !org_tags.iter().any(|seen| seen == t)
165 {
166 org_tags.push(t.to_string());
167 }
168 }
169 if let Some(d) = opts.deadline {
170 validate_org_date(d)?;
171 props.insert("DEADLINE".into(), d.into());
172 }
173 if let Some(s) = opts.scheduled {
174 validate_org_date(s)?;
175 props.insert("SCHEDULED".into(), s.into());
176 }
177 if let Some(tags) = opts.tags {
181 let mut property_tags: Vec<String> = Vec::new();
182 for tag in tags.split([',', ':']).map(str::trim) {
183 if tag.is_empty() {
184 continue;
185 }
186 if tag.chars().all(crate::model::is_org_tag_char) {
187 if !org_tags.iter().any(|seen| seen == tag) {
188 org_tags.push(tag.to_string());
189 }
190 } else if !property_tags.iter().any(|seen| seen == tag) {
191 property_tags.push(tag.to_string());
192 }
193 }
194 if !property_tags.is_empty() {
195 props.insert(crate::model::TAGS_PROPERTY.into(), property_tags.join(","));
196 }
197 }
198 if let Some(p) = opts.parent {
199 crate::props::insert(&mut props, crate::props::PARENT, p.into());
200 }
201
202 doc.headings.push(IssueHeading {
203 id: id.clone(),
204 title: title.to_string(),
205 state: "TODO".into(),
206 priority,
207 properties: props,
208 org_tags,
209 statistics: None,
210 property_order: Vec::new(),
211 extra_drawers: Vec::new(),
212 body: match opts.body {
213 Some(b) if !b.trim().is_empty() => format!("{}\n", b.trim_end()),
214 _ => String::new(),
215 },
216 logbook: Vec::new(),
217 line_start: 0,
218 line_end: 0,
219 });
220 doc.write()?;
221
222 if opts.quiet {
223 Ok(format!("{id}\n"))
224 } else {
225 Ok(format!(
226 "{id} TODO [#{priority}] {title}\nfile: {}\n",
227 path.display()
228 ))
229 }
230 })
231}
232
233pub(crate) fn validate_explicit_id(project: &str, id: &str) -> Result<()> {
239 let prefix = format!("{project}-");
240 let Some(suffix) = id.strip_prefix(&prefix) else {
241 return Err(anyhow!("--id {id} is not {project}-<suffix>").into());
242 };
243 if suffix.is_empty()
244 || !suffix
245 .bytes()
246 .all(|b| b.is_ascii_digit() || (b.is_ascii_lowercase() && b.is_ascii_alphanumeric()))
247 {
248 return Err(anyhow!("--id {id} suffix must be one or more 0-9a-z").into());
249 }
250 Ok(())
251}
252
253pub(crate) fn validate_org_date(s: &str) -> Result<()> {
254 let inner = s
255 .trim_start_matches(['<', '['])
256 .trim_end_matches(['>', ']']);
257 let token = inner.split_whitespace().next().unwrap_or("");
258 NaiveDate::parse_from_str(token, "%Y-%m-%d").with_context(|| {
259 format!("expected org date like <YYYY-MM-DD> or [YYYY-MM-DD], got {s:?}")
260 })?;
261 Ok(())
262}
263
264pub fn update(
272 layout: &Layout,
273 id: &str,
274 new_state: Option<&str>,
275 new_priority: Option<char>,
276 block_add: Option<&str>,
277 block_clear: Option<&str>,
278) -> Result<UpdateOutcome> {
279 let identity = crate::config::identity(layout);
280 update_as(
281 layout,
282 id,
283 new_state,
284 new_priority,
285 block_add,
286 block_clear,
287 &identity,
288 )
289}
290
291#[derive(Debug, Default, Clone, Copy)]
296pub struct UpdatePred<'a> {
297 pub if_state: Option<&'a str>,
299 pub if_gen: Option<u64>,
301}
302
303pub fn update_pred(
309 layout: &Layout,
310 id: &str,
311 new_state: Option<&str>,
312 new_priority: Option<char>,
313 block_add: Option<&str>,
314 block_clear: Option<&str>,
315 pred: UpdatePred<'_>,
316) -> Result<UpdateOutcome> {
317 let identity = crate::config::identity(layout);
318 update_as_pred(
319 layout,
320 id,
321 new_state,
322 new_priority,
323 block_add,
324 block_clear,
325 &identity,
326 pred,
327 )
328}
329
330pub fn update_as(
337 layout: &Layout,
338 id: &str,
339 new_state: Option<&str>,
340 new_priority: Option<char>,
341 block_add: Option<&str>,
342 block_clear: Option<&str>,
343 identity: &str,
344) -> Result<UpdateOutcome> {
345 update_as_pred(
346 layout,
347 id,
348 new_state,
349 new_priority,
350 block_add,
351 block_clear,
352 identity,
353 UpdatePred::default(),
354 )
355}
356
357#[allow(clippy::too_many_arguments)]
363pub fn update_as_pred(
364 layout: &Layout,
365 id: &str,
366 new_state: Option<&str>,
367 new_priority: Option<char>,
368 block_add: Option<&str>,
369 block_clear: Option<&str>,
370 identity: &str,
371 pred: UpdatePred<'_>,
372) -> Result<UpdateOutcome> {
373 let (_h0, path, project) =
374 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
375
376 let (transition, changed) = with_issues_lock(&path, || {
377 let graph = if block_add.is_some() {
380 Some(DependencyGraph::from_issues(&load_all(layout)?)?)
381 } else {
382 None
383 };
384 let mut doc = IssueDoc::parse_file(&project, &path)?;
385 let spec = doc.priority_spec();
386 let h = doc
387 .headings
388 .iter_mut()
389 .find(|x| x.id == id)
390 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
391
392 let original = h.state.clone();
393 let mut changed = Vec::new();
394
395 if pred.if_state.is_some() || pred.if_gen.is_some() {
396 let seen = crate::events::generation(layout);
397 if let Some(want) = pred.if_state {
398 if !TODO_KEYWORDS.contains(&want) {
399 return Err(
400 anyhow!("invalid --if-state {want:?}; allowed: {TODO_KEYWORDS:?}").into(),
401 );
402 }
403 if h.state != want {
404 return Err(Error::StaleWrite {
405 id: id.to_string(),
406 expected_state: Some(want.to_string()),
407 actual_state: h.state.clone(),
408 expected_gen: pred.if_gen,
409 actual_gen: Some(seen),
410 });
411 }
412 }
413 if let Some(want_gen) = pred.if_gen
414 && seen != want_gen
415 {
416 return Err(Error::StaleWrite {
417 id: id.to_string(),
418 expected_state: pred.if_state.map(str::to_string),
419 actual_state: h.state.clone(),
420 expected_gen: Some(want_gen),
421 actual_gen: Some(seen),
422 });
423 }
424 }
425
426 if let Some(s) = new_state {
427 if !TODO_KEYWORDS.contains(&s) {
428 return Err(anyhow!("invalid state {s:?}; allowed: {TODO_KEYWORDS:?}").into());
429 }
430 if h.state != s {
431 if is_terminal(&h.state) && is_terminal(s) {
432 record_sibling_terminal(h, s);
433 changed.push(format!("sibling terminal {s} (held {})", h.state));
434 } else {
435 let from = h.state.clone();
436 h.record_state_change(s);
437 changed.push(format!("state {from} -> {s}"));
438 for note in settle_claim(h, &from, s, identity) {
439 changed.push(note);
440 }
441 }
442 }
443 }
444
445 if let Some(p) = new_priority {
446 if !spec.contains(p) {
447 return Err(anyhow!(
448 "invalid priority {p:?}; file allows [#{}]..[#{}]",
449 spec.highest,
450 spec.lowest
451 )
452 .into());
453 }
454 if h.priority != p {
455 h.priority = p;
456 changed.push(format!("priority -> [#{p}]"));
457 }
458 }
459
460 if let Some(blk) = block_add {
461 let mut current = h.blocked_by();
462 if !current.iter().any(|x| x == blk) {
463 if let Some(graph) = &graph {
464 graph.accepts_edge(blk, id)?;
465 }
466 current.push(blk.to_string());
467 crate::props::insert(
468 &mut h.properties,
469 crate::props::BLOCKED_BY,
470 current.join(" "),
471 );
472 if h.state == "TODO" || h.state == "STARTED" {
473 let from = h.state.clone();
474 h.record_state_change("BLOCKED");
475 changed.push(format!("state {from} -> BLOCKED (auto on block)"));
476 }
477 changed.push(format!("blocked_by += {blk}"));
478 }
479 }
480
481 if let Some(blk) = block_clear {
482 let mut current = h.blocked_by();
483 let before = current.len();
484 current.retain(|x| x != blk);
485 if current.len() < before {
486 if current.is_empty() {
487 crate::props::remove(&mut h.properties, crate::props::BLOCKED_BY);
488 if h.state == "BLOCKED" {
489 let from = h.state.clone();
490 h.record_state_change("TODO");
491 changed.push("state BLOCKED -> TODO (auto on unblock)".to_string());
492 for note in settle_claim(h, &from, "TODO", identity) {
493 changed.push(note);
494 }
495 }
496 } else {
497 crate::props::insert(
498 &mut h.properties,
499 crate::props::BLOCKED_BY,
500 current.join(" "),
501 );
502 }
503 changed.push(format!("blocked_by -= {blk}"));
504 }
505 }
506
507 if changed.is_empty() {
508 return Ok((None, Vec::new()));
509 }
510
511 let final_state = h.state.clone();
512 doc.write()?;
513 let transition = (original != final_state).then_some((original, final_state));
514 Ok((transition, changed))
515 })?;
516
517 if changed.is_empty() {
518 return Ok(UpdateOutcome {
519 report: format!("{id}: no change\n"),
520 hints: Vec::new(),
521 });
522 }
523
524 if let Some((from, to)) = &transition {
525 let _ = crate::events::emit_state_change(layout, &project, id, from, to);
526 }
527
528 let mut hints = Vec::new();
529 if matches!(
530 transition.as_ref().map(|(_, to)| to.as_str()),
531 Some("DONE") | Some("CANCELLED")
532 ) {
533 for (other_project, other) in load_all(layout)? {
534 if !other.blocked_by().iter().any(|b| b == id) {
535 continue;
536 }
537 if other.state == "DONE" || other.state == "CANCELLED" {
538 continue;
539 }
540 hints.push(format!(
541 "{} (in {}) lists this as a blocker; clear with `vissue update {} --unblock {}`",
542 other.id, other_project, other.id, id
543 ));
544 }
545 }
546 Ok(UpdateOutcome {
547 report: format!("{id}: {}\n", changed.join(", ")),
548 hints,
549 })
550}
551
552fn keeps_claim(state: &str) -> bool {
555 matches!(state, "STARTED" | "BLOCKED")
556}
557
558fn is_terminal(state: &str) -> bool {
559 matches!(state, "DONE" | "CANCELLED")
560}
561
562fn record_sibling_terminal(h: &mut IssueHeading, attempted: &str) {
563 crate::props::insert(
564 &mut h.properties,
565 crate::props::SIBLING_TERMINAL,
566 attempted.to_string(),
567 );
568}
569
570pub fn resolve_terminal(layout: &Layout, id: &str, state: &str) -> Result<String> {
577 if !is_terminal(state) {
578 return Err(anyhow!("resolve state must be DONE or CANCELLED, got {state:?}").into());
579 }
580 let identity = crate::config::identity(layout);
581 let (_h0, path, project) =
582 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
583 let from = with_issues_lock(&path, || {
584 let mut doc = IssueDoc::parse_file(&project, &path)?;
585 let h = doc
586 .headings
587 .iter_mut()
588 .find(|x| x.id == id)
589 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
590 let from = h.state.clone();
591 if from != state {
592 h.record_state_change(state);
593 settle_claim(h, &from, state, &identity);
594 }
595 crate::props::remove(&mut h.properties, crate::props::SIBLING_TERMINAL);
596 doc.write()?;
597 Ok(from)
598 })?;
599 if from != state {
600 let _ = crate::events::emit_state_change(layout, &project, id, &from, state);
601 }
602 Ok(format!("resolved {id} -> {state}\n"))
603}
604
605fn settle_claim(h: &mut IssueHeading, from: &str, to: &str, identity: &str) -> Vec<String> {
611 let mut notes = Vec::new();
612 if to == "STARTED" && h.claimed_by().is_none() {
613 h.set_claim(identity);
614 notes.push(format!("claimed by {identity}"));
615 } else if keeps_claim(from)
616 && !keeps_claim(to)
617 && let Some((who, _when)) = h.release_claim()
618 {
619 notes.push(format!("claim released ({who})"));
620 }
621 notes
622}
623
624pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
635 let identity = crate::config::identity(layout);
636 claim_as(layout, id, force, &identity)
637}
638
639pub fn claim_as(layout: &Layout, id: &str, force: bool, identity: &str) -> Result<String> {
647 let (_h0, path, project) =
648 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
649
650 let report = with_issues_lock(&path, || {
651 let mut doc = IssueDoc::parse_file(&project, &path)?;
652 let h = doc
653 .headings
654 .iter_mut()
655 .find(|x| x.id == id)
656 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
657
658 if h.state == "DONE" || h.state == "CANCELLED" {
659 return Err(Error::InvalidState {
660 id: id.to_string(),
661 state: h.state.clone(),
662 });
663 }
664 if let Some(holder) = h.claimed_by() {
665 if holder != identity && !force {
666 return Err(Error::ClaimConflict {
667 id: id.to_string(),
668 holder: holder.to_string(),
669 claimed_at: h.claimed_at().map(str::to_string),
670 });
671 }
672 if holder != identity {
673 let previous = holder.to_string();
674 let from = h.state.clone();
675 h.release_claim();
676 h.set_claim(identity);
677 h.record_state_change("STARTED");
678 doc.write()?;
679 if from != "STARTED" {
680 let _ =
681 crate::events::emit_state_change(layout, &project, id, &from, "STARTED");
682 }
683 return Ok(format!("claimed {id} (taken over from {previous})\n"));
684 }
685 }
686
687 let was = h.state.clone();
688 h.record_state_change("STARTED");
689 if h.claimed_by().is_none() {
690 h.set_claim(identity);
691 }
692 let standing = standing_on(h);
694 doc.write()?;
695 if was != "STARTED" {
696 let _ = crate::events::emit_state_change(layout, &project, id, &was, "STARTED");
697 }
698 let mut out = if was == "STARTED" {
699 format!("claimed {id} by {identity}\n")
700 } else {
701 format!("claimed {id} by {identity} ({was} -> STARTED)\n")
702 };
703 out.push_str(&standing);
704 Ok(out)
705 })?;
706 Ok(report)
707}
708
709fn standing_on(h: &IssueHeading) -> String {
712 let blockers = h.blocked_by().len();
713 let bounced = crate::props::get(&h.properties, crate::props::DISCOVERED_FROM).is_some();
714 if blockers == 0 && !bounced && h.parent().is_none() {
715 return String::new();
716 }
717 let mut parts: Vec<String> = Vec::new();
718 if blockers > 0 {
719 parts.push(format!(
720 "{blockers} declared input{}",
721 if blockers == 1 { "" } else { "s" }
722 ));
723 }
724 if bounced {
725 parts.push("an origin it was bounced from".to_string());
726 }
727 if h.parent().is_some() {
728 parts.push("a plan above it".to_string());
729 }
730 format!(" `recall {}` for {}\n", h.id, parts.join(", "))
731}
732
733#[derive(Debug, Clone)]
735pub struct UpdateOutcome {
736 pub report: String,
738 pub hints: Vec<String>,
740}
741
742pub fn note(layout: &Layout, id: &str, text: &str) -> Result<String> {
751 let text = text
754 .split_whitespace()
755 .collect::<Vec<_>>()
756 .join(" ")
757 .replace('"', "'");
758 if text.is_empty() {
759 return Err(anyhow!("note text is empty").into());
760 }
761 let (_h0, path, project) =
762 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
763 with_issues_lock(&path, || {
764 let mut doc = IssueDoc::parse_file(&project, &path)?;
765 let h = doc
766 .headings
767 .iter_mut()
768 .find(|x| x.id == id)
769 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
770 h.logbook.insert(
773 0,
774 LogEntry {
775 timestamp: LogEntry::now(),
776 from_state: None,
777 to_state: None,
778 note: Some(text.clone()),
779 raw: None,
780 },
781 );
782 doc.write()?;
783 Ok(format!("{id}: noted\n"))
784 })
785}
786
787pub fn append_body(layout: &Layout, id: &str, text: &str) -> Result<String> {
795 append_body_as(layout, id, text, &crate::config::identity(layout))
796}
797
798pub fn append_body_as(layout: &Layout, id: &str, text: &str, identity: &str) -> Result<String> {
805 let text = text.trim_end();
806 if text.trim().is_empty() {
807 return Err(anyhow!("append text is empty").into());
808 }
809 let (_h0, path, project) =
810 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
811 with_issues_lock(&path, || {
812 let mut doc = IssueDoc::parse_file(&project, &path)?;
813 let h = doc
814 .headings
815 .iter_mut()
816 .find(|x| x.id == id)
817 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
818 let stamp = format!("{} {identity}", today_inactive_bracket());
819 if !h.body.trim().is_empty() {
820 h.body = h.body.trim_end().to_string();
821 h.body.push_str("\n\n");
822 } else {
823 h.body.clear();
824 }
825 h.body.push_str(&stamp);
826 h.body.push('\n');
827 h.body.push_str(text);
828 h.body.push('\n');
829 doc.write()?;
830 let lines = text.lines().count();
831 Ok(format!("{id}: appended {lines} line(s)\n"))
832 })
833}
834
835const VOTES_DRAWER: &str = "VOTES";
837
838#[derive(Debug, Clone, PartialEq, Eq)]
840pub struct Ballot {
841 pub agent: String,
843 pub choice: String,
845 pub stamp: String,
847}
848
849pub fn vote(layout: &Layout, id: &str, choice: Option<&str>, identity: &str) -> Result<String> {
858 let (_h, path, project) =
859 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
860 let Some(choice) = choice else {
861 let doc = IssueDoc::parse_file(&project, &path)?;
862 let h = doc
863 .headings
864 .iter()
865 .find(|x| x.id == id)
866 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
867 let (ballots, _) = read_ballots(h);
868 return Ok(tally_text(id, &ballots));
869 };
870 let choice = choice.trim();
871 if choice.is_empty() {
872 return Err(anyhow!("vote needs something to vote for").into());
873 }
874 if choice.contains('\n') {
875 return Err(anyhow!("a vote is one line").into());
876 }
877 if identity.contains(": ") {
880 return Err(anyhow!(
881 "the identity {identity:?} contains a colon and a space, which a ballot line \
882 cannot hold unambiguously; set VISSUE_AGENT or `agent` in the config to a \
883 name without one"
884 )
885 .into());
886 }
887 if identity.trim().is_empty() {
888 return Err(anyhow!("a ballot needs an identity to file it under").into());
889 }
890 with_issues_lock(&path, || {
891 let mut doc = IssueDoc::parse_file(&project, &path)?;
892 let h = doc
893 .headings
894 .iter_mut()
895 .find(|x| x.id == id)
896 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
897 let (mut ballots, foreign) = read_ballots(h);
898 let stamp = today_inactive_bracket();
899 let previous = ballots.iter().position(|b| b.agent == identity);
900 let changed_from = previous.map(|i| ballots[i].choice.clone());
901 let ballot = Ballot {
902 agent: identity.to_string(),
903 choice: choice.to_string(),
904 stamp,
905 };
906 match previous {
907 Some(i) => ballots[i] = ballot,
908 None => ballots.push(ballot),
909 }
910 write_ballots(h, &ballots, &foreign);
911 doc.write()?;
912 let mut out = match changed_from {
913 Some(old) if old == choice => format!("{id}: {identity} already voted {choice}\n"),
914 Some(old) => format!("{id}: {identity} changed {old} to {choice}\n"),
915 None => format!("{id}: {identity} voted {choice}\n"),
916 };
917 out.push_str(&tally_text(id, &ballots));
918 Ok(out)
919 })
920}
921
922pub fn ballots(layout: &Layout, id: &str) -> Result<Vec<Ballot>> {
928 let (h, _path, _project) =
929 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
930 Ok(read_ballots(&h).0)
931}
932
933fn read_ballots(h: &IssueHeading) -> (Vec<Ballot>, Vec<String>) {
936 let Some(drawer) = h
937 .extra_drawers
938 .iter()
939 .find(|d| drawer_name_is(d, VOTES_DRAWER))
940 else {
941 return (Vec::new(), Vec::new());
942 };
943 let mut ballots: Vec<Ballot> = Vec::new();
944 let mut foreign: Vec<String> = Vec::new();
945 for line in drawer.lines() {
946 let trimmed = line.trim();
947 if trimmed.is_empty() {
948 continue;
949 }
950 if trimmed.eq_ignore_ascii_case(&format!(":{VOTES_DRAWER}:"))
952 || trimmed.eq_ignore_ascii_case(":END:")
953 {
954 continue;
955 }
956 match parse_ballot(trimmed) {
957 Some(b) => match ballots.iter_mut().find(|x| x.agent == b.agent) {
960 Some(existing) => *existing = b,
961 None => ballots.push(b),
962 },
963 None => foreign.push(trimmed.to_string()),
964 }
965 }
966 (ballots, foreign)
967}
968
969fn parse_ballot(line: &str) -> Option<Ballot> {
972 let (stamp, rest) = line.strip_prefix('[')?.split_once("] ")?;
973 let (agent, choice) = rest.split_once(": ")?;
974 let agent = agent.trim();
975 let choice = choice.trim();
976 if agent.is_empty() || choice.is_empty() {
977 return None;
978 }
979 Some(Ballot {
980 agent: agent.to_string(),
981 choice: choice.to_string(),
982 stamp: format!("[{stamp}]"),
983 })
984}
985
986fn drawer_name_is(drawer: &str, name: &str) -> bool {
987 drawer
988 .lines()
989 .next()
990 .map(str::trim)
991 .and_then(|first| first.strip_prefix(':'))
992 .and_then(|rest| rest.strip_suffix(':'))
993 .is_some_and(|n| n.eq_ignore_ascii_case(name))
994}
995
996fn write_ballots(h: &mut IssueHeading, ballots: &[Ballot], foreign: &[String]) {
999 let at = h
1000 .extra_drawers
1001 .iter()
1002 .position(|d| drawer_name_is(d, VOTES_DRAWER));
1003 if ballots.is_empty() && foreign.is_empty() {
1004 if let Some(i) = at {
1005 h.extra_drawers.remove(i);
1006 }
1007 return;
1008 }
1009 let mut drawer = format!(":{VOTES_DRAWER}:\n");
1010 for b in ballots {
1011 drawer.push_str(&format!("{} {}: {}\n", b.stamp, b.agent, b.choice));
1012 }
1013 for line in foreign {
1014 drawer.push_str(line);
1015 drawer.push('\n');
1016 }
1017 drawer.push_str(":END:\n");
1018 match at {
1019 Some(i) => h.extra_drawers[i] = drawer,
1020 None => h.extra_drawers.push(drawer),
1021 }
1022}
1023
1024fn tally_text(id: &str, ballots: &[Ballot]) -> String {
1026 if ballots.is_empty() {
1027 return format!("{id}: no votes\n");
1028 }
1029 let mut counts: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1030 for b in ballots {
1031 counts
1032 .entry(b.choice.as_str())
1033 .or_default()
1034 .push(b.agent.as_str());
1035 }
1036 let total = ballots.len();
1037 let mut rows: Vec<(&&str, &Vec<&str>)> = counts.iter().collect();
1038 rows.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0)));
1039 let mut out = format!(
1040 "{id}: {total} vote{} from {} option{}\n",
1041 if total == 1 { "" } else { "s" },
1042 counts.len(),
1043 if counts.len() == 1 { "" } else { "s" }
1044 );
1045 for (choice, who) in &rows {
1046 let _ = writeln!(out, " {:<24} {} ({})", choice, who.len(), who.join(", "));
1047 }
1048 let top = rows[0].1.len();
1049 let tied = rows.iter().filter(|(_, who)| who.len() == top).count();
1050 if tied > 1 {
1051 let _ = writeln!(out, " no consensus: {tied} options tied at {top}");
1052 } else if total < 2 {
1053 let _ = writeln!(
1057 out,
1058 " one ballot only: {}, which nobody has agreed with yet",
1059 rows[0].0
1060 );
1061 } else if top * 2 > total {
1062 let _ = writeln!(out, " consensus: {} ({top} of {total})", rows[0].0);
1063 } else {
1064 let _ = writeln!(
1065 out,
1066 " plurality only: {} ({top} of {total}), which is not a majority",
1067 rows[0].0
1068 );
1069 }
1070 out
1071}
1072
1073const DEED_PREFIXES: &[&str] = &["deed-", "sha256:"];
1075
1076#[must_use]
1078pub fn is_deed_accession(value: &str) -> bool {
1079 let value = value.trim();
1080 if value.contains(|c: char| c.is_whitespace() || c == ',') {
1081 return false;
1082 }
1083 DEED_PREFIXES.iter().any(|prefix| {
1084 value
1085 .strip_prefix(*prefix)
1086 .is_some_and(|rest| !rest.is_empty())
1087 })
1088}
1089
1090pub fn deed(layout: &Layout, id: &str, add: &[String], remove: &[String]) -> Result<String> {
1099 let (h, path, project) =
1100 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
1101 if add.is_empty() && remove.is_empty() {
1102 return Ok(deed_list_text(id, &h.deeds()));
1103 }
1104 for value in add {
1105 if !is_deed_accession(value) {
1106 return Err(anyhow!(
1107 "{value:?} is not a deed accession; deedar mints `deed-<kind>-<slug>` \
1108 and answers `get` for a `sha256:` of the deed or of one product path"
1109 )
1110 .into());
1111 }
1112 }
1113 with_issues_lock(&path, || {
1114 let mut doc = IssueDoc::parse_file(&project, &path)?;
1115 let h = doc
1116 .headings
1117 .iter_mut()
1118 .find(|x| x.id == id)
1119 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
1120 let mut cited = h.deeds();
1121 let mut changed: Vec<String> = Vec::new();
1122 for value in add {
1123 let value = value.trim();
1124 if cited.iter().any(|x| x == value) {
1127 continue;
1128 }
1129 cited.push(value.to_string());
1130 changed.push(format!("deeds += {value}"));
1131 }
1132 for value in remove {
1133 let value = value.trim();
1134 let before = cited.len();
1135 cited.retain(|x| x != value);
1136 if cited.len() != before {
1137 changed.push(format!("deeds -= {value}"));
1138 }
1139 }
1140 if changed.is_empty() {
1141 return Ok(format!("{id}: no change\n{}", deed_list_text(id, &cited)));
1142 }
1143 if cited.is_empty() {
1144 crate::props::remove(&mut h.properties, crate::props::DEEDS);
1145 } else {
1146 crate::props::insert(&mut h.properties, crate::props::DEEDS, cited.join(" "));
1147 }
1148 doc.write()?;
1149 Ok(format!(
1150 "{id}: {}\n{}",
1151 changed.join(", "),
1152 deed_list_text(id, &cited)
1153 ))
1154 })
1155}
1156
1157fn deed_list_text(id: &str, cited: &[String]) -> String {
1159 if cited.is_empty() {
1160 return format!("{id}: no deeds cited\n");
1161 }
1162 let mut out = format!(
1163 "{id}: {} deed{}\n",
1164 cited.len(),
1165 if cited.len() == 1 { "" } else { "s" }
1166 );
1167 for value in cited {
1168 let _ = writeln!(out, " {value}");
1169 }
1170 out
1171}
1172
1173pub fn fold(layout: &Layout, inbox: &std::path::Path, project: &str) -> Result<String> {
1187 let project = resolve_existing_project_case(layout, project)?;
1188 let text = std::fs::read_to_string(inbox)
1189 .with_context(|| format!("read inbox {}", inbox.display()))?;
1190 let lines: Vec<String> = text.lines().map(str::to_string).collect();
1191
1192 struct Entry {
1193 line: usize,
1194 title: String,
1195 body: String,
1196 stamped: bool,
1197 }
1198 let mut entries: Vec<Entry> = Vec::new();
1199 let mut i = 0;
1200 let mut nest = crate::org::OrgScan::new();
1201 while i < lines.len() {
1202 if nest.observe(&lines[i]) {
1203 i += 1;
1204 continue;
1205 }
1206 if let Some(title) = lines[i].strip_prefix("* TODO ") {
1207 let start = i + 1;
1208 let mut end_nest = crate::org::OrgScan::new();
1209 let end = {
1210 let mut j = start;
1211 while j < lines.len() {
1212 if !end_nest.observe(&lines[j]) && lines[j].starts_with("* ") {
1213 break;
1214 }
1215 j += 1;
1216 }
1217 j
1218 };
1219 let stamped = lines[start..end]
1220 .iter()
1221 .any(|l| l.trim_start().starts_with(":VISSUE_ID:"));
1222 let body = lines[start..end].join("\n").trim().to_string();
1223 entries.push(Entry {
1224 line: i,
1225 title: title.trim().to_string(),
1226 body,
1227 stamped,
1228 });
1229 i = end;
1230 } else {
1231 i += 1;
1232 }
1233 }
1234
1235 let mut out = lines.clone();
1238 let mut created: Vec<String> = Vec::new();
1239 let mut failure = None;
1240 for e in entries.iter().rev() {
1241 if e.stamped {
1242 continue;
1243 }
1244 let printed = create(
1245 layout,
1246 &project,
1247 &e.title,
1248 CreateOpts {
1249 quiet: true,
1250 body: if e.body.is_empty() {
1251 None
1252 } else {
1253 Some(&e.body)
1254 },
1255 ..CreateOpts::default()
1256 },
1257 );
1258 let id = match printed {
1259 Ok(printed) => printed.trim().to_string(),
1260 Err(e) => {
1261 failure = Some(e);
1265 break;
1266 }
1267 };
1268 out[e.line] = format!("* DONE {}", e.title);
1269 out.insert(e.line + 1, format!(":VISSUE_ID: {id}"));
1270 created.push(id);
1271 }
1272 created.reverse();
1273
1274 if !created.is_empty() {
1275 let mut rendered = out.join("\n");
1276 if text.ends_with('\n') {
1277 rendered.push('\n');
1278 }
1279 std::fs::write(inbox, rendered)
1280 .with_context(|| format!("write inbox {}", inbox.display()))?;
1281 }
1282 if let Some(error) = failure {
1283 return Err(crate::error::Error::Other(
1284 anyhow::Error::from(error).context(format!(
1285 "folded {} before failing: {}",
1286 created.len(),
1287 created.join(" ")
1288 )),
1289 ));
1290 }
1291 if created.is_empty() {
1292 return Ok("folded 0 (nothing unstamped)\n".into());
1293 }
1294 Ok(format!("folded {}: {}\n", created.len(), created.join(" ")))
1295}
1296
1297pub fn refile(layout: &Layout, id: &str, to_project: &str) -> Result<String> {
1305 refile_to(layout, id, layout, to_project)
1306}
1307
1308pub fn refile_to(
1315 layout: &Layout,
1316 id: &str,
1317 dst_layout: &Layout,
1318 to_project: &str,
1319) -> Result<String> {
1320 let to_project = resolve_existing_project_case(dst_layout, to_project)?;
1321 let target_path = dst_layout.project_issues_path(&to_project);
1322 let (_heading, src_path, src_project) =
1323 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
1324 if src_path == target_path {
1325 return Ok(format!("{id} already in {to_project}; nothing to do\n"));
1326 }
1327 with_issues_locks(&[&src_path, &target_path], || {
1328 let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
1329 let heading = src_doc
1330 .remove(id)
1331 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
1332
1333 let mut tgt_doc = IssueDoc::parse_file(&to_project, &target_path)?;
1336 tgt_doc.upsert(heading);
1337 tgt_doc.write()?;
1338 src_doc.write()?;
1339 Ok(())
1340 })?;
1341 Ok(format!("{id}: {src_project} -> {to_project}\n"))
1342}
1343
1344#[derive(Debug, Default, Clone, Copy)]
1346pub struct RejectOpts<'a> {
1347 pub to: Option<&'a str>,
1349 pub project: Option<&'a str>,
1351 pub title: Option<&'a str>,
1353 pub reason: Option<&'a str>,
1355 pub dst_layout: Option<&'a Layout>,
1357 pub dst_extra_id_paths: &'a [PathBuf],
1361}
1362
1363pub fn reject(layout: &Layout, src: &str, opts: RejectOpts<'_>) -> Result<String> {
1376 let identity = crate::config::identity(layout);
1377 let (src0, src_path, src_project) =
1378 find_by_id(layout, src)?.ok_or_else(|| Error::IssueNotFound {
1379 id: src.to_string(),
1380 })?;
1381
1382 let dst_layout = opts.dst_layout.unwrap_or(layout);
1383 let existing_dst = if let Some(to) = opts.to {
1384 if to == src {
1385 return Err(anyhow!("reject destination cannot be the source {src}").into());
1386 }
1387 Some(
1388 find_by_id(dst_layout, to)?
1389 .ok_or_else(|| Error::IssueNotFound { id: to.to_string() })?,
1390 )
1391 } else {
1392 None
1393 };
1394
1395 let creating = existing_dst.is_none();
1396 if creating && opts.project.is_none() {
1397 return Err(anyhow!("reject needs --to DST or --project to create a successor").into());
1398 }
1399
1400 let dst_project = if let Some((_, _, ref project)) = existing_dst {
1401 project.clone()
1402 } else {
1403 resolve_existing_project_case(dst_layout, opts.project.unwrap_or(&src_project))?
1404 };
1405 let dst_path = dst_layout.project_issues_path(&dst_project);
1406 let dst_title = opts.title.unwrap_or(src0.title.as_str());
1407 let cfg = VissueConfig::load(layout)?;
1408
1409 let mut lock_paths: Vec<PathBuf> = vec![src_path.clone(), dst_path.clone()];
1412 lock_paths.extend(opts.dst_extra_id_paths.iter().cloned());
1413 let lock_refs: Vec<&Path> = lock_paths.iter().map(PathBuf::as_path).collect();
1414 let (dst_id, old_state, new_state) = with_issues_locks(&lock_refs, || {
1415 if src_path == dst_path {
1416 let mut doc = IssueDoc::parse_file(&src_project, &src_path)?;
1417 let dst_id = if creating {
1418 push_successor(
1419 &mut doc,
1420 &dst_project,
1421 dst_title,
1422 src,
1423 &cfg,
1424 opts.dst_extra_id_paths,
1425 )?
1426 } else {
1427 let to = reject_to(opts)?;
1428 set_discovered_from_if_empty(&mut doc, to, src)?;
1429 to.to_string()
1430 };
1431 let (old_state, new_state) =
1432 cancel_and_pivot(&mut doc, src, &dst_id, opts.reason, &identity)?;
1433 doc.write()?;
1434 Ok((dst_id, old_state, new_state))
1435 } else {
1436 let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
1437 let mut dst_doc = IssueDoc::parse_file(&dst_project, &dst_path)?;
1438 let dst_id = if creating {
1439 push_successor(
1440 &mut dst_doc,
1441 &dst_project,
1442 dst_title,
1443 src,
1444 &cfg,
1445 opts.dst_extra_id_paths,
1446 )?
1447 } else {
1448 let to = reject_to(opts)?;
1449 set_discovered_from_if_empty(&mut dst_doc, to, src)?;
1450 to.to_string()
1451 };
1452 let (old_state, new_state) =
1453 cancel_and_pivot(&mut src_doc, src, &dst_id, opts.reason, &identity)?;
1454 dst_doc.write()?;
1455 src_doc.write()?;
1456 Ok((dst_id, old_state, new_state))
1457 }
1458 })?;
1459
1460 if old_state != new_state {
1461 let _ = crate::events::emit_state_change(layout, &src_project, src, &old_state, &new_state);
1462 }
1463 Ok(format!("rejected {src} -> {dst_id}\n"))
1464}
1465
1466fn reject_to(opts: RejectOpts<'_>) -> Result<&str> {
1467 opts.to
1468 .ok_or_else(|| anyhow!("reject destination missing after --to was required").into())
1469}
1470
1471fn push_successor(
1472 doc: &mut IssueDoc,
1473 project: &str,
1474 title: &str,
1475 src: &str,
1476 cfg: &VissueConfig,
1477 extra_id_paths: &[PathBuf],
1478) -> Result<String> {
1479 let mut taken = doc.known_ids();
1480 for twin in extra_id_paths {
1482 if twin == &doc.path {
1483 continue;
1484 }
1485 if let Ok(other) = IssueDoc::parse_file(project, twin) {
1486 taken.extend(other.known_ids());
1487 }
1488 }
1489 let id = generate_id(project, title, &taken, cfg.issues.id_length)?;
1490 let mut props = BTreeMap::new();
1491 props.insert("ID".into(), id.clone());
1492 props.insert("CREATED".into(), today_inactive_bracket());
1493 crate::props::insert(&mut props, crate::props::DISCOVERED_FROM, src.to_string());
1494 doc.headings.push(IssueHeading {
1495 id: id.clone(),
1496 title: title.to_string(),
1497 state: "TODO".into(),
1498 priority: doc.default_create_priority(cfg.issues.default_priority),
1499 properties: props,
1500 org_tags: Vec::new(),
1501 statistics: None,
1502 property_order: Vec::new(),
1503 extra_drawers: Vec::new(),
1504 body: String::new(),
1505 logbook: Vec::new(),
1506 line_start: 0,
1507 line_end: 0,
1508 });
1509 Ok(id)
1510}
1511
1512fn set_discovered_from_if_empty(doc: &mut IssueDoc, id: &str, src: &str) -> Result<()> {
1513 let h = doc
1514 .headings
1515 .iter_mut()
1516 .find(|h| h.id == id)
1517 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
1518 let empty = crate::props::get(&h.properties, crate::props::DISCOVERED_FROM)
1519 .is_none_or(|s| s.trim().is_empty());
1520 if empty {
1521 crate::props::insert(
1522 &mut h.properties,
1523 crate::props::DISCOVERED_FROM,
1524 src.to_string(),
1525 );
1526 }
1527 Ok(())
1528}
1529
1530fn cancel_and_pivot(
1531 doc: &mut IssueDoc,
1532 src: &str,
1533 dst: &str,
1534 reason: Option<&str>,
1535 identity: &str,
1536) -> Result<(String, String)> {
1537 let h = doc
1538 .headings
1539 .iter_mut()
1540 .find(|h| h.id == src)
1541 .ok_or_else(|| Error::IssueNotFound {
1542 id: src.to_string(),
1543 })?;
1544 let old_state = h.state.clone();
1545 if is_terminal(&old_state) && old_state != "CANCELLED" {
1546 record_sibling_terminal(h, "CANCELLED");
1547 } else if old_state != "CANCELLED" {
1548 h.record_state_change("CANCELLED");
1549 settle_claim(h, &old_state, "CANCELLED", identity);
1550 }
1551 crate::props::insert(&mut h.properties, crate::props::PIVOTED_TO, dst.to_string());
1552 if let Some(reason) = reason {
1553 append_reason(h, reason, identity);
1554 }
1555 Ok((old_state, h.state.clone()))
1556}
1557
1558fn append_reason(h: &mut IssueHeading, text: &str, identity: &str) {
1559 let text = text.trim_end();
1560 if text.trim().is_empty() {
1561 return;
1562 }
1563 let stamp = format!("{} {identity}", today_inactive_bracket());
1564 if !h.body.trim().is_empty() {
1565 h.body = h.body.trim_end().to_string();
1566 h.body.push_str("\n\n");
1567 } else {
1568 h.body.clear();
1569 }
1570 h.body.push_str(&stamp);
1571 h.body.push('\n');
1572 h.body.push_str(text);
1573 h.body.push('\n');
1574}
1575
1576fn first_existing_id_link(body: &str, known: &std::collections::HashSet<String>) -> Option<String> {
1578 let mut rest = body;
1579 while let Some(start) = rest.find("[[") {
1580 let after_start = &rest[start + 2..];
1581 let end = after_start.find("]]")?;
1582 let raw = &after_start[..end];
1583 let target = raw.split_once("][").map_or(raw, |(target, _)| target);
1584 let target = target.trim();
1585 if let Some(id) = target.strip_prefix("id:") {
1586 let id = id.trim();
1587 if known.contains(id) {
1588 return Some(id.to_string());
1589 }
1590 }
1591 rest = &after_start[end + 2..];
1592 }
1593 None
1594}
1595
1596pub fn normalize(layout: &Layout, project: Option<&str>, dry_run: bool) -> Result<String> {
1607 let projects = match project {
1608 Some(name) => vec![resolve_existing_project_case(layout, name)?],
1609 None => crate::store::list_projects(layout)?,
1610 };
1611 let mut out = String::new();
1612 let mut files = 0usize;
1613 let mut headings = 0usize;
1614 let mut changed = 0usize;
1615 for project in projects {
1616 let path = layout.project_issues_path(&project);
1617 if !path.exists() {
1618 continue;
1619 }
1620 files += 1;
1621 let before =
1622 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1623 let report = with_issues_lock(&path, || {
1624 let mut doc = IssueDoc::parse_file(&project, &path)?;
1625 let mut moved = 0usize;
1626 for h in &mut doc.headings {
1627 moved += crate::props::settle(&mut h.org_tags, &mut h.properties);
1628 }
1629 let after = doc.render_string();
1630 if after != before {
1631 if !dry_run {
1632 doc.write()?;
1633 }
1634 Ok(Some((moved, after.len())))
1635 } else {
1636 Ok(None)
1637 }
1638 })?;
1639 headings += IssueDoc::parse(&project, path.clone(), &before)
1640 .map(|d| d.headings.len())
1641 .unwrap_or(0);
1642 if let Some((moved, _)) = report {
1643 changed += 1;
1644 let verb = if dry_run { "would rewrite" } else { "rewrote" };
1645 writeln!(out, "{verb} {project} ({moved} key move(s))")?;
1646 }
1647 }
1648 let mode = if dry_run { "dry-run" } else { "wrote" };
1649 writeln!(
1650 out,
1651 "normalize {mode}: {changed}/{files} file(s) changed, {headings} heading(s) scanned"
1652 )?;
1653 Ok(out)
1654}
1655
1656#[cfg(test)]
1657mod tests {
1658 use super::*;
1659 use crate::config::DEFAULT_PREFIX;
1660 use std::fs;
1661 use std::path::Path;
1662
1663 fn fresh_layout(dir: &Path) -> Layout {
1664 fs::create_dir_all(dir.join(DEFAULT_PREFIX)).unwrap();
1665 Layout::new(dir, DEFAULT_PREFIX)
1666 }
1667
1668 fn issue_at(layout: &Layout, project: &str, id: &str) -> IssueHeading {
1669 IssueDoc::parse_file(project, &layout.project_issues_path(project))
1670 .unwrap()
1671 .headings
1672 .into_iter()
1673 .find(|h| h.id == id)
1674 .expect("issue not found")
1675 }
1676
1677 fn only_id(layout: &Layout, project: &str) -> String {
1678 IssueDoc::parse_file(project, &layout.project_issues_path(project))
1679 .unwrap()
1680 .headings[0]
1681 .id
1682 .clone()
1683 }
1684
1685 #[test]
1688 fn a_claim_points_at_the_working_set_when_there_is_one() {
1689 let dir = tempfile::tempdir().unwrap();
1690 let layout = fresh_layout(dir.path());
1691 create(&layout, "sample", "the groundwork", CreateOpts::default()).unwrap();
1692 let first = only_id(&layout, "sample");
1693 create(&layout, "sample", "the next step", CreateOpts::default()).unwrap();
1694 let second = IssueDoc::parse_file("sample", &layout.project_issues_path("sample"))
1695 .unwrap()
1696 .headings
1697 .into_iter()
1698 .find(|h| h.id != first)
1699 .unwrap()
1700 .id;
1701 update(&layout, &second, None, None, Some(&first), None).unwrap();
1702
1703 let claimed = claim_as(&layout, &second, false, "impl").unwrap();
1704 assert!(
1705 claimed.contains(&format!("`recall {second}`")),
1706 "the claim has to say where the working set is: {claimed}"
1707 );
1708 assert!(claimed.contains("1 declared input"), "{claimed}");
1709
1710 let alone = claim_as(&layout, &first, false, "impl").unwrap();
1713 assert!(!alone.contains("recall"), "{alone}");
1714 }
1715
1716 #[test]
1719 fn a_cited_deed_is_readable_back_off_the_heading() {
1720 let dir = tempfile::tempdir().unwrap();
1721 let layout = fresh_layout(dir.path());
1722 create(&layout, "sample", "name the note", CreateOpts::default()).unwrap();
1723 let id = only_id(&layout, "sample");
1724
1725 let out = deed(&layout, &id, &["deed-patch-note".to_string()], &[]).unwrap();
1726 assert!(out.contains("deeds += deed-patch-note"), "{out}");
1727 assert_eq!(
1728 issue_at(&layout, "sample", &id).deeds(),
1729 vec!["deed-patch-note".to_string()]
1730 );
1731 }
1732
1733 #[test]
1736 fn citations_keep_the_order_they_were_added_in() {
1737 let dir = tempfile::tempdir().unwrap();
1738 let layout = fresh_layout(dir.path());
1739 create(&layout, "sample", "two products", CreateOpts::default()).unwrap();
1740 let id = only_id(&layout, "sample");
1741
1742 deed(&layout, &id, &["deed-file-note".to_string()], &[]).unwrap();
1743 deed(&layout, &id, &["deed-patch-note".to_string()], &[]).unwrap();
1744 assert_eq!(
1745 issue_at(&layout, "sample", &id).deeds(),
1746 vec!["deed-file-note".to_string(), "deed-patch-note".to_string()]
1747 );
1748 }
1749
1750 #[test]
1753 fn citing_the_same_deed_twice_leaves_one_citation() {
1754 let dir = tempfile::tempdir().unwrap();
1755 let layout = fresh_layout(dir.path());
1756 create(&layout, "sample", "retried", CreateOpts::default()).unwrap();
1757 let id = only_id(&layout, "sample");
1758
1759 deed(&layout, &id, &["deed-file-note".to_string()], &[]).unwrap();
1760 let again = deed(&layout, &id, &["deed-file-note".to_string()], &[]).unwrap();
1761 assert!(again.contains("no change"), "{again}");
1762 assert_eq!(issue_at(&layout, "sample", &id).deeds().len(), 1);
1763 }
1764
1765 #[test]
1768 fn removing_the_last_citation_removes_the_property() {
1769 let dir = tempfile::tempdir().unwrap();
1770 let layout = fresh_layout(dir.path());
1771 create(&layout, "sample", "mistaken", CreateOpts::default()).unwrap();
1772 let id = only_id(&layout, "sample");
1773
1774 deed(&layout, &id, &["deed-file-oops".to_string()], &[]).unwrap();
1775 deed(&layout, &id, &[], &["deed-file-oops".to_string()]).unwrap();
1776 let h = issue_at(&layout, "sample", &id);
1777 assert!(h.deeds().is_empty());
1778 assert!(
1779 !h.properties.contains_key(crate::props::DEEDS),
1780 "an empty citation list is not a citation list: {:?}",
1781 h.properties
1782 );
1783 }
1784
1785 #[test]
1789 fn a_value_deedar_could_not_be_asked_for_is_refused() {
1790 let dir = tempfile::tempdir().unwrap();
1791 let layout = fresh_layout(dir.path());
1792 create(&layout, "sample", "bad citation", CreateOpts::default()).unwrap();
1793 let id = only_id(&layout, "sample");
1794
1795 let err = deed(&layout, &id, &["/tmp/note.md".to_string()], &[]).unwrap_err();
1796 assert!(err.to_string().contains("not a deed accession"), "{err}");
1797 assert!(
1798 issue_at(&layout, "sample", &id).deeds().is_empty(),
1799 "a refused citation must not land"
1800 );
1801 }
1802
1803 #[test]
1805 fn both_deed_forms_are_accessions() {
1806 assert!(is_deed_accession("deed-quote-rfc2094-nll"));
1807 assert!(is_deed_accession(
1808 "sha256:0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7"
1809 ));
1810 assert!(!is_deed_accession("deed-"), "a prefix alone names nothing");
1811 assert!(
1812 !is_deed_accession("sha256:"),
1813 "a prefix alone names nothing"
1814 );
1815 assert!(!is_deed_accession(""));
1816 assert!(!is_deed_accession("deed-file a"));
1819 assert!(!is_deed_accession("deed-file,a"));
1820 }
1821
1822 #[test]
1824 fn listing_citations_does_not_touch_the_file() {
1825 let dir = tempfile::tempdir().unwrap();
1826 let layout = fresh_layout(dir.path());
1827 create(&layout, "sample", "read only", CreateOpts::default()).unwrap();
1828 let id = only_id(&layout, "sample");
1829 deed(&layout, &id, &["deed-file-note".to_string()], &[]).unwrap();
1830
1831 let path = layout.project_issues_path("sample");
1832 let before = fs::read_to_string(&path).unwrap();
1833 let out = deed(&layout, &id, &[], &[]).unwrap();
1834 assert!(out.contains("deed-file-note"), "{out}");
1835 assert_eq!(before, fs::read_to_string(&path).unwrap());
1836 }
1837
1838 #[test]
1839 fn create_rejects_a_parent_that_does_not_exist() {
1840 let dir = tempfile::tempdir().unwrap();
1841 let layout = fresh_layout(dir.path());
1842 let err = create(
1843 &layout,
1844 "sample",
1845 "child without parent",
1846 CreateOpts {
1847 parent: Some("sample-zzz9"),
1848 ..Default::default()
1849 },
1850 )
1851 .unwrap_err();
1852 assert!(err.to_string().contains("does not refer to any known id"));
1853 }
1854
1855 #[test]
1856 fn create_accepts_a_parent_defined_in_a_design_document() {
1857 let dir = tempfile::tempdir().unwrap();
1858 let layout = fresh_layout(dir.path());
1859 let parent_id = "sample-spec-20260615";
1860 let project_dir = layout.projects_dir().join("sample");
1861 fs::create_dir_all(&project_dir).unwrap();
1862 fs::write(
1863 project_dir.join("design.org"),
1864 format!("#+TITLE: sample design\n\n* Design\n:PROPERTIES:\n:ID: {parent_id}\n:END:\n"),
1865 )
1866 .unwrap();
1867
1868 create(
1869 &layout,
1870 "sample",
1871 "child under design",
1872 CreateOpts {
1873 parent: Some(parent_id),
1874 ..Default::default()
1875 },
1876 )
1877 .unwrap();
1878 assert!(only_id(&layout, "sample").starts_with("sample-"));
1879 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1880 assert_eq!(doc.headings[0].parent(), Some(parent_id));
1881 }
1882
1883 #[test]
1884 fn a_state_update_writes_a_logbook_entry() {
1885 let dir = tempfile::tempdir().unwrap();
1886 let layout = fresh_layout(dir.path());
1887 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1888 let id = only_id(&layout, "sample");
1889 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1890 let h = issue_at(&layout, "sample", &id);
1891 assert_eq!(h.state, "STARTED");
1892 assert_eq!(h.logbook[0].from_state.as_deref(), Some("TODO"));
1893 assert_eq!(h.logbook[0].to_state.as_deref(), Some("STARTED"));
1894 }
1895
1896 #[test]
1897 fn blocking_and_unblocking_drive_the_state() {
1898 let dir = tempfile::tempdir().unwrap();
1899 let layout = fresh_layout(dir.path());
1900 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1901 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1902 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1903 let first = doc.headings[0].id.clone();
1904 let blocker = doc.headings[1].id.clone();
1905
1906 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1907 let h = issue_at(&layout, "sample", &first);
1908 assert_eq!(h.state, "BLOCKED");
1909 assert!(h.blocked_by().contains(&blocker));
1910
1911 update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
1912 let h = issue_at(&layout, "sample", &first);
1913 assert_eq!(h.state, "TODO");
1914 assert!(h.blocked_by().is_empty());
1915 }
1916
1917 #[test]
1918 fn auto_unblock_to_todo_releases_the_claim() {
1919 let dir = tempfile::tempdir().unwrap();
1920 let layout = fresh_layout(dir.path());
1921 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1922 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1923 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1924 let first = doc.headings[0].id.clone();
1925 let blocker = doc.headings[1].id.clone();
1926
1927 crate::agent::claim(&layout, &first, false).unwrap();
1928 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1929 assert!(issue_at(&layout, "sample", &first).claimed_by().is_some());
1930
1931 update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
1932 let h = issue_at(&layout, "sample", &first);
1933 assert_eq!(h.state, "TODO");
1934 assert!(h.claimed_by().is_none(), "claim stuck on TODO: {h:?}");
1935 }
1936
1937 #[test]
1938 fn blocker_cycle_is_rejected_before_writing() {
1939 let dir = tempfile::tempdir().unwrap();
1940 let layout = fresh_layout(dir.path());
1941 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1942 create(&layout, "sample", "second", CreateOpts::default()).unwrap();
1943 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1944 let first = doc.headings[0].id.clone();
1945 let second = doc.headings[1].id.clone();
1946
1947 update(&layout, &first, None, None, Some(&second), None).unwrap();
1948 let err = update(&layout, &second, None, None, Some(&first), None).unwrap_err();
1949 assert!(err.to_string().contains("blocker cycle"), "{err}");
1950 assert!(issue_at(&layout, "sample", &second).blocked_by().is_empty());
1951 }
1952
1953 #[test]
1954 fn closing_a_blocker_reports_the_issues_still_pointing_at_it() {
1955 let dir = tempfile::tempdir().unwrap();
1956 let layout = fresh_layout(dir.path());
1957 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1958 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1959 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1960 let first = doc.headings[0].id.clone();
1961 let blocker = doc.headings[1].id.clone();
1962 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1963
1964 let outcome = update(&layout, &blocker, Some("DONE"), None, None, None).unwrap();
1965 assert_eq!(outcome.hints.len(), 1, "{:?}", outcome.hints);
1966 assert!(outcome.hints[0].contains(&first), "{:?}", outcome.hints);
1967 }
1968
1969 #[test]
1970 fn refile_moves_the_heading_between_projects() {
1971 let dir = tempfile::tempdir().unwrap();
1972 let layout = fresh_layout(dir.path());
1973 create(&layout, "source", "the issue", CreateOpts::default()).unwrap();
1974 let id = only_id(&layout, "source");
1975 refile(&layout, &id, "target").unwrap();
1976
1977 let src = IssueDoc::parse_file("source", &layout.project_issues_path("source")).unwrap();
1978 let tgt = IssueDoc::parse_file("target", &layout.project_issues_path("target")).unwrap();
1979 assert!(src.headings.is_empty());
1980 assert_eq!(tgt.headings[0].id, id);
1981 }
1982
1983 #[test]
1984 fn deadlines_must_parse_as_org_dates() {
1985 let dir = tempfile::tempdir().unwrap();
1986 let layout = fresh_layout(dir.path());
1987 let err = create(
1988 &layout,
1989 "sample",
1990 "bad date",
1991 CreateOpts {
1992 deadline: Some("not-a-date"),
1993 ..Default::default()
1994 },
1995 )
1996 .unwrap_err();
1997 assert!(err.to_string().contains("expected org date"));
1998
1999 for (i, d) in ["<2026-05-15 Fri>", "[2026-05-15]"].iter().enumerate() {
2000 create(
2001 &layout,
2002 "sample",
2003 &format!("issue {i}"),
2004 CreateOpts {
2005 deadline: Some(d),
2006 ..Default::default()
2007 },
2008 )
2009 .unwrap();
2010 }
2011 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2012 assert_eq!(doc.headings.len(), 2);
2013 assert!(doc.headings.iter().all(|h| h.deadline().is_some()));
2014 }
2015
2016 #[test]
2017 fn org_safe_tags_go_on_the_heading_and_the_rest_stay_in_the_property() {
2018 let dir = tempfile::tempdir().unwrap();
2019 let layout = fresh_layout(dir.path());
2020 create(
2021 &layout,
2022 "sample",
2023 "tagged",
2024 CreateOpts {
2025 tags: Some("rust: perf ,, scaling, needs-review"),
2026 ..Default::default()
2027 },
2028 )
2029 .unwrap();
2030 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2031 let h = &doc.headings[0];
2032 assert_eq!(h.org_tags, vec!["rust", "perf", "scaling"]);
2033 assert_eq!(
2034 h.properties
2035 .get(crate::model::TAGS_PROPERTY)
2036 .map(|s| s.as_str()),
2037 Some("needs-review"),
2038 "a tag Org cannot hold keeps the property"
2039 );
2040 assert_eq!(
2042 h.tags(),
2043 vec!["needs-review", "rust", "perf", "scaling"],
2044 "{h:?}"
2045 );
2046 }
2047
2048 #[test]
2049 fn create_keeps_an_explicit_id_that_is_free() {
2050 let dir = tempfile::tempdir().unwrap();
2051 let layout = fresh_layout(dir.path());
2052 create(
2053 &layout,
2054 "sample",
2055 "imported from the other board",
2056 CreateOpts {
2057 id: Some("sample-ab12"),
2058 ..Default::default()
2059 },
2060 )
2061 .unwrap();
2062 assert_eq!(only_id(&layout, "sample"), "sample-ab12");
2063 }
2064
2065 #[test]
2066 fn create_rejects_an_explicit_id_that_is_taken() {
2067 let dir = tempfile::tempdir().unwrap();
2068 let layout = fresh_layout(dir.path());
2069 let first = create(&layout, "sample", "already here", CreateOpts::default()).unwrap();
2070 let id = first.split_whitespace().next().unwrap().to_string();
2071 let err = create(
2072 &layout,
2073 "sample",
2074 "second copy",
2075 CreateOpts {
2076 id: Some(&id),
2077 ..Default::default()
2078 },
2079 )
2080 .unwrap_err();
2081 assert!(
2082 err.to_string().contains(&id),
2083 "taken id must be named: {err}"
2084 );
2085 }
2086
2087 #[test]
2088 fn create_rejects_an_explicit_id_for_another_project() {
2089 let dir = tempfile::tempdir().unwrap();
2090 let layout = fresh_layout(dir.path());
2091 let err = create(
2092 &layout,
2093 "sample",
2094 "wrong prefix",
2095 CreateOpts {
2096 id: Some("other-ab12"),
2097 ..Default::default()
2098 },
2099 )
2100 .unwrap_err();
2101 assert!(err.to_string().contains("sample-<suffix>"), "{err}");
2102 }
2103
2104 #[test]
2105 fn create_puts_a_legal_type_on_the_heading() {
2106 let dir = tempfile::tempdir().unwrap();
2107 let layout = fresh_layout(dir.path());
2108 create(
2109 &layout,
2110 "sample",
2111 "a bug",
2112 CreateOpts {
2113 issue_type: Some("bug"),
2114 ..Default::default()
2115 },
2116 )
2117 .unwrap();
2118 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2119 let h = &doc.headings[0];
2120 assert_eq!(
2121 crate::props::get(&h.properties, crate::props::TYPE),
2122 Some("bug")
2123 );
2124 assert_eq!(h.org_tags, vec!["bug"]);
2125 let written = std::fs::read_to_string(layout.project_issues_path("sample")).unwrap();
2126 assert!(written.contains("#+CATEGORY: sample"), "{written}");
2127 assert!(written.contains(":bug:"), "{written}");
2128 }
2129
2130 #[test]
2131 fn resolve_project_needs_a_name_from_somewhere() {
2132 let dir = tempfile::tempdir().unwrap();
2133 let layout = fresh_layout(dir.path());
2134 assert_eq!(
2135 resolve_project(&layout, Some("fromcli")).unwrap(),
2136 "fromcli"
2137 );
2138 assert!(
2139 resolve_project(&layout, Some(""))
2140 .unwrap_err()
2141 .to_string()
2142 .contains("empty")
2143 );
2144 }
2145
2146 #[test]
2148 fn concurrent_creates_preserve_every_heading() {
2149 use std::sync::Arc;
2150 use std::thread;
2151
2152 let dir = tempfile::tempdir().unwrap();
2153 let layout = Arc::new(fresh_layout(dir.path()));
2154 let n = 24usize;
2155 let handles: Vec<_> = (0..n)
2156 .map(|i| {
2157 let layout = Arc::clone(&layout);
2158 thread::spawn(move || {
2159 create(
2160 &layout,
2161 "sample",
2162 &format!("parallel title {i}"),
2163 CreateOpts {
2164 quiet: true,
2165 ..Default::default()
2166 },
2167 )
2168 })
2169 })
2170 .collect();
2171 let mut ids: Vec<String> = handles
2172 .into_iter()
2173 .map(|h| {
2174 h.join()
2175 .expect("thread panicked")
2176 .expect("create failed")
2177 .trim()
2178 .to_string()
2179 })
2180 .collect();
2181 ids.sort();
2182 ids.dedup();
2183 assert_eq!(ids.len(), n, "expected {n} unique ids, got {ids:?}");
2184
2185 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2186 let mut on_disk: Vec<String> = doc.headings.iter().map(|h| h.id.clone()).collect();
2187 on_disk.sort();
2188 assert_eq!(on_disk, ids);
2189 }
2190
2191 #[test]
2192 fn note_appends_to_the_logbook_and_leaves_state_alone() {
2193 let dir = tempfile::tempdir().unwrap();
2194 let layout = fresh_layout(dir.path());
2195 create(&layout, "sample", "carries a note", CreateOpts::default()).unwrap();
2196 let id = only_id(&layout, "sample");
2197
2198 let out = note(&layout, &id, "first pass done,\n \"quoted\" bit next").unwrap();
2199 assert_eq!(out, format!("{id}: noted\n"));
2200
2201 let h = issue_at(&layout, "sample", &id);
2202 assert_eq!(h.state, "TODO");
2203 assert!(h.claimed_by().is_none());
2204 let notes: Vec<&str> = h.logbook.iter().filter_map(|e| e.note.as_deref()).collect();
2205 assert_eq!(notes, vec!["first pass done, 'quoted' bit next"]);
2207 }
2208
2209 #[test]
2210 fn the_logbook_reads_newest_first_however_an_entry_arrived() {
2211 let dir = tempfile::tempdir().unwrap();
2212 let layout = fresh_layout(dir.path());
2213 create(&layout, "sample", "ordered", CreateOpts::default()).unwrap();
2214 let id = only_id(&layout, "sample");
2215
2216 note(&layout, &id, "first note").unwrap();
2217 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
2218 note(&layout, &id, "second note").unwrap();
2219
2220 let h = issue_at(&layout, "sample", &id);
2221 let summary: Vec<String> = h
2222 .logbook
2223 .iter()
2224 .map(|e| match (&e.note, &e.to_state) {
2225 (Some(note), _) => note.clone(),
2226 (_, Some(to)) => format!("state:{to}"),
2227 _ => "?".into(),
2228 })
2229 .collect();
2230 assert_eq!(
2231 summary,
2232 vec!["second note", "state:STARTED", "first note"],
2233 "{h:?}"
2234 );
2235 }
2236
2237 #[test]
2238 fn note_rejects_empty_text_and_unknown_ids() {
2239 let dir = tempfile::tempdir().unwrap();
2240 let layout = fresh_layout(dir.path());
2241 create(&layout, "sample", "target", CreateOpts::default()).unwrap();
2242 let id = only_id(&layout, "sample");
2243 assert!(note(&layout, &id, " ").is_err());
2244 assert!(note(&layout, "sample-zzz9", "text").is_err());
2245 }
2246
2247 #[test]
2248 fn fold_creates_issues_and_stamps_the_inbox_idempotently() {
2249 let dir = tempfile::tempdir().unwrap();
2250 let layout = fresh_layout(dir.path());
2251 create(&layout, "sample", "seed", CreateOpts::default()).unwrap();
2252
2253 let inbox = dir.path().join("inbox.org");
2254 fs::write(
2255 &inbox,
2256 "#+TITLE: inbox\n\n\
2257 * TODO first discovered thing\nSome body line.\nAnother line.\n\
2258 * DONE already handled elsewhere\n\
2259 * TODO second discovered thing\n",
2260 )
2261 .unwrap();
2262
2263 let out = fold(&layout, &inbox, "sample").unwrap();
2264 assert!(out.starts_with("folded 2: "), "got: {out}");
2265
2266 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2267 let titles: Vec<&str> = doc.headings.iter().map(|h| h.title.as_str()).collect();
2268 assert!(titles.contains(&"first discovered thing"));
2269 assert!(titles.contains(&"second discovered thing"));
2270 let folded = doc
2271 .headings
2272 .iter()
2273 .find(|h| h.title == "first discovered thing")
2274 .unwrap();
2275 assert!(folded.body.contains("Some body line."));
2276
2277 let stamped = fs::read_to_string(&inbox).unwrap();
2279 assert_eq!(stamped.matches("* DONE ").count(), 3);
2280 assert_eq!(stamped.matches(":VISSUE_ID: sample-").count(), 2);
2281 assert!(!stamped.contains("* TODO "));
2282
2283 let again = fold(&layout, &inbox, "sample").unwrap();
2285 assert_eq!(again, "folded 0 (nothing unstamped)\n");
2286 let doc2 = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2287 assert_eq!(doc2.headings.len(), doc.headings.len());
2288 }
2289
2290 #[test]
2291 fn refile_to_moves_across_two_layouts_and_leaves_no_shadow() {
2292 let src_dir = tempfile::tempdir().unwrap();
2293 let dst_dir = tempfile::tempdir().unwrap();
2294 let src_layout = fresh_layout(src_dir.path());
2295 let dst_layout = fresh_layout(dst_dir.path());
2296 create(&src_layout, "misc", "wrong board", CreateOpts::default()).unwrap();
2297 let id = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc"))
2298 .unwrap()
2299 .headings[0]
2300 .id
2301 .clone();
2302
2303 let out = refile_to(&src_layout, &id, &dst_layout, "surf").unwrap();
2304 assert!(out.contains("misc -> surf"), "{out}");
2305
2306 let moved = IssueDoc::parse_file("surf", &dst_layout.project_issues_path("surf")).unwrap();
2309 assert_eq!(moved.headings.len(), 1);
2310 assert_eq!(moved.headings[0].id, id);
2311 assert!(!src_layout.project_issues_path("surf").exists());
2312 let left = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc")).unwrap();
2313 assert!(left.headings.is_empty());
2314 }
2315
2316 #[test]
2317 fn reject_creates_the_successor_on_the_destination_layout() {
2318 let src_dir = tempfile::tempdir().unwrap();
2319 let dst_dir = tempfile::tempdir().unwrap();
2320 let src_layout = fresh_layout(src_dir.path());
2321 let dst_layout = fresh_layout(dst_dir.path());
2322 create(&src_layout, "misc", "old approach", CreateOpts::default()).unwrap();
2323 let src = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc"))
2324 .unwrap()
2325 .headings[0]
2326 .id
2327 .clone();
2328
2329 let twin_dir = tempfile::tempdir().unwrap();
2332 let twin_layout = fresh_layout(twin_dir.path());
2333 let twin_path = twin_layout.project_issues_path("surf");
2334 std::fs::create_dir_all(twin_path.parent().unwrap()).unwrap();
2335 std::fs::write(
2336 &twin_path,
2337 "#+TITLE: surf issues\n\n* TODO taken elsewhere\n:PROPERTIES:\n :ID: surf-aaaa\n:END:\n",
2338 )
2339 .unwrap();
2340 let twins = vec![twin_path.clone()];
2341 let out = reject(
2342 &src_layout,
2343 &src,
2344 RejectOpts {
2345 project: Some("surf"),
2346 title: Some("new approach"),
2347 dst_layout: Some(&dst_layout),
2348 dst_extra_id_paths: &twins,
2349 ..Default::default()
2350 },
2351 )
2352 .unwrap();
2353
2354 assert!(!src_layout.project_issues_path("surf").exists());
2355 let made = IssueDoc::parse_file("surf", &dst_layout.project_issues_path("surf")).unwrap();
2356 assert_eq!(made.headings.len(), 1);
2357 assert_ne!(made.headings[0].id, "surf-aaaa");
2358 assert!(out.contains(&made.headings[0].id), "{out}");
2359 assert_eq!(issue_at(&src_layout, "misc", &src).state, "CANCELLED");
2360 }
2361
2362 #[test]
2363 fn reject_to_an_existing_issue_cancels_and_wires_the_pair() {
2364 let dir = tempfile::tempdir().unwrap();
2365 let layout = fresh_layout(dir.path());
2366 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
2367 create(&layout, "sample", "new approach", CreateOpts::default()).unwrap();
2368 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2369 let src = doc.headings[0].id.clone();
2370 let dst = doc.headings[1].id.clone();
2371
2372 let out = reject(
2373 &layout,
2374 &src,
2375 RejectOpts {
2376 to: Some(&dst),
2377 ..Default::default()
2378 },
2379 )
2380 .unwrap();
2381 assert!(out.contains(&src) && out.contains(&dst), "{out}");
2382
2383 let src_h = issue_at(&layout, "sample", &src);
2384 assert_eq!(src_h.state, "CANCELLED");
2385 assert_eq!(
2386 src_h.properties.get("PIVOTED_TO").map(String::as_str),
2387 Some(dst.as_str())
2388 );
2389 let dst_h = issue_at(&layout, "sample", &dst);
2390 assert_eq!(
2391 dst_h.properties.get("DISCOVERED_FROM").map(String::as_str),
2392 Some(src.as_str())
2393 );
2394 }
2395
2396 #[test]
2397 fn reject_creates_the_destination_in_another_project() {
2398 let dir = tempfile::tempdir().unwrap();
2399 let layout = fresh_layout(dir.path());
2400 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
2401 let src = only_id(&layout, "sample");
2402
2403 let out = reject(
2404 &layout,
2405 &src,
2406 RejectOpts {
2407 project: Some("other"),
2408 title: Some("new approach"),
2409 ..Default::default()
2410 },
2411 )
2412 .unwrap();
2413
2414 let dst_doc = IssueDoc::parse_file("other", &layout.project_issues_path("other")).unwrap();
2415 assert_eq!(dst_doc.headings.len(), 1);
2416 let dst = &dst_doc.headings[0];
2417 assert_eq!(dst.title, "new approach");
2418 assert_eq!(
2419 dst.properties.get("DISCOVERED_FROM").map(String::as_str),
2420 Some(src.as_str())
2421 );
2422 assert!(out.contains(&src) && out.contains(&dst.id), "{out}");
2423
2424 let src_h = issue_at(&layout, "sample", &src);
2425 assert_eq!(src_h.state, "CANCELLED");
2426 assert_eq!(
2427 src_h.properties.get("PIVOTED_TO").map(String::as_str),
2428 Some(dst.id.as_str())
2429 );
2430 }
2431
2432 #[test]
2433 fn reject_refuses_an_unknown_source_or_destination() {
2434 let dir = tempfile::tempdir().unwrap();
2435 let layout = fresh_layout(dir.path());
2436 create(&layout, "sample", "only", CreateOpts::default()).unwrap();
2437 let src = only_id(&layout, "sample");
2438
2439 let missing_src = reject(
2440 &layout,
2441 "sample-zzzz",
2442 RejectOpts {
2443 to: Some(&src),
2444 ..Default::default()
2445 },
2446 )
2447 .unwrap_err();
2448 assert!(
2449 matches!(missing_src, Error::IssueNotFound { .. }),
2450 "{missing_src}"
2451 );
2452
2453 let missing_dst = reject(
2454 &layout,
2455 &src,
2456 RejectOpts {
2457 to: Some("sample-zzzz"),
2458 ..Default::default()
2459 },
2460 )
2461 .unwrap_err();
2462 assert!(
2463 matches!(missing_dst, Error::IssueNotFound { .. }),
2464 "{missing_dst}"
2465 );
2466 }
2467
2468 #[test]
2469 fn reject_does_not_overwrite_a_nonempty_discovered_from() {
2470 let dir = tempfile::tempdir().unwrap();
2471 let layout = fresh_layout(dir.path());
2472 create(&layout, "sample", "origin", CreateOpts::default()).unwrap();
2473 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
2474 create(&layout, "sample", "already sourced", CreateOpts::default()).unwrap();
2475 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2476 let origin = doc.headings[0].id.clone();
2477 let src = doc.headings[1].id.clone();
2478 let dst = doc.headings[2].id.clone();
2479
2480 let path = layout.project_issues_path("sample");
2481 let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
2482 doc.headings
2483 .iter_mut()
2484 .find(|h| h.id == dst)
2485 .unwrap()
2486 .properties
2487 .insert("DISCOVERED_FROM".into(), origin.clone());
2488 doc.write().unwrap();
2489
2490 reject(
2491 &layout,
2492 &src,
2493 RejectOpts {
2494 to: Some(&dst),
2495 ..Default::default()
2496 },
2497 )
2498 .unwrap();
2499 let dst_h = issue_at(&layout, "sample", &dst);
2500 assert_eq!(
2501 dst_h.properties.get("DISCOVERED_FROM").map(String::as_str),
2502 Some(origin.as_str()),
2503 "a filled DISCOVERED_FROM stays put"
2504 );
2505 }
2506
2507 #[test]
2508 fn create_sets_discovered_from_from_the_first_known_id_link() {
2509 let dir = tempfile::tempdir().unwrap();
2510 let layout = fresh_layout(dir.path());
2511 create(&layout, "sample", "source", CreateOpts::default()).unwrap();
2512 let known = only_id(&layout, "sample");
2513 create(
2514 &layout,
2515 "sample",
2516 "fell out of it",
2517 CreateOpts {
2518 body: Some(&format!("See [[id:{known}]] for the parent finding.")),
2519 ..Default::default()
2520 },
2521 )
2522 .unwrap();
2523 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2524 let child = doc
2525 .headings
2526 .iter()
2527 .find(|h| h.title == "fell out of it")
2528 .unwrap();
2529 assert_eq!(
2530 child.properties.get("DISCOVERED_FROM").map(String::as_str),
2531 Some(known.as_str())
2532 );
2533 }
2534
2535 #[test]
2536 fn create_ignores_an_id_link_that_is_not_in_the_corpus() {
2537 let dir = tempfile::tempdir().unwrap();
2538 let layout = fresh_layout(dir.path());
2539 create(
2540 &layout,
2541 "sample",
2542 "orphan mention",
2543 CreateOpts {
2544 body: Some("See [[id:sample-zzzz]] which does not exist."),
2545 ..Default::default()
2546 },
2547 )
2548 .unwrap();
2549 let h = issue_at(&layout, "sample", &only_id(&layout, "sample"));
2550 assert!(
2551 !h.properties.contains_key("DISCOVERED_FROM"),
2552 "unknown [[id:]] must not mint DISCOVERED_FROM: {h:?}"
2553 );
2554 assert!(
2555 !h.properties.contains_key("BLOCKED_BY"),
2556 "prose must not mint BLOCKED_BY: {h:?}"
2557 );
2558 }
2559
2560 #[test]
2561 fn related_after_reject_names_the_successor_without_a_body_link() {
2562 let dir = tempfile::tempdir().unwrap();
2563 let layout = fresh_layout(dir.path());
2564 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
2565 create(&layout, "sample", "new approach", CreateOpts::default()).unwrap();
2566 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2567 let src = doc.headings[0].id.clone();
2568 let dst = doc.headings[1].id.clone();
2569 reject(
2570 &layout,
2571 &src,
2572 RejectOpts {
2573 to: Some(&dst),
2574 ..Default::default()
2575 },
2576 )
2577 .unwrap();
2578
2579 assert!(
2580 !issue_at(&layout, "sample", &src).body.contains(&dst),
2581 "the pair is wired by PIVOTED_TO, not prose"
2582 );
2583 let from_src = crate::related::related(&layout, &src, 1, 10, "text").unwrap();
2584 assert!(from_src.contains(&dst), "{from_src}");
2585 assert!(from_src.contains("pivoted_to"), "{from_src}");
2586
2587 let from_dst = crate::related::related(&layout, &dst, 1, 10, "text").unwrap();
2588 assert!(from_dst.contains(&src), "{from_dst}");
2589 assert!(from_dst.contains("successor_of"), "{from_dst}");
2590
2591 let waiting = crate::report::backlinks(&layout, &dst).unwrap();
2592 assert!(waiting.contains(&src), "{waiting}");
2593 }
2594
2595 #[test]
2596 fn update_to_cancelled_emits_state_change_with_the_id() {
2597 let dir = tempfile::tempdir().unwrap();
2598 let layout = fresh_layout(dir.path());
2599 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
2600 let id = only_id(&layout, "sample");
2601 let before = crate::events::generation(&layout);
2602 update(&layout, &id, Some("CANCELLED"), None, None, None).unwrap();
2603 let events = crate::events::since(&layout, before, 50).unwrap();
2604 assert!(
2605 events.iter().any(|e| {
2606 e.kind == "state_change"
2607 && e.id.as_deref() == Some(id.as_str())
2608 && e.detail.as_deref() == Some("TODO->CANCELLED")
2609 }),
2610 "{events:?}"
2611 );
2612 }
2613
2614 #[test]
2615 fn a_stale_done_after_reject_is_refused_and_the_source_stays_cancelled() {
2616 let dir = tempfile::tempdir().unwrap();
2617 let layout = fresh_layout(dir.path());
2618 create(&layout, "sample", "old plan", CreateOpts::default()).unwrap();
2619 create(&layout, "sample", "rewrite", CreateOpts::default()).unwrap();
2620 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2621 let src = doc.headings[0].id.clone();
2622 let dst = doc.headings[1].id.clone();
2623 reject(
2624 &layout,
2625 &src,
2626 RejectOpts {
2627 to: Some(&dst),
2628 ..Default::default()
2629 },
2630 )
2631 .unwrap();
2632
2633 let err = update_pred(
2634 &layout,
2635 &src,
2636 Some("DONE"),
2637 None,
2638 None,
2639 None,
2640 UpdatePred {
2641 if_state: Some("STARTED"),
2642 if_gen: None,
2643 },
2644 )
2645 .unwrap_err();
2646 assert!(
2647 matches!(
2648 err,
2649 Error::StaleWrite {
2650 ref actual_state,
2651 ref expected_state,
2652 ..
2653 } if actual_state == "CANCELLED" && expected_state.as_deref() == Some("STARTED")
2654 ),
2655 "{err:?}"
2656 );
2657 assert_eq!(issue_at(&layout, "sample", &src).state, "CANCELLED");
2658 }
2659
2660 #[test]
2661 fn if_gen_refuses_when_the_corpus_moved() {
2662 let dir = tempfile::tempdir().unwrap();
2663 let layout = fresh_layout(dir.path());
2664 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
2665 let id = only_id(&layout, "sample");
2666 let seen = crate::events::generation(&layout);
2667 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
2668 let err = update_pred(
2669 &layout,
2670 &id,
2671 Some("DONE"),
2672 None,
2673 None,
2674 None,
2675 UpdatePred {
2676 if_state: None,
2677 if_gen: Some(seen),
2678 },
2679 )
2680 .unwrap_err();
2681 assert!(matches!(err, Error::StaleWrite { .. }), "{err:?}");
2682 assert_eq!(issue_at(&layout, "sample", &id).state, "STARTED");
2683 }
2684
2685 #[test]
2686 fn a_second_terminal_does_not_drop_the_first() {
2687 let dir = tempfile::tempdir().unwrap();
2688 let layout = fresh_layout(dir.path());
2689 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
2690 let id = only_id(&layout, "sample");
2691 update(&layout, &id, Some("DONE"), None, None, None).unwrap();
2692 update(&layout, &id, Some("CANCELLED"), None, None, None).unwrap();
2693 let h = issue_at(&layout, "sample", &id);
2694 assert_eq!(h.state, "DONE", "first terminal must stay");
2695 assert_eq!(
2696 crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL),
2697 Some("CANCELLED")
2698 );
2699
2700 resolve_terminal(&layout, &id, "CANCELLED").unwrap();
2701 let h = issue_at(&layout, "sample", &id);
2702 assert_eq!(h.state, "CANCELLED");
2703 assert!(crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_none());
2704 }
2705
2706 #[test]
2707 fn check_warns_on_reject_prose_done_and_a_mention_without_an_edge() {
2708 let dir = tempfile::tempdir().unwrap();
2709 let layout = fresh_layout(dir.path());
2710 create(&layout, "sample", "shipped", CreateOpts::default()).unwrap();
2711 create(&layout, "sample", "other", CreateOpts::default()).unwrap();
2712 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2713 let shipped = doc.headings[0].id.clone();
2714 let other = doc.headings[1].id.clone();
2715 update(&layout, &shipped, Some("DONE"), None, None, None).unwrap();
2716 append_body(&layout, &shipped, "superseded by the other one, bounced").unwrap();
2717 append_body(
2718 &layout,
2719 &other,
2720 &format!("discovered while reading [[id:{shipped}]]"),
2721 )
2722 .unwrap();
2723
2724 let report = crate::report::check(&layout).unwrap();
2725 assert!(
2726 report.text.contains(&shipped)
2727 && report.text.contains("DONE but the body reads as a reject"),
2728 "{}",
2729 report.text
2730 );
2731 assert!(
2732 report.text.contains(&other)
2733 && report
2734 .text
2735 .contains("as discovered or pivoted with no edge"),
2736 "{}",
2737 report.text
2738 );
2739 assert!(report.warnings >= 2, "{}", report.text);
2740 }
2741
2742 #[test]
2746 fn check_is_quiet_about_a_done_issue_that_merely_uses_the_word_rejected() {
2747 let dir = tempfile::tempdir().unwrap();
2748 let layout = fresh_layout(dir.path());
2749 create(&layout, "sample", "validation", CreateOpts::default()).unwrap();
2750 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2751 let id = doc.headings[0].id.clone();
2752 update(&layout, &id, Some("DONE"), None, None, None).unwrap();
2753 append_body(
2754 &layout,
2755 &id,
2756 "A compound spec is silently corrupted rather than rejected, and the \
2757 alternative parser was rejected as strictly dominated.",
2758 )
2759 .unwrap();
2760
2761 let report = crate::report::check(&layout).unwrap();
2762 assert!(
2763 !report.text.contains("reads as a reject"),
2764 "the word alone was read as an outcome: {}",
2765 report.text
2766 );
2767 }
2768
2769 #[test]
2772 fn check_reads_supersedes_as_a_roll_up_and_superseded_by_as_an_outcome() {
2773 let dir = tempfile::tempdir().unwrap();
2774 let layout = fresh_layout(dir.path());
2775 create(&layout, "sample", "umbrella", CreateOpts::default()).unwrap();
2776 create(&layout, "sample", "replaced", CreateOpts::default()).unwrap();
2777 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2778 let rollup = doc.headings[0].id.clone();
2779 let replaced = doc.headings[1].id.clone();
2780 update(&layout, &rollup, Some("DONE"), None, None, None).unwrap();
2781 update(&layout, &replaced, Some("DONE"), None, None, None).unwrap();
2782 append_body(&layout, &rollup, "** Supersedes\nrolls up the pieces").unwrap();
2783 append_body(&layout, &replaced, "superseded by the umbrella").unwrap();
2784
2785 let report = crate::report::check(&layout).unwrap();
2786 let flagged: Vec<&str> = report
2787 .text
2788 .lines()
2789 .filter(|l| l.contains("reads as a reject"))
2790 .collect();
2791
2792 assert!(
2793 flagged.iter().any(|l| l.contains(&replaced)),
2794 "an issue that says it was superseded was not flagged: {}",
2795 report.text
2796 );
2797 assert!(
2798 !flagged.iter().any(|l| l.contains(&rollup)),
2799 "a Supersedes roll-up was read as its own rejection: {}",
2800 report.text
2801 );
2802 }
2803
2804 #[test]
2807 fn check_is_quiet_about_a_mention_that_claims_no_relation() {
2808 let dir = tempfile::tempdir().unwrap();
2809 let layout = fresh_layout(dir.path());
2810 create(&layout, "sample", "umbrella", CreateOpts::default()).unwrap();
2811 create(&layout, "sample", "piece", CreateOpts::default()).unwrap();
2812 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2813 let umbrella = doc.headings[0].id.clone();
2814 let piece = doc.headings[1].id.clone();
2815 append_body(
2816 &layout,
2817 &umbrella,
2818 &format!("** Supersedes\nRolls up [[id:{piece}]], which it does not close."),
2819 )
2820 .unwrap();
2821
2822 let report = crate::report::check(&layout).unwrap();
2823 assert!(
2824 !report.text.contains("as discovered or pivoted"),
2825 "a roll-up was read as a discovery: {}",
2826 report.text
2827 );
2828 }
2829
2830 #[test]
2832 fn check_reads_a_discovery_claim_only_near_the_link_it_belongs_to() {
2833 let dir = tempfile::tempdir().unwrap();
2834 let layout = fresh_layout(dir.path());
2835 create(&layout, "sample", "long", CreateOpts::default()).unwrap();
2836 create(&layout, "sample", "elsewhere", CreateOpts::default()).unwrap();
2837 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2838 let long = doc.headings[0].id.clone();
2839 let elsewhere = doc.headings[1].id.clone();
2840 let filler = "prose ".repeat(120);
2841 append_body(
2842 &layout,
2843 &long,
2844 &format!("discovered while auditing the loader.\n{filler}\nsee [[id:{elsewhere}]]"),
2845 )
2846 .unwrap();
2847
2848 let report = crate::report::check(&layout).unwrap();
2849 assert!(
2850 !report.text.contains("as discovered or pivoted"),
2851 "a claim in another section was attached to this link: {}",
2852 report.text
2853 );
2854 }
2855
2856 #[test]
2858 fn check_is_quiet_about_a_mention_that_a_parent_edge_already_explains() {
2859 let dir = tempfile::tempdir().unwrap();
2860 let layout = fresh_layout(dir.path());
2861 create(&layout, "sample", "umbrella", CreateOpts::default()).unwrap();
2862 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2863 let parent = doc.headings[0].id.clone();
2864 create(
2865 &layout,
2866 "sample",
2867 "piece",
2868 CreateOpts {
2869 parent: Some(parent.as_str()),
2870 ..CreateOpts::default()
2871 },
2872 )
2873 .unwrap();
2874 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2875 let child = doc
2876 .headings
2877 .iter()
2878 .find(|h| h.id != parent)
2879 .map(|h| h.id.clone())
2880 .unwrap();
2881 append_body(
2884 &layout,
2885 &parent,
2886 &format!("discovered while reading [[id:{child}]]"),
2887 )
2888 .unwrap();
2889 create(&layout, "sample", "unrelated", CreateOpts::default()).unwrap();
2890 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2891 let stranger = doc
2892 .headings
2893 .iter()
2894 .find(|h| h.id != parent && h.id != child)
2895 .map(|h| h.id.clone())
2896 .unwrap();
2897 append_body(
2898 &layout,
2899 &stranger,
2900 &format!("discovered while reading [[id:{parent}]]"),
2901 )
2902 .unwrap();
2903
2904 let report = crate::report::check(&layout).unwrap();
2905 let flagged: Vec<&str> = report
2906 .text
2907 .lines()
2908 .filter(|l| l.contains("as discovered or pivoted"))
2909 .collect();
2910 assert!(
2911 flagged.iter().any(|l| l.contains(&stranger)),
2912 "the control pair with no edge was not flagged, so this test proves nothing: {}",
2913 report.text
2914 );
2915 assert!(
2916 !flagged
2917 .iter()
2918 .any(|l| l.contains(&parent) && l.contains(&child)),
2919 "a parent edge did not count as a relation: {}",
2920 report.text
2921 );
2922 }
2923
2924 #[test]
2925 fn check_names_a_file_missing_category_and_a_type_not_on_the_heading() {
2926 let dir = tempfile::tempdir().unwrap();
2927 let layout = fresh_layout(dir.path());
2928 let path = layout.project_issues_path("sample");
2929 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2930 std::fs::write(
2931 &path,
2932 "#+TITLE: sample issues\n#+TODO: TODO STARTED BLOCKED | DONE CANCELLED\n\n* TODO [#A] Untagged type\n:PROPERTIES:\n:ID: sample-aaaa\n:TYPE: bug\n:END:\n",
2933 )
2934 .unwrap();
2935 let report = crate::report::check(&layout).unwrap();
2936 assert!(
2937 report.text.contains("sample: preamble has no #+CATEGORY:"),
2938 "{}",
2939 report.text
2940 );
2941 assert!(
2942 report
2943 .text
2944 .contains("have :TYPE: that is a legal Org tag but is not on the heading"),
2945 "{}",
2946 report.text
2947 );
2948 assert!(
2949 report
2950 .text
2951 .contains("preamble has no #+VISSUE: protocol stamp"),
2952 "{}",
2953 report.text
2954 );
2955 assert!(
2956 report.text.contains("preamble has no #+PRIORITIES:"),
2957 "{}",
2958 report.text
2959 );
2960 }
2961
2962 #[test]
2963 fn check_errors_on_a_newer_protocol_stamp() {
2964 let dir = tempfile::tempdir().unwrap();
2965 let layout = fresh_layout(dir.path());
2966 let path = layout.project_issues_path("sample");
2967 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2968 std::fs::write(
2969 &path,
2970 "#+TITLE: sample issues\n#+VISSUE: 99\n#+CATEGORY: sample\n#+FILETAGS: :issues:sample:noexport:\n#+TAGS: docs\n#+TODO: TODO | DONE\n\n* TODO [#A] Future\n:PROPERTIES:\n:ID: sample-aaaa\n:END:\n",
2971 )
2972 .unwrap();
2973 let report = crate::report::check(&layout).unwrap();
2974 assert!(report.errors >= 1, "{}", report.text);
2975 assert!(
2976 report
2977 .text
2978 .contains("#+VISSUE: 99 is newer than this vissue"),
2979 "{}",
2980 report.text
2981 );
2982 }
2983
2984 #[test]
2985 fn normalize_rewrites_legacy_keys_and_keeps_edna() {
2986 let dir = tempfile::tempdir().unwrap();
2987 let layout = fresh_layout(dir.path());
2988 let path = layout.project_issues_path("sample");
2989 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2990 std::fs::write(
2991 &path,
2992 "#+TITLE: sample issues\n#+TODO: TODO STARTED BLOCKED | DONE CANCELLED\n\n* TODO [#A] Legacy\n:PROPERTIES:\n:ID: sample-aaaa\n:TYPE: bug\n:PARENT: sample-root\n:BLOCKEDBY: sample-bbbb\n:END:\n\n* TODO [#A] Edna condition\n:PROPERTIES:\n:ID: sample-cccc\n:BLOCKER: prev-sibling\n:END:\n",
2993 )
2994 .unwrap();
2995 let dry = normalize(&layout, Some("sample"), true).unwrap();
2996 assert!(dry.contains("would rewrite"), "{dry}");
2997 let on_disk = std::fs::read_to_string(&path).unwrap();
2998 assert!(on_disk.contains(":TYPE:"), "{on_disk}");
2999 let wrote = normalize(&layout, Some("sample"), false).unwrap();
3000 assert!(wrote.contains("rewrote"), "{wrote}");
3001 let after = std::fs::read_to_string(&path).unwrap();
3002 assert!(after.contains("#+CATEGORY: sample"), "{after}");
3003 assert!(after.contains("#+PRIORITIES: A C C"), "{after}");
3004 assert!(after.contains(":TYPE: bug"), "{after}");
3005 assert!(after.contains(":PARENT:"), "{after}");
3006 assert!(after.contains(":BLOCKED_BY:"), "{after}");
3007 assert!(
3008 !after.contains("ids(sample-bbbb)"),
3009 "normalize must not mint edna ids(): {after}"
3010 );
3011 assert!(after.contains("prev-sibling"), "{after}");
3012 }
3013 #[test]
3017 fn the_reservation_is_read_after_the_lock_is_held() {
3018 let dir = tempfile::tempdir().unwrap();
3019 let own_root = dir.path().join("own");
3020 let twin_root = dir.path().join("twin");
3021 std::fs::create_dir_all(&own_root).unwrap();
3022 std::fs::create_dir_all(&twin_root).unwrap();
3023 std::fs::write(own_root.join("vissue.toml"), "[issues]\nid_length = 2\n").unwrap();
3024 let own = fresh_layout(&own_root);
3025 let twin = fresh_layout(&twin_root);
3026
3027 let mut body = String::from("#+TITLE: sample issues\n\n");
3029 let alphabet = b"0123456789abcdefghijklmnopqrstuvwxyz";
3030 for a in alphabet {
3031 for b in alphabet {
3032 if *a == b'z' && *b == b'z' {
3033 continue;
3034 }
3035 let id = format!("sample-{}{}", *a as char, *b as char);
3036 body.push_str(&format!(
3037 "* TODO filler {id}\n:PROPERTIES:\n:ID: {id}\n:END:\n\n"
3038 ));
3039 }
3040 }
3041 let twin_path = twin.project_issues_path("sample");
3042 std::fs::create_dir_all(twin_path.parent().unwrap()).unwrap();
3043 std::fs::write(&twin_path, body).unwrap();
3044
3045 let twins = vec![twin_path.clone()];
3046 let id = create(
3047 &own,
3048 "sample",
3049 "the only suffix left",
3050 CreateOpts {
3051 quiet: true,
3052 extra_id_paths: &twins,
3053 ..Default::default()
3054 },
3055 )
3056 .expect("create failed")
3057 .trim()
3058 .to_string();
3059
3060 assert_eq!(
3061 id, "sample-zz",
3062 "the mint did not treat the twin file as taken, so it read the reservation \
3063 before the lock rather than after"
3064 );
3065 }
3066
3067 #[test]
3069 fn the_written_file_appearing_in_its_own_reservation_is_not_a_deadlock() {
3070 let dir = tempfile::tempdir().unwrap();
3071 let layout = fresh_layout(dir.path());
3072 let own_path = layout.project_issues_path("sample");
3073 let twins = vec![own_path.clone(), own_path.clone()];
3074 let id = create(
3075 &layout,
3076 "sample",
3077 "self referential reservation",
3078 CreateOpts {
3079 quiet: true,
3080 extra_id_paths: &twins,
3081 ..Default::default()
3082 },
3083 )
3084 .expect("create deadlocked or failed")
3085 .trim()
3086 .to_string();
3087 assert!(id.starts_with("sample-"), "{id}");
3088 }
3089 fn voted(layout: &Layout, id: &str, who: &str, choice: &str) -> String {
3092 vote(layout, id, Some(choice), who).expect("vote failed")
3093 }
3094
3095 #[test]
3096 fn one_agent_one_ballot_and_a_recast_replaces_it() {
3097 let dir = tempfile::tempdir().unwrap();
3098 let layout = fresh_layout(dir.path());
3099 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3100 let id = only_id(&layout, "sample");
3101
3102 voted(&layout, &id, "agent-a", "ship");
3103 let out = voted(&layout, &id, "agent-a", "hold");
3104 assert!(out.contains("changed ship to hold"), "{out}");
3105
3106 let tally = vote(&layout, &id, None, "reader").unwrap();
3107 assert!(tally.contains("1 vote from 1 option"), "{tally}");
3108 assert!(tally.contains("hold"), "{tally}");
3109 assert!(!tally.contains("ship"), "{tally}");
3110 }
3111
3112 #[test]
3113 fn two_agents_do_not_overwrite_each_other() {
3114 let dir = tempfile::tempdir().unwrap();
3115 let layout = fresh_layout(dir.path());
3116 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3117 let id = only_id(&layout, "sample");
3118
3119 voted(&layout, &id, "agent-a", "ship");
3120 voted(&layout, &id, "agent-b", "ship");
3121 let out = voted(&layout, &id, "agent-c", "hold");
3122
3123 assert!(out.contains("3 votes from 2 options"), "{out}");
3124 assert!(out.contains("consensus: ship (2 of 3)"), "{out}");
3125 }
3126
3127 #[test]
3130 fn a_tie_is_reported_as_no_consensus() {
3131 let dir = tempfile::tempdir().unwrap();
3132 let layout = fresh_layout(dir.path());
3133 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3134 let id = only_id(&layout, "sample");
3135
3136 voted(&layout, &id, "agent-a", "ship");
3137 let out = voted(&layout, &id, "agent-b", "hold");
3138
3139 assert!(out.contains("no consensus: 2 options tied at 1"), "{out}");
3140 assert!(!out.contains("consensus: ship"), "{out}");
3141 }
3142
3143 #[test]
3146 fn a_lead_short_of_a_majority_is_not_called_consensus() {
3147 let dir = tempfile::tempdir().unwrap();
3148 let layout = fresh_layout(dir.path());
3149 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3150 let id = only_id(&layout, "sample");
3151
3152 voted(&layout, &id, "agent-a", "ship");
3153 voted(&layout, &id, "agent-b", "ship");
3154 voted(&layout, &id, "agent-c", "hold");
3155 let out = voted(&layout, &id, "agent-d", "rework");
3156
3157 assert!(out.contains("plurality only: ship (2 of 4)"), "{out}");
3159 assert!(!out.contains("consensus: ship"), "{out}");
3160 }
3161
3162 #[test]
3163 fn votes_survive_a_rewrite_and_are_readable_in_the_file() {
3164 let dir = tempfile::tempdir().unwrap();
3165 let layout = fresh_layout(dir.path());
3166 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3167 let id = only_id(&layout, "sample");
3168 voted(&layout, &id, "agent-a", "ship");
3169
3170 append_body(&layout, &id, "some prose").unwrap();
3172 let text = std::fs::read_to_string(layout.project_issues_path("sample")).unwrap();
3173 assert!(text.contains(":VOTES:"), "{text}");
3174 assert!(text.contains("agent-a: ship"), "{text}");
3175
3176 let tally = vote(&layout, &id, None, "reader").unwrap();
3177 assert!(tally.contains("agent-a"), "{tally}");
3178 }
3179
3180 #[test]
3181 fn an_issue_with_no_votes_says_so_rather_than_showing_an_empty_table() {
3182 let dir = tempfile::tempdir().unwrap();
3183 let layout = fresh_layout(dir.path());
3184 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3185 let id = only_id(&layout, "sample");
3186 assert!(
3187 vote(&layout, &id, None, "reader")
3188 .unwrap()
3189 .contains("no votes")
3190 );
3191 }
3192
3193 #[test]
3194 fn a_blank_or_multiline_vote_is_refused() {
3195 let dir = tempfile::tempdir().unwrap();
3196 let layout = fresh_layout(dir.path());
3197 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3198 let id = only_id(&layout, "sample");
3199 assert!(vote(&layout, &id, Some(" "), "agent-a").is_err());
3200 assert!(vote(&layout, &id, Some("ship\nhold"), "agent-a").is_err());
3201 }
3202
3203 #[test]
3206 fn a_choice_containing_a_colon_round_trips() {
3207 let dir = tempfile::tempdir().unwrap();
3208 let layout = fresh_layout(dir.path());
3209 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3210 let id = only_id(&layout, "sample");
3211 voted(&layout, &id, "agent-a", "ship: after the audit");
3212 let tally = vote(&layout, &id, None, "reader").unwrap();
3213 assert!(tally.contains("ship: after the audit"), "{tally}");
3214 }
3215
3216 #[test]
3219 fn concurrent_voters_all_land() {
3220 use std::sync::Arc;
3221 use std::thread;
3222
3223 let dir = tempfile::tempdir().unwrap();
3224 let layout = Arc::new(fresh_layout(dir.path()));
3225 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3226 let id = only_id(&layout, "sample");
3227
3228 let n = 16usize;
3229 let handles: Vec<_> = (0..n)
3230 .map(|i| {
3231 let layout = Arc::clone(&layout);
3232 let id = id.clone();
3233 thread::spawn(move || vote(&layout, &id, Some("ship"), &format!("agent-{i:02}")))
3234 })
3235 .collect();
3236 for h in handles {
3237 h.join().expect("thread panicked").expect("vote failed");
3238 }
3239
3240 let tally = vote(&layout, &id, None, "reader").unwrap();
3241 assert!(
3242 tally.contains(&format!("{n} votes from 1 option")),
3243 "a ballot was lost: {tally}"
3244 );
3245 }
3246 #[test]
3249 fn a_single_ballot_is_not_called_a_consensus() {
3250 let dir = tempfile::tempdir().unwrap();
3251 let layout = fresh_layout(dir.path());
3252 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3253 let id = only_id(&layout, "sample");
3254
3255 let out = voted(&layout, &id, "agent-a", "ship");
3256 assert!(out.contains("one ballot only: ship"), "{out}");
3257 assert!(!out.contains("consensus: ship"), "{out}");
3258
3259 let out = voted(&layout, &id, "agent-b", "ship");
3261 assert!(out.contains("consensus: ship (2 of 2)"), "{out}");
3262 }
3263
3264 #[test]
3266 fn an_identity_that_the_line_format_cannot_hold_is_refused() {
3267 let dir = tempfile::tempdir().unwrap();
3268 let layout = fresh_layout(dir.path());
3269 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3270 let id = only_id(&layout, "sample");
3271
3272 let err = vote(&layout, &id, Some("ship"), "team: alpha").unwrap_err();
3273 assert!(err.to_string().contains("colon"), "{err}");
3274 assert!(vote(&layout, &id, Some("ship"), " ").is_err());
3275
3276 assert!(
3278 vote(&layout, &id, None, "reader")
3279 .unwrap()
3280 .contains("no votes")
3281 );
3282 }
3283
3284 #[test]
3288 fn a_hand_written_line_in_the_drawer_survives_a_vote() {
3289 let dir = tempfile::tempdir().unwrap();
3290 let layout = fresh_layout(dir.path());
3291 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3292 let id = only_id(&layout, "sample");
3293 voted(&layout, &id, "agent-a", "ship");
3294
3295 let path = layout.project_issues_path("sample");
3297 let text = std::fs::read_to_string(&path).unwrap();
3298 let edited = text.replace(
3299 ":VOTES:\n",
3300 ":VOTES:\n# decided at the Tuesday review, do not clear\n",
3301 );
3302 std::fs::write(&path, edited).unwrap();
3303
3304 voted(&layout, &id, "agent-b", "hold");
3305
3306 let after = std::fs::read_to_string(&path).unwrap();
3307 assert!(
3308 after.contains("# decided at the Tuesday review, do not clear"),
3309 "the hand-written line was eaten: {after}"
3310 );
3311 assert!(after.contains("agent-a: ship"), "{after}");
3312 assert!(after.contains("agent-b: hold"), "{after}");
3313 }
3314
3315 #[cfg(unix)]
3318 #[test]
3319 fn one_file_named_two_ways_is_locked_once() {
3320 let dir = tempfile::tempdir().unwrap();
3321 let layout = fresh_layout(dir.path());
3322 let direct = layout.project_issues_path("sample");
3323 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
3324
3325 let link = dir.path().join("linked");
3327 std::os::unix::fs::symlink(dir.path().join(DEFAULT_PREFIX), &link).unwrap();
3328 let indirect = link.join("sample").join("issues.org");
3329 assert!(indirect.exists(), "the link does not reach the file");
3330 assert_ne!(
3331 direct.components().count(),
3332 0,
3333 "the two paths must differ by components or this proves nothing"
3334 );
3335 assert!(
3336 direct != indirect,
3337 "the two paths compare equal, so the plain dedup would already collapse them"
3338 );
3339
3340 let twins = vec![direct.clone(), indirect];
3341 let id = create(
3342 &layout,
3343 "sample",
3344 "second",
3345 CreateOpts {
3346 quiet: true,
3347 extra_id_paths: &twins,
3348 ..Default::default()
3349 },
3350 )
3351 .expect("create hung or failed on an aliased lock path")
3352 .trim()
3353 .to_string();
3354 assert!(id.starts_with("sample-"), "{id}");
3355 }
3356
3357 #[test]
3361 fn two_hand_written_lines_for_one_agent_collapse_to_the_last() {
3362 let dir = tempfile::tempdir().unwrap();
3363 let layout = fresh_layout(dir.path());
3364 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3365 let id = only_id(&layout, "sample");
3366 voted(&layout, &id, "agent-b", "hold");
3367
3368 let path = layout.project_issues_path("sample");
3369 let text = std::fs::read_to_string(&path).unwrap();
3370 let edited = text.replace(
3371 ":VOTES:\n",
3372 ":VOTES:\n[2026-01-01 Thu] agent-a: ship\n[2026-02-02 Mon] agent-a: rework\n",
3373 );
3374 std::fs::write(&path, edited).unwrap();
3375
3376 let tally = vote(&layout, &id, None, "reader").unwrap();
3377 assert!(tally.contains("2 votes from 2 options"), "{tally}");
3379 assert!(tally.contains("rework"), "{tally}");
3380 assert!(!tally.contains("ship"), "{tally}");
3381
3382 voted(&layout, &id, "agent-c", "hold");
3384 let after = std::fs::read_to_string(&path).unwrap();
3385 assert_eq!(after.matches("agent-a:").count(), 1, "{after}");
3386 }
3387}