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