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