1use anyhow::{Context, anyhow};
4
5use crate::error::Result;
6use chrono::NaiveDate;
7use std::collections::BTreeMap;
8use std::fmt::Write as _;
9use std::path::{Path, PathBuf};
10
11use crate::config::{Layout, VissueConfig};
12use crate::error::Error;
13use crate::graph::DependencyGraph;
14use crate::model::{IssueHeading, LogEntry, TODO_KEYWORDS, today_inactive_bracket};
15use crate::store::{
16 IssueDoc, collect_org_ids, detect_project_from_ctx, find_by_id, generate_id, load_all,
17 resolve_existing_project_case, with_issues_lock, with_issues_locks,
18};
19
20pub fn resolve_project(layout: &Layout, explicit: Option<&str>) -> Result<String> {
30 if let Some(p) = explicit {
31 if p.is_empty() {
32 return Err(anyhow!("--project given but empty").into());
33 }
34 return resolve_existing_project_case(layout, p);
35 }
36 let cwd = std::env::current_dir()?;
37 let detected = detect_project_from_ctx(&cwd).ok_or_else(|| {
38 anyhow!(
39 "no --project given and no .project-ctx.toml found walking up from {}",
40 cwd.display()
41 )
42 })?;
43 resolve_existing_project_case(layout, &detected)
44}
45
46#[derive(Debug, Default, Clone, Copy)]
48pub struct CreateOpts<'a> {
49 pub priority: Option<char>,
51 pub issue_type: Option<&'a str>,
53 pub deadline: Option<&'a str>,
55 pub scheduled: Option<&'a str>,
57 pub tags: Option<&'a str>,
59 pub parent: Option<&'a str>,
61 pub quiet: bool,
63 pub body: Option<&'a str>,
65 pub extra_ids: &'a [String],
68 pub extra_id_paths: &'a [PathBuf],
81}
82
83pub fn create(layout: &Layout, project: &str, title: &str, opts: CreateOpts<'_>) -> Result<String> {
95 let project = resolve_existing_project_case(layout, project)?;
96 let cfg = VissueConfig::load(layout)?;
97 let path = layout.project_issues_path(&project);
98 let (spec, named) = match IssueDoc::parse_file(&project, &path) {
99 Ok(doc) => (doc.priority_spec(), doc.priorities_are_named()),
100 Err(_) => (crate::org::PrioritySpec::default(), false),
101 };
102 let house_new = !path.exists();
103 let priority = opts.priority.unwrap_or(if named || house_new {
104 spec.default
105 } else {
106 cfg.issues.default_priority
107 });
108 if !spec.contains(priority) {
109 return Err(anyhow!(
110 "invalid priority {priority:?}; file allows [#{}]..[#{}]",
111 spec.highest,
112 spec.lowest
113 )
114 .into());
115 }
116
117 let known_ids = if opts.parent.is_some() || opts.body.is_some() {
119 collect_org_ids(layout)?
120 } else {
121 std::collections::HashSet::new()
122 };
123 if let Some(p) = opts.parent
124 && !known_ids.contains(p)
125 {
126 return Err(anyhow!("--parent {p} does not refer to any known id").into());
127 }
128
129 let mut lock_paths: Vec<PathBuf> = vec![path.clone()];
134 lock_paths.extend(opts.extra_id_paths.iter().cloned());
135 let lock_refs: Vec<&Path> = lock_paths.iter().map(PathBuf::as_path).collect();
136 with_issues_locks(&lock_refs, || {
137 let mut doc = IssueDoc::parse_file(&project, &path)?;
138 let mut taken = doc.known_ids();
139 taken.extend(opts.extra_ids.iter().cloned());
140 for twin in opts.extra_id_paths {
141 if twin == &path {
142 continue;
143 }
144 if let Ok(doc) = IssueDoc::parse_file(&project, twin) {
145 taken.extend(doc.known_ids());
146 }
147 }
148 let id = generate_id(&project, title, &taken, cfg.issues.id_length)?;
149
150 let mut props = BTreeMap::new();
151 props.insert("ID".into(), id.clone());
152 props.insert("CREATED".into(), today_inactive_bracket());
153 if crate::props::get(&props, crate::props::DISCOVERED_FROM).is_none()
154 && let Some(body) = opts.body
155 && let Some(origin) = first_existing_id_link(body, &known_ids)
156 {
157 crate::props::insert(&mut props, crate::props::DISCOVERED_FROM, origin);
158 }
159 let mut org_tags: Vec<String> = Vec::new();
160 if let Some(t) = opts.issue_type {
161 crate::props::insert(&mut props, crate::props::TYPE, t.into());
162 if t.chars().all(crate::model::is_org_tag_char)
165 && !t.is_empty()
166 && !org_tags.iter().any(|seen| seen == t)
167 {
168 org_tags.push(t.to_string());
169 }
170 }
171 if let Some(d) = opts.deadline {
172 validate_org_date(d)?;
173 props.insert("DEADLINE".into(), d.into());
174 }
175 if let Some(s) = opts.scheduled {
176 validate_org_date(s)?;
177 props.insert("SCHEDULED".into(), s.into());
178 }
179 if let Some(tags) = opts.tags {
183 let mut property_tags: Vec<String> = Vec::new();
184 for tag in tags.split([',', ':']).map(str::trim) {
185 if tag.is_empty() {
186 continue;
187 }
188 if tag.chars().all(crate::model::is_org_tag_char) {
189 if !org_tags.iter().any(|seen| seen == tag) {
190 org_tags.push(tag.to_string());
191 }
192 } else if !property_tags.iter().any(|seen| seen == tag) {
193 property_tags.push(tag.to_string());
194 }
195 }
196 if !property_tags.is_empty() {
197 props.insert(crate::model::TAGS_PROPERTY.into(), property_tags.join(","));
198 }
199 }
200 if let Some(p) = opts.parent {
201 crate::props::insert(&mut props, crate::props::PARENT, p.into());
202 }
203
204 doc.headings.push(IssueHeading {
205 id: id.clone(),
206 title: title.to_string(),
207 state: "TODO".into(),
208 priority,
209 properties: props,
210 org_tags,
211 statistics: None,
212 property_order: Vec::new(),
213 extra_drawers: Vec::new(),
214 body: match opts.body {
215 Some(b) if !b.trim().is_empty() => format!("{}\n", b.trim_end()),
216 _ => String::new(),
217 },
218 logbook: Vec::new(),
219 line_start: 0,
220 line_end: 0,
221 });
222 doc.write()?;
223
224 if opts.quiet {
225 Ok(format!("{id}\n"))
226 } else {
227 Ok(format!(
228 "{id} TODO [#{priority}] {title}\nfile: {}\n",
229 path.display()
230 ))
231 }
232 })
233}
234
235pub(crate) fn validate_org_date(s: &str) -> Result<()> {
236 let inner = s
237 .trim_start_matches(['<', '['])
238 .trim_end_matches(['>', ']']);
239 let token = inner.split_whitespace().next().unwrap_or("");
240 NaiveDate::parse_from_str(token, "%Y-%m-%d").with_context(|| {
241 format!("expected org date like <YYYY-MM-DD> or [YYYY-MM-DD], got {s:?}")
242 })?;
243 Ok(())
244}
245
246pub fn update(
254 layout: &Layout,
255 id: &str,
256 new_state: Option<&str>,
257 new_priority: Option<char>,
258 block_add: Option<&str>,
259 block_clear: Option<&str>,
260) -> Result<UpdateOutcome> {
261 let identity = crate::config::identity(layout);
262 update_as(
263 layout,
264 id,
265 new_state,
266 new_priority,
267 block_add,
268 block_clear,
269 &identity,
270 )
271}
272
273#[derive(Debug, Default, Clone, Copy)]
278pub struct UpdatePred<'a> {
279 pub if_state: Option<&'a str>,
281 pub if_gen: Option<u64>,
283}
284
285pub fn update_pred(
291 layout: &Layout,
292 id: &str,
293 new_state: Option<&str>,
294 new_priority: Option<char>,
295 block_add: Option<&str>,
296 block_clear: Option<&str>,
297 pred: UpdatePred<'_>,
298) -> Result<UpdateOutcome> {
299 let identity = crate::config::identity(layout);
300 update_as_pred(
301 layout,
302 id,
303 new_state,
304 new_priority,
305 block_add,
306 block_clear,
307 &identity,
308 pred,
309 )
310}
311
312pub fn update_as(
319 layout: &Layout,
320 id: &str,
321 new_state: Option<&str>,
322 new_priority: Option<char>,
323 block_add: Option<&str>,
324 block_clear: Option<&str>,
325 identity: &str,
326) -> Result<UpdateOutcome> {
327 update_as_pred(
328 layout,
329 id,
330 new_state,
331 new_priority,
332 block_add,
333 block_clear,
334 identity,
335 UpdatePred::default(),
336 )
337}
338
339#[allow(clippy::too_many_arguments)]
345pub fn update_as_pred(
346 layout: &Layout,
347 id: &str,
348 new_state: Option<&str>,
349 new_priority: Option<char>,
350 block_add: Option<&str>,
351 block_clear: Option<&str>,
352 identity: &str,
353 pred: UpdatePred<'_>,
354) -> Result<UpdateOutcome> {
355 let (_h0, path, project) =
356 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
357
358 let (transition, changed) = with_issues_lock(&path, || {
359 let graph = if block_add.is_some() {
362 Some(DependencyGraph::from_issues(&load_all(layout)?)?)
363 } else {
364 None
365 };
366 let mut doc = IssueDoc::parse_file(&project, &path)?;
367 let spec = doc.priority_spec();
368 let h = doc
369 .headings
370 .iter_mut()
371 .find(|x| x.id == id)
372 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
373
374 let original = h.state.clone();
375 let mut changed = Vec::new();
376
377 if pred.if_state.is_some() || pred.if_gen.is_some() {
378 let seen = crate::events::generation(layout);
379 if let Some(want) = pred.if_state {
380 if !TODO_KEYWORDS.contains(&want) {
381 return Err(
382 anyhow!("invalid --if-state {want:?}; allowed: {TODO_KEYWORDS:?}").into(),
383 );
384 }
385 if h.state != want {
386 return Err(Error::StaleWrite {
387 id: id.to_string(),
388 expected_state: Some(want.to_string()),
389 actual_state: h.state.clone(),
390 expected_gen: pred.if_gen,
391 actual_gen: Some(seen),
392 });
393 }
394 }
395 if let Some(want_gen) = pred.if_gen
396 && seen != want_gen
397 {
398 return Err(Error::StaleWrite {
399 id: id.to_string(),
400 expected_state: pred.if_state.map(str::to_string),
401 actual_state: h.state.clone(),
402 expected_gen: Some(want_gen),
403 actual_gen: Some(seen),
404 });
405 }
406 }
407
408 if let Some(s) = new_state {
409 if !TODO_KEYWORDS.contains(&s) {
410 return Err(anyhow!("invalid state {s:?}; allowed: {TODO_KEYWORDS:?}").into());
411 }
412 if h.state != s {
413 if is_terminal(&h.state) && is_terminal(s) {
414 record_sibling_terminal(h, s);
415 changed.push(format!("sibling terminal {s} (held {})", h.state));
416 } else {
417 let from = h.state.clone();
418 h.record_state_change(s);
419 changed.push(format!("state {from} -> {s}"));
420 for note in settle_claim(h, &from, s, identity) {
421 changed.push(note);
422 }
423 }
424 }
425 }
426
427 if let Some(p) = new_priority {
428 if !spec.contains(p) {
429 return Err(anyhow!(
430 "invalid priority {p:?}; file allows [#{}]..[#{}]",
431 spec.highest,
432 spec.lowest
433 )
434 .into());
435 }
436 if h.priority != p {
437 h.priority = p;
438 changed.push(format!("priority -> [#{p}]"));
439 }
440 }
441
442 if let Some(blk) = block_add {
443 let mut current = h.blocked_by();
444 if !current.iter().any(|x| x == blk) {
445 if let Some(graph) = &graph {
446 graph.accepts_edge(blk, id)?;
447 }
448 current.push(blk.to_string());
449 crate::props::insert(
450 &mut h.properties,
451 crate::props::BLOCKED_BY,
452 current.join(" "),
453 );
454 if h.state == "TODO" || h.state == "STARTED" {
455 let from = h.state.clone();
456 h.record_state_change("BLOCKED");
457 changed.push(format!("state {from} -> BLOCKED (auto on block)"));
458 }
459 changed.push(format!("blocked_by += {blk}"));
460 }
461 }
462
463 if let Some(blk) = block_clear {
464 let mut current = h.blocked_by();
465 let before = current.len();
466 current.retain(|x| x != blk);
467 if current.len() < before {
468 if current.is_empty() {
469 crate::props::remove(&mut h.properties, crate::props::BLOCKED_BY);
470 if h.state == "BLOCKED" {
471 let from = h.state.clone();
472 h.record_state_change("TODO");
473 changed.push("state BLOCKED -> TODO (auto on unblock)".to_string());
474 for note in settle_claim(h, &from, "TODO", identity) {
475 changed.push(note);
476 }
477 }
478 } else {
479 crate::props::insert(
480 &mut h.properties,
481 crate::props::BLOCKED_BY,
482 current.join(" "),
483 );
484 }
485 changed.push(format!("blocked_by -= {blk}"));
486 }
487 }
488
489 if changed.is_empty() {
490 return Ok((None, Vec::new()));
491 }
492
493 let final_state = h.state.clone();
494 doc.write()?;
495 let transition = (original != final_state).then_some((original, final_state));
496 Ok((transition, changed))
497 })?;
498
499 if changed.is_empty() {
500 return Ok(UpdateOutcome {
501 report: format!("{id}: no change\n"),
502 hints: Vec::new(),
503 });
504 }
505
506 if let Some((from, to)) = &transition {
507 let _ = crate::events::emit_state_change(layout, &project, id, from, to);
508 }
509
510 let mut hints = Vec::new();
511 if matches!(
512 transition.as_ref().map(|(_, to)| to.as_str()),
513 Some("DONE") | Some("CANCELLED")
514 ) {
515 for (other_project, other) in load_all(layout)? {
516 if !other.blocked_by().iter().any(|b| b == id) {
517 continue;
518 }
519 if other.state == "DONE" || other.state == "CANCELLED" {
520 continue;
521 }
522 hints.push(format!(
523 "{} (in {}) lists this as a blocker; clear with `vissue update {} --unblock {}`",
524 other.id, other_project, other.id, id
525 ));
526 }
527 }
528 Ok(UpdateOutcome {
529 report: format!("{id}: {}\n", changed.join(", ")),
530 hints,
531 })
532}
533
534fn keeps_claim(state: &str) -> bool {
537 matches!(state, "STARTED" | "BLOCKED")
538}
539
540fn is_terminal(state: &str) -> bool {
541 matches!(state, "DONE" | "CANCELLED")
542}
543
544fn record_sibling_terminal(h: &mut IssueHeading, attempted: &str) {
545 crate::props::insert(
546 &mut h.properties,
547 crate::props::SIBLING_TERMINAL,
548 attempted.to_string(),
549 );
550}
551
552pub fn resolve_terminal(layout: &Layout, id: &str, state: &str) -> Result<String> {
559 if !is_terminal(state) {
560 return Err(anyhow!("resolve state must be DONE or CANCELLED, got {state:?}").into());
561 }
562 let identity = crate::config::identity(layout);
563 let (_h0, path, project) =
564 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
565 let from = with_issues_lock(&path, || {
566 let mut doc = IssueDoc::parse_file(&project, &path)?;
567 let h = doc
568 .headings
569 .iter_mut()
570 .find(|x| x.id == id)
571 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
572 let from = h.state.clone();
573 if from != state {
574 h.record_state_change(state);
575 settle_claim(h, &from, state, &identity);
576 }
577 crate::props::remove(&mut h.properties, crate::props::SIBLING_TERMINAL);
578 doc.write()?;
579 Ok(from)
580 })?;
581 if from != state {
582 let _ = crate::events::emit_state_change(layout, &project, id, &from, state);
583 }
584 Ok(format!("resolved {id} -> {state}\n"))
585}
586
587fn settle_claim(h: &mut IssueHeading, from: &str, to: &str, identity: &str) -> Vec<String> {
593 let mut notes = Vec::new();
594 if to == "STARTED" && h.claimed_by().is_none() {
595 h.set_claim(identity);
596 notes.push(format!("claimed by {identity}"));
597 } else if keeps_claim(from)
598 && !keeps_claim(to)
599 && let Some((who, _when)) = h.release_claim()
600 {
601 notes.push(format!("claim released ({who})"));
602 }
603 notes
604}
605
606pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
617 let identity = crate::config::identity(layout);
618 claim_as(layout, id, force, &identity)
619}
620
621pub fn claim_as(layout: &Layout, id: &str, force: bool, identity: &str) -> Result<String> {
629 let (_h0, path, project) =
630 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
631
632 let report = with_issues_lock(&path, || {
633 let mut doc = IssueDoc::parse_file(&project, &path)?;
634 let h = doc
635 .headings
636 .iter_mut()
637 .find(|x| x.id == id)
638 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
639
640 if h.state == "DONE" || h.state == "CANCELLED" {
641 return Err(Error::InvalidState {
642 id: id.to_string(),
643 state: h.state.clone(),
644 });
645 }
646 if let Some(holder) = h.claimed_by() {
647 if holder != identity && !force {
648 return Err(Error::ClaimConflict {
649 id: id.to_string(),
650 holder: holder.to_string(),
651 claimed_at: h.claimed_at().map(str::to_string),
652 });
653 }
654 if holder != identity {
655 let previous = holder.to_string();
656 let from = h.state.clone();
657 h.release_claim();
658 h.set_claim(identity);
659 h.record_state_change("STARTED");
660 doc.write()?;
661 if from != "STARTED" {
662 let _ =
663 crate::events::emit_state_change(layout, &project, id, &from, "STARTED");
664 }
665 return Ok(format!("claimed {id} (taken over from {previous})\n"));
666 }
667 }
668
669 let was = h.state.clone();
670 h.record_state_change("STARTED");
671 if h.claimed_by().is_none() {
672 h.set_claim(identity);
673 }
674 doc.write()?;
675 if was != "STARTED" {
676 let _ = crate::events::emit_state_change(layout, &project, id, &was, "STARTED");
677 }
678 if was == "STARTED" {
679 Ok(format!("claimed {id} by {identity}\n"))
680 } else {
681 Ok(format!("claimed {id} by {identity} ({was} -> STARTED)\n"))
682 }
683 })?;
684 Ok(report)
685}
686
687#[derive(Debug, Clone)]
689pub struct UpdateOutcome {
690 pub report: String,
692 pub hints: Vec<String>,
694}
695
696pub fn note(layout: &Layout, id: &str, text: &str) -> Result<String> {
705 let text = text
708 .split_whitespace()
709 .collect::<Vec<_>>()
710 .join(" ")
711 .replace('"', "'");
712 if text.is_empty() {
713 return Err(anyhow!("note text is empty").into());
714 }
715 let (_h0, path, project) =
716 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
717 with_issues_lock(&path, || {
718 let mut doc = IssueDoc::parse_file(&project, &path)?;
719 let h = doc
720 .headings
721 .iter_mut()
722 .find(|x| x.id == id)
723 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
724 h.logbook.insert(
727 0,
728 LogEntry {
729 timestamp: LogEntry::now(),
730 from_state: None,
731 to_state: None,
732 note: Some(text.clone()),
733 raw: None,
734 },
735 );
736 doc.write()?;
737 Ok(format!("{id}: noted\n"))
738 })
739}
740
741pub fn append_body(layout: &Layout, id: &str, text: &str) -> Result<String> {
756 append_body_as(layout, id, text, &crate::config::identity(layout))
757}
758
759pub fn append_body_as(layout: &Layout, id: &str, text: &str, identity: &str) -> Result<String> {
766 let text = text.trim_end();
767 if text.trim().is_empty() {
768 return Err(anyhow!("append text is empty").into());
769 }
770 let (_h0, path, project) =
771 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
772 with_issues_lock(&path, || {
773 let mut doc = IssueDoc::parse_file(&project, &path)?;
774 let h = doc
775 .headings
776 .iter_mut()
777 .find(|x| x.id == id)
778 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
779 let stamp = format!("{} {identity}", today_inactive_bracket());
780 if !h.body.trim().is_empty() {
781 h.body = h.body.trim_end().to_string();
782 h.body.push_str("\n\n");
783 } else {
784 h.body.clear();
785 }
786 h.body.push_str(&stamp);
787 h.body.push('\n');
788 h.body.push_str(text);
789 h.body.push('\n');
790 doc.write()?;
791 let lines = text.lines().count();
792 Ok(format!("{id}: appended {lines} line(s)\n"))
793 })
794}
795
796const VOTES_DRAWER: &str = "VOTES";
798
799#[derive(Debug, Clone, PartialEq, Eq)]
801pub struct Ballot {
802 pub agent: String,
804 pub choice: String,
806 pub stamp: String,
808}
809
810pub fn vote(layout: &Layout, id: &str, choice: Option<&str>, identity: &str) -> Result<String> {
833 let (_h, path, project) =
834 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
835 let Some(choice) = choice else {
836 let doc = IssueDoc::parse_file(&project, &path)?;
837 let h = doc
838 .headings
839 .iter()
840 .find(|x| x.id == id)
841 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
842 let (ballots, _) = read_ballots(h);
843 return Ok(tally_text(id, &ballots));
844 };
845 let choice = choice.trim();
846 if choice.is_empty() {
847 return Err(anyhow!("vote needs something to vote for").into());
848 }
849 if choice.contains('\n') {
850 return Err(anyhow!("a vote is one line").into());
851 }
852 if identity.contains(": ") {
859 return Err(anyhow!(
860 "the identity {identity:?} contains a colon and a space, which a ballot line \
861 cannot hold unambiguously; set VISSUE_AGENT or `agent` in the config to a \
862 name without one"
863 )
864 .into());
865 }
866 if identity.trim().is_empty() {
867 return Err(anyhow!("a ballot needs an identity to file it under").into());
868 }
869 with_issues_lock(&path, || {
870 let mut doc = IssueDoc::parse_file(&project, &path)?;
871 let h = doc
872 .headings
873 .iter_mut()
874 .find(|x| x.id == id)
875 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
876 let (mut ballots, foreign) = read_ballots(h);
877 let stamp = today_inactive_bracket();
878 let previous = ballots.iter().position(|b| b.agent == identity);
879 let changed_from = previous.map(|i| ballots[i].choice.clone());
880 let ballot = Ballot {
881 agent: identity.to_string(),
882 choice: choice.to_string(),
883 stamp,
884 };
885 match previous {
886 Some(i) => ballots[i] = ballot,
887 None => ballots.push(ballot),
888 }
889 write_ballots(h, &ballots, &foreign);
890 doc.write()?;
891 let mut out = match changed_from {
892 Some(old) if old == choice => format!("{id}: {identity} already voted {choice}\n"),
893 Some(old) => format!("{id}: {identity} changed {old} to {choice}\n"),
894 None => format!("{id}: {identity} voted {choice}\n"),
895 };
896 out.push_str(&tally_text(id, &ballots));
897 Ok(out)
898 })
899}
900
901fn read_ballots(h: &IssueHeading) -> (Vec<Ballot>, Vec<String>) {
907 let Some(drawer) = h
908 .extra_drawers
909 .iter()
910 .find(|d| drawer_name_is(d, VOTES_DRAWER))
911 else {
912 return (Vec::new(), Vec::new());
913 };
914 let mut ballots: Vec<Ballot> = Vec::new();
915 let mut foreign: Vec<String> = Vec::new();
916 for line in drawer.lines() {
917 let trimmed = line.trim();
918 if trimmed.is_empty() {
919 continue;
920 }
921 if trimmed.eq_ignore_ascii_case(&format!(":{VOTES_DRAWER}:"))
923 || trimmed.eq_ignore_ascii_case(":END:")
924 {
925 continue;
926 }
927 match parse_ballot(trimmed) {
928 Some(b) => match ballots.iter_mut().find(|x| x.agent == b.agent) {
934 Some(existing) => *existing = b,
935 None => ballots.push(b),
936 },
937 None => foreign.push(trimmed.to_string()),
938 }
939 }
940 (ballots, foreign)
941}
942
943fn parse_ballot(line: &str) -> Option<Ballot> {
946 let (stamp, rest) = line.strip_prefix('[')?.split_once("] ")?;
947 let (agent, choice) = rest.split_once(": ")?;
948 let agent = agent.trim();
949 let choice = choice.trim();
950 if agent.is_empty() || choice.is_empty() {
951 return None;
952 }
953 Some(Ballot {
954 agent: agent.to_string(),
955 choice: choice.to_string(),
956 stamp: format!("[{stamp}]"),
957 })
958}
959
960fn drawer_name_is(drawer: &str, name: &str) -> bool {
961 drawer
962 .lines()
963 .next()
964 .map(str::trim)
965 .and_then(|first| first.strip_prefix(':'))
966 .and_then(|rest| rest.strip_suffix(':'))
967 .is_some_and(|n| n.eq_ignore_ascii_case(name))
968}
969
970fn write_ballots(h: &mut IssueHeading, ballots: &[Ballot], foreign: &[String]) {
975 let at = h
976 .extra_drawers
977 .iter()
978 .position(|d| drawer_name_is(d, VOTES_DRAWER));
979 if ballots.is_empty() && foreign.is_empty() {
980 if let Some(i) = at {
981 h.extra_drawers.remove(i);
982 }
983 return;
984 }
985 let mut drawer = format!(":{VOTES_DRAWER}:\n");
986 for b in ballots {
987 drawer.push_str(&format!("{} {}: {}\n", b.stamp, b.agent, b.choice));
988 }
989 for line in foreign {
990 drawer.push_str(line);
991 drawer.push('\n');
992 }
993 drawer.push_str(":END:\n");
994 match at {
995 Some(i) => h.extra_drawers[i] = drawer,
996 None => h.extra_drawers.push(drawer),
997 }
998}
999
1000fn tally_text(id: &str, ballots: &[Ballot]) -> String {
1006 if ballots.is_empty() {
1007 return format!("{id}: no votes\n");
1008 }
1009 let mut counts: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1010 for b in ballots {
1011 counts
1012 .entry(b.choice.as_str())
1013 .or_default()
1014 .push(b.agent.as_str());
1015 }
1016 let total = ballots.len();
1017 let mut rows: Vec<(&&str, &Vec<&str>)> = counts.iter().collect();
1018 rows.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0)));
1019 let mut out = format!(
1020 "{id}: {total} vote{} from {} option{}\n",
1021 if total == 1 { "" } else { "s" },
1022 counts.len(),
1023 if counts.len() == 1 { "" } else { "s" }
1024 );
1025 for (choice, who) in &rows {
1026 let _ = writeln!(out, " {:<24} {} ({})", choice, who.len(), who.join(", "));
1027 }
1028 let top = rows[0].1.len();
1029 let tied = rows.iter().filter(|(_, who)| who.len() == top).count();
1030 if tied > 1 {
1031 let _ = writeln!(out, " no consensus: {tied} options tied at {top}");
1032 } else if total < 2 {
1033 let _ = writeln!(
1037 out,
1038 " one ballot only: {}, which nobody has agreed with yet",
1039 rows[0].0
1040 );
1041 } else if top * 2 > total {
1042 let _ = writeln!(out, " consensus: {} ({top} of {total})", rows[0].0);
1043 } else {
1044 let _ = writeln!(
1045 out,
1046 " plurality only: {} ({top} of {total}), which is not a majority",
1047 rows[0].0
1048 );
1049 }
1050 out
1051}
1052
1053pub fn fold(layout: &Layout, inbox: &std::path::Path, project: &str) -> Result<String> {
1067 let project = resolve_existing_project_case(layout, project)?;
1068 let text = std::fs::read_to_string(inbox)
1069 .with_context(|| format!("read inbox {}", inbox.display()))?;
1070 let lines: Vec<String> = text.lines().map(str::to_string).collect();
1071
1072 struct Entry {
1073 line: usize,
1074 title: String,
1075 body: String,
1076 stamped: bool,
1077 }
1078 let mut entries: Vec<Entry> = Vec::new();
1079 let mut i = 0;
1080 let mut nest = crate::org::OrgScan::new();
1081 while i < lines.len() {
1082 if nest.observe(&lines[i]) {
1083 i += 1;
1084 continue;
1085 }
1086 if let Some(title) = lines[i].strip_prefix("* TODO ") {
1087 let start = i + 1;
1088 let mut end_nest = crate::org::OrgScan::new();
1089 let end = {
1090 let mut j = start;
1091 while j < lines.len() {
1092 if !end_nest.observe(&lines[j]) && lines[j].starts_with("* ") {
1093 break;
1094 }
1095 j += 1;
1096 }
1097 j
1098 };
1099 let stamped = lines[start..end]
1100 .iter()
1101 .any(|l| l.trim_start().starts_with(":VISSUE_ID:"));
1102 let body = lines[start..end].join("\n").trim().to_string();
1103 entries.push(Entry {
1104 line: i,
1105 title: title.trim().to_string(),
1106 body,
1107 stamped,
1108 });
1109 i = end;
1110 } else {
1111 i += 1;
1112 }
1113 }
1114
1115 let mut out = lines.clone();
1118 let mut created: Vec<String> = Vec::new();
1119 let mut failure = None;
1120 for e in entries.iter().rev() {
1121 if e.stamped {
1122 continue;
1123 }
1124 let printed = create(
1125 layout,
1126 &project,
1127 &e.title,
1128 CreateOpts {
1129 quiet: true,
1130 body: if e.body.is_empty() {
1131 None
1132 } else {
1133 Some(&e.body)
1134 },
1135 ..CreateOpts::default()
1136 },
1137 );
1138 let id = match printed {
1139 Ok(printed) => printed.trim().to_string(),
1140 Err(e) => {
1141 failure = Some(e);
1145 break;
1146 }
1147 };
1148 out[e.line] = format!("* DONE {}", e.title);
1149 out.insert(e.line + 1, format!(":VISSUE_ID: {id}"));
1150 created.push(id);
1151 }
1152 created.reverse();
1153
1154 if !created.is_empty() {
1155 let mut rendered = out.join("\n");
1156 if text.ends_with('\n') {
1157 rendered.push('\n');
1158 }
1159 std::fs::write(inbox, rendered)
1160 .with_context(|| format!("write inbox {}", inbox.display()))?;
1161 }
1162 if let Some(error) = failure {
1163 return Err(crate::error::Error::Other(
1164 anyhow::Error::from(error).context(format!(
1165 "folded {} before failing: {}",
1166 created.len(),
1167 created.join(" ")
1168 )),
1169 ));
1170 }
1171 if created.is_empty() {
1172 return Ok("folded 0 (nothing unstamped)\n".into());
1173 }
1174 Ok(format!("folded {}: {}\n", created.len(), created.join(" ")))
1175}
1176
1177pub fn refile(layout: &Layout, id: &str, to_project: &str) -> Result<String> {
1185 refile_to(layout, id, layout, to_project)
1186}
1187
1188pub fn refile_to(
1197 layout: &Layout,
1198 id: &str,
1199 dst_layout: &Layout,
1200 to_project: &str,
1201) -> Result<String> {
1202 let to_project = resolve_existing_project_case(dst_layout, to_project)?;
1203 let target_path = dst_layout.project_issues_path(&to_project);
1204 let (_heading, src_path, src_project) =
1205 find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
1206 if src_path == target_path {
1207 return Ok(format!("{id} already in {to_project}; nothing to do\n"));
1208 }
1209 with_issues_locks(&[&src_path, &target_path], || {
1210 let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
1211 let heading = src_doc
1212 .remove(id)
1213 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
1214
1215 let mut tgt_doc = IssueDoc::parse_file(&to_project, &target_path)?;
1221 tgt_doc.upsert(heading);
1222 tgt_doc.write()?;
1223 src_doc.write()?;
1224 Ok(())
1225 })?;
1226 Ok(format!("{id}: {src_project} -> {to_project}\n"))
1227}
1228
1229#[derive(Debug, Default, Clone, Copy)]
1231pub struct RejectOpts<'a> {
1232 pub to: Option<&'a str>,
1234 pub project: Option<&'a str>,
1236 pub title: Option<&'a str>,
1238 pub reason: Option<&'a str>,
1240 pub dst_layout: Option<&'a Layout>,
1242 pub dst_extra_id_paths: &'a [PathBuf],
1246}
1247
1248pub fn reject(layout: &Layout, src: &str, opts: RejectOpts<'_>) -> Result<String> {
1261 let identity = crate::config::identity(layout);
1262 let (src0, src_path, src_project) =
1263 find_by_id(layout, src)?.ok_or_else(|| Error::IssueNotFound {
1264 id: src.to_string(),
1265 })?;
1266
1267 let dst_layout = opts.dst_layout.unwrap_or(layout);
1268 let existing_dst = if let Some(to) = opts.to {
1269 if to == src {
1270 return Err(anyhow!("reject destination cannot be the source {src}").into());
1271 }
1272 Some(
1273 find_by_id(dst_layout, to)?
1274 .ok_or_else(|| Error::IssueNotFound { id: to.to_string() })?,
1275 )
1276 } else {
1277 None
1278 };
1279
1280 let creating = existing_dst.is_none();
1281 if creating && opts.project.is_none() {
1282 return Err(anyhow!("reject needs --to DST or --project to create a successor").into());
1283 }
1284
1285 let dst_project = if let Some((_, _, ref project)) = existing_dst {
1286 project.clone()
1287 } else {
1288 resolve_existing_project_case(dst_layout, opts.project.unwrap_or(&src_project))?
1289 };
1290 let dst_path = dst_layout.project_issues_path(&dst_project);
1291 let dst_title = opts.title.unwrap_or(src0.title.as_str());
1292 let cfg = VissueConfig::load(layout)?;
1293
1294 let mut lock_paths: Vec<PathBuf> = vec![src_path.clone(), dst_path.clone()];
1297 lock_paths.extend(opts.dst_extra_id_paths.iter().cloned());
1298 let lock_refs: Vec<&Path> = lock_paths.iter().map(PathBuf::as_path).collect();
1299 let (dst_id, old_state, new_state) = with_issues_locks(&lock_refs, || {
1300 if src_path == dst_path {
1301 let mut doc = IssueDoc::parse_file(&src_project, &src_path)?;
1302 let dst_id = if creating {
1303 push_successor(
1304 &mut doc,
1305 &dst_project,
1306 dst_title,
1307 src,
1308 &cfg,
1309 opts.dst_extra_id_paths,
1310 )?
1311 } else {
1312 let to = reject_to(opts)?;
1313 set_discovered_from_if_empty(&mut doc, to, src)?;
1314 to.to_string()
1315 };
1316 let (old_state, new_state) =
1317 cancel_and_pivot(&mut doc, src, &dst_id, opts.reason, &identity)?;
1318 doc.write()?;
1319 Ok((dst_id, old_state, new_state))
1320 } else {
1321 let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
1322 let mut dst_doc = IssueDoc::parse_file(&dst_project, &dst_path)?;
1323 let dst_id = if creating {
1324 push_successor(
1325 &mut dst_doc,
1326 &dst_project,
1327 dst_title,
1328 src,
1329 &cfg,
1330 opts.dst_extra_id_paths,
1331 )?
1332 } else {
1333 let to = reject_to(opts)?;
1334 set_discovered_from_if_empty(&mut dst_doc, to, src)?;
1335 to.to_string()
1336 };
1337 let (old_state, new_state) =
1338 cancel_and_pivot(&mut src_doc, src, &dst_id, opts.reason, &identity)?;
1339 dst_doc.write()?;
1340 src_doc.write()?;
1341 Ok((dst_id, old_state, new_state))
1342 }
1343 })?;
1344
1345 if old_state != new_state {
1346 let _ = crate::events::emit_state_change(layout, &src_project, src, &old_state, &new_state);
1347 }
1348 Ok(format!("rejected {src} -> {dst_id}\n"))
1349}
1350
1351fn reject_to(opts: RejectOpts<'_>) -> Result<&str> {
1352 opts.to
1353 .ok_or_else(|| anyhow!("reject destination missing after --to was required").into())
1354}
1355
1356fn push_successor(
1357 doc: &mut IssueDoc,
1358 project: &str,
1359 title: &str,
1360 src: &str,
1361 cfg: &VissueConfig,
1362 extra_id_paths: &[PathBuf],
1363) -> Result<String> {
1364 let mut taken = doc.known_ids();
1365 for twin in extra_id_paths {
1367 if twin == &doc.path {
1368 continue;
1369 }
1370 if let Ok(other) = IssueDoc::parse_file(project, twin) {
1371 taken.extend(other.known_ids());
1372 }
1373 }
1374 let id = generate_id(project, title, &taken, cfg.issues.id_length)?;
1375 let mut props = BTreeMap::new();
1376 props.insert("ID".into(), id.clone());
1377 props.insert("CREATED".into(), today_inactive_bracket());
1378 crate::props::insert(&mut props, crate::props::DISCOVERED_FROM, src.to_string());
1379 doc.headings.push(IssueHeading {
1380 id: id.clone(),
1381 title: title.to_string(),
1382 state: "TODO".into(),
1383 priority: doc.default_create_priority(cfg.issues.default_priority),
1384 properties: props,
1385 org_tags: Vec::new(),
1386 statistics: None,
1387 property_order: Vec::new(),
1388 extra_drawers: Vec::new(),
1389 body: String::new(),
1390 logbook: Vec::new(),
1391 line_start: 0,
1392 line_end: 0,
1393 });
1394 Ok(id)
1395}
1396
1397fn set_discovered_from_if_empty(doc: &mut IssueDoc, id: &str, src: &str) -> Result<()> {
1398 let h = doc
1399 .headings
1400 .iter_mut()
1401 .find(|h| h.id == id)
1402 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
1403 let empty = crate::props::get(&h.properties, crate::props::DISCOVERED_FROM)
1404 .is_none_or(|s| s.trim().is_empty());
1405 if empty {
1406 crate::props::insert(
1407 &mut h.properties,
1408 crate::props::DISCOVERED_FROM,
1409 src.to_string(),
1410 );
1411 }
1412 Ok(())
1413}
1414
1415fn cancel_and_pivot(
1416 doc: &mut IssueDoc,
1417 src: &str,
1418 dst: &str,
1419 reason: Option<&str>,
1420 identity: &str,
1421) -> Result<(String, String)> {
1422 let h = doc
1423 .headings
1424 .iter_mut()
1425 .find(|h| h.id == src)
1426 .ok_or_else(|| Error::IssueNotFound {
1427 id: src.to_string(),
1428 })?;
1429 let old_state = h.state.clone();
1430 if is_terminal(&old_state) && old_state != "CANCELLED" {
1431 record_sibling_terminal(h, "CANCELLED");
1432 } else if old_state != "CANCELLED" {
1433 h.record_state_change("CANCELLED");
1434 settle_claim(h, &old_state, "CANCELLED", identity);
1435 }
1436 crate::props::insert(&mut h.properties, crate::props::PIVOTED_TO, dst.to_string());
1437 if let Some(reason) = reason {
1438 append_reason(h, reason, identity);
1439 }
1440 Ok((old_state, h.state.clone()))
1441}
1442
1443fn append_reason(h: &mut IssueHeading, text: &str, identity: &str) {
1444 let text = text.trim_end();
1445 if text.trim().is_empty() {
1446 return;
1447 }
1448 let stamp = format!("{} {identity}", today_inactive_bracket());
1449 if !h.body.trim().is_empty() {
1450 h.body = h.body.trim_end().to_string();
1451 h.body.push_str("\n\n");
1452 } else {
1453 h.body.clear();
1454 }
1455 h.body.push_str(&stamp);
1456 h.body.push('\n');
1457 h.body.push_str(text);
1458 h.body.push('\n');
1459}
1460
1461fn first_existing_id_link(body: &str, known: &std::collections::HashSet<String>) -> Option<String> {
1463 let mut rest = body;
1464 while let Some(start) = rest.find("[[") {
1465 let after_start = &rest[start + 2..];
1466 let end = after_start.find("]]")?;
1467 let raw = &after_start[..end];
1468 let target = raw.split_once("][").map_or(raw, |(target, _)| target);
1469 let target = target.trim();
1470 if let Some(id) = target.strip_prefix("id:") {
1471 let id = id.trim();
1472 if known.contains(id) {
1473 return Some(id.to_string());
1474 }
1475 }
1476 rest = &after_start[end + 2..];
1477 }
1478 None
1479}
1480
1481pub fn normalize(layout: &Layout, project: Option<&str>, dry_run: bool) -> Result<String> {
1492 let projects = match project {
1493 Some(name) => vec![resolve_existing_project_case(layout, name)?],
1494 None => crate::store::list_projects(layout)?,
1495 };
1496 let mut out = String::new();
1497 let mut files = 0usize;
1498 let mut headings = 0usize;
1499 let mut changed = 0usize;
1500 for project in projects {
1501 let path = layout.project_issues_path(&project);
1502 if !path.exists() {
1503 continue;
1504 }
1505 files += 1;
1506 let before =
1507 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1508 let report = with_issues_lock(&path, || {
1509 let mut doc = IssueDoc::parse_file(&project, &path)?;
1510 let mut moved = 0usize;
1511 for h in &mut doc.headings {
1512 moved += crate::props::settle(&mut h.org_tags, &mut h.properties);
1513 }
1514 let after = doc.render_string();
1515 if after != before {
1516 if !dry_run {
1517 doc.write()?;
1518 }
1519 Ok(Some((moved, after.len())))
1520 } else {
1521 Ok(None)
1522 }
1523 })?;
1524 headings += IssueDoc::parse(&project, path.clone(), &before)
1525 .map(|d| d.headings.len())
1526 .unwrap_or(0);
1527 if let Some((moved, _)) = report {
1528 changed += 1;
1529 let verb = if dry_run { "would rewrite" } else { "rewrote" };
1530 writeln!(out, "{verb} {project} ({moved} key move(s))")?;
1531 }
1532 }
1533 let mode = if dry_run { "dry-run" } else { "wrote" };
1534 writeln!(
1535 out,
1536 "normalize {mode}: {changed}/{files} file(s) changed, {headings} heading(s) scanned"
1537 )?;
1538 Ok(out)
1539}
1540
1541#[cfg(test)]
1542mod tests {
1543 use super::*;
1544 use crate::config::DEFAULT_PREFIX;
1545 use std::fs;
1546 use std::path::Path;
1547
1548 fn fresh_layout(dir: &Path) -> Layout {
1549 fs::create_dir_all(dir.join(DEFAULT_PREFIX)).unwrap();
1550 Layout::new(dir, DEFAULT_PREFIX)
1551 }
1552
1553 fn issue_at(layout: &Layout, project: &str, id: &str) -> IssueHeading {
1554 IssueDoc::parse_file(project, &layout.project_issues_path(project))
1555 .unwrap()
1556 .headings
1557 .into_iter()
1558 .find(|h| h.id == id)
1559 .expect("issue not found")
1560 }
1561
1562 fn only_id(layout: &Layout, project: &str) -> String {
1563 IssueDoc::parse_file(project, &layout.project_issues_path(project))
1564 .unwrap()
1565 .headings[0]
1566 .id
1567 .clone()
1568 }
1569
1570 #[test]
1571 fn create_rejects_a_parent_that_does_not_exist() {
1572 let dir = tempfile::tempdir().unwrap();
1573 let layout = fresh_layout(dir.path());
1574 let err = create(
1575 &layout,
1576 "sample",
1577 "child without parent",
1578 CreateOpts {
1579 parent: Some("sample-zzz9"),
1580 ..Default::default()
1581 },
1582 )
1583 .unwrap_err();
1584 assert!(err.to_string().contains("does not refer to any known id"));
1585 }
1586
1587 #[test]
1588 fn create_accepts_a_parent_defined_in_a_design_document() {
1589 let dir = tempfile::tempdir().unwrap();
1590 let layout = fresh_layout(dir.path());
1591 let parent_id = "sample-spec-20260615";
1592 let project_dir = layout.projects_dir().join("sample");
1593 fs::create_dir_all(&project_dir).unwrap();
1594 fs::write(
1595 project_dir.join("design.org"),
1596 format!("#+TITLE: sample design\n\n* Design\n:PROPERTIES:\n:ID: {parent_id}\n:END:\n"),
1597 )
1598 .unwrap();
1599
1600 create(
1601 &layout,
1602 "sample",
1603 "child under design",
1604 CreateOpts {
1605 parent: Some(parent_id),
1606 ..Default::default()
1607 },
1608 )
1609 .unwrap();
1610 assert!(only_id(&layout, "sample").starts_with("sample-"));
1611 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1612 assert_eq!(doc.headings[0].parent(), Some(parent_id));
1613 }
1614
1615 #[test]
1616 fn a_state_update_writes_a_logbook_entry() {
1617 let dir = tempfile::tempdir().unwrap();
1618 let layout = fresh_layout(dir.path());
1619 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1620 let id = only_id(&layout, "sample");
1621 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1622 let h = issue_at(&layout, "sample", &id);
1623 assert_eq!(h.state, "STARTED");
1624 assert_eq!(h.logbook[0].from_state.as_deref(), Some("TODO"));
1625 assert_eq!(h.logbook[0].to_state.as_deref(), Some("STARTED"));
1626 }
1627
1628 #[test]
1629 fn blocking_and_unblocking_drive_the_state() {
1630 let dir = tempfile::tempdir().unwrap();
1631 let layout = fresh_layout(dir.path());
1632 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1633 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1634 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1635 let first = doc.headings[0].id.clone();
1636 let blocker = doc.headings[1].id.clone();
1637
1638 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1639 let h = issue_at(&layout, "sample", &first);
1640 assert_eq!(h.state, "BLOCKED");
1641 assert!(h.blocked_by().contains(&blocker));
1642
1643 update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
1644 let h = issue_at(&layout, "sample", &first);
1645 assert_eq!(h.state, "TODO");
1646 assert!(h.blocked_by().is_empty());
1647 }
1648
1649 #[test]
1650 fn auto_unblock_to_todo_releases_the_claim() {
1651 let dir = tempfile::tempdir().unwrap();
1652 let layout = fresh_layout(dir.path());
1653 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1654 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1655 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1656 let first = doc.headings[0].id.clone();
1657 let blocker = doc.headings[1].id.clone();
1658
1659 crate::agent::claim(&layout, &first, false).unwrap();
1660 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1661 assert!(issue_at(&layout, "sample", &first).claimed_by().is_some());
1662
1663 update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
1664 let h = issue_at(&layout, "sample", &first);
1665 assert_eq!(h.state, "TODO");
1666 assert!(h.claimed_by().is_none(), "claim stuck on TODO: {h:?}");
1667 }
1668
1669 #[test]
1670 fn blocker_cycle_is_rejected_before_writing() {
1671 let dir = tempfile::tempdir().unwrap();
1672 let layout = fresh_layout(dir.path());
1673 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1674 create(&layout, "sample", "second", CreateOpts::default()).unwrap();
1675 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1676 let first = doc.headings[0].id.clone();
1677 let second = doc.headings[1].id.clone();
1678
1679 update(&layout, &first, None, None, Some(&second), None).unwrap();
1680 let err = update(&layout, &second, None, None, Some(&first), None).unwrap_err();
1681 assert!(err.to_string().contains("blocker cycle"), "{err}");
1682 assert!(issue_at(&layout, "sample", &second).blocked_by().is_empty());
1683 }
1684
1685 #[test]
1686 fn closing_a_blocker_reports_the_issues_still_pointing_at_it() {
1687 let dir = tempfile::tempdir().unwrap();
1688 let layout = fresh_layout(dir.path());
1689 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1690 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1691 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1692 let first = doc.headings[0].id.clone();
1693 let blocker = doc.headings[1].id.clone();
1694 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1695
1696 let outcome = update(&layout, &blocker, Some("DONE"), None, None, None).unwrap();
1697 assert_eq!(outcome.hints.len(), 1, "{:?}", outcome.hints);
1698 assert!(outcome.hints[0].contains(&first), "{:?}", outcome.hints);
1699 }
1700
1701 #[test]
1702 fn refile_moves_the_heading_between_projects() {
1703 let dir = tempfile::tempdir().unwrap();
1704 let layout = fresh_layout(dir.path());
1705 create(&layout, "source", "the issue", CreateOpts::default()).unwrap();
1706 let id = only_id(&layout, "source");
1707 refile(&layout, &id, "target").unwrap();
1708
1709 let src = IssueDoc::parse_file("source", &layout.project_issues_path("source")).unwrap();
1710 let tgt = IssueDoc::parse_file("target", &layout.project_issues_path("target")).unwrap();
1711 assert!(src.headings.is_empty());
1712 assert_eq!(tgt.headings[0].id, id);
1713 }
1714
1715 #[test]
1716 fn deadlines_must_parse_as_org_dates() {
1717 let dir = tempfile::tempdir().unwrap();
1718 let layout = fresh_layout(dir.path());
1719 let err = create(
1720 &layout,
1721 "sample",
1722 "bad date",
1723 CreateOpts {
1724 deadline: Some("not-a-date"),
1725 ..Default::default()
1726 },
1727 )
1728 .unwrap_err();
1729 assert!(err.to_string().contains("expected org date"));
1730
1731 for (i, d) in ["<2026-05-15 Fri>", "[2026-05-15]"].iter().enumerate() {
1732 create(
1733 &layout,
1734 "sample",
1735 &format!("issue {i}"),
1736 CreateOpts {
1737 deadline: Some(d),
1738 ..Default::default()
1739 },
1740 )
1741 .unwrap();
1742 }
1743 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1744 assert_eq!(doc.headings.len(), 2);
1745 assert!(doc.headings.iter().all(|h| h.deadline().is_some()));
1746 }
1747
1748 #[test]
1749 fn org_safe_tags_go_on_the_heading_and_the_rest_stay_in_the_property() {
1750 let dir = tempfile::tempdir().unwrap();
1751 let layout = fresh_layout(dir.path());
1752 create(
1753 &layout,
1754 "sample",
1755 "tagged",
1756 CreateOpts {
1757 tags: Some("rust: perf ,, scaling, needs-review"),
1758 ..Default::default()
1759 },
1760 )
1761 .unwrap();
1762 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1763 let h = &doc.headings[0];
1764 assert_eq!(h.org_tags, vec!["rust", "perf", "scaling"]);
1765 assert_eq!(
1766 h.properties
1767 .get(crate::model::TAGS_PROPERTY)
1768 .map(|s| s.as_str()),
1769 Some("needs-review"),
1770 "a tag Org cannot hold keeps the property"
1771 );
1772 assert_eq!(
1774 h.tags(),
1775 vec!["needs-review", "rust", "perf", "scaling"],
1776 "{h:?}"
1777 );
1778 }
1779
1780 #[test]
1781 fn create_puts_a_legal_type_on_the_heading() {
1782 let dir = tempfile::tempdir().unwrap();
1783 let layout = fresh_layout(dir.path());
1784 create(
1785 &layout,
1786 "sample",
1787 "a bug",
1788 CreateOpts {
1789 issue_type: Some("bug"),
1790 ..Default::default()
1791 },
1792 )
1793 .unwrap();
1794 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1795 let h = &doc.headings[0];
1796 assert_eq!(
1797 crate::props::get(&h.properties, crate::props::TYPE),
1798 Some("bug")
1799 );
1800 assert_eq!(h.org_tags, vec!["bug"]);
1801 let written = std::fs::read_to_string(layout.project_issues_path("sample")).unwrap();
1802 assert!(written.contains("#+CATEGORY: sample"), "{written}");
1803 assert!(written.contains(":bug:"), "{written}");
1804 }
1805
1806 #[test]
1807 fn resolve_project_needs_a_name_from_somewhere() {
1808 let dir = tempfile::tempdir().unwrap();
1809 let layout = fresh_layout(dir.path());
1810 assert_eq!(
1811 resolve_project(&layout, Some("fromcli")).unwrap(),
1812 "fromcli"
1813 );
1814 assert!(
1815 resolve_project(&layout, Some(""))
1816 .unwrap_err()
1817 .to_string()
1818 .contains("empty")
1819 );
1820 }
1821
1822 #[test]
1824 fn concurrent_creates_preserve_every_heading() {
1825 use std::sync::Arc;
1826 use std::thread;
1827
1828 let dir = tempfile::tempdir().unwrap();
1829 let layout = Arc::new(fresh_layout(dir.path()));
1830 let n = 24usize;
1831 let handles: Vec<_> = (0..n)
1832 .map(|i| {
1833 let layout = Arc::clone(&layout);
1834 thread::spawn(move || {
1835 create(
1836 &layout,
1837 "sample",
1838 &format!("parallel title {i}"),
1839 CreateOpts {
1840 quiet: true,
1841 ..Default::default()
1842 },
1843 )
1844 })
1845 })
1846 .collect();
1847 let mut ids: Vec<String> = handles
1848 .into_iter()
1849 .map(|h| {
1850 h.join()
1851 .expect("thread panicked")
1852 .expect("create failed")
1853 .trim()
1854 .to_string()
1855 })
1856 .collect();
1857 ids.sort();
1858 ids.dedup();
1859 assert_eq!(ids.len(), n, "expected {n} unique ids, got {ids:?}");
1860
1861 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1862 let mut on_disk: Vec<String> = doc.headings.iter().map(|h| h.id.clone()).collect();
1863 on_disk.sort();
1864 assert_eq!(on_disk, ids);
1865 }
1866
1867 #[test]
1868 fn note_appends_to_the_logbook_and_leaves_state_alone() {
1869 let dir = tempfile::tempdir().unwrap();
1870 let layout = fresh_layout(dir.path());
1871 create(&layout, "sample", "carries a note", CreateOpts::default()).unwrap();
1872 let id = only_id(&layout, "sample");
1873
1874 let out = note(&layout, &id, "first pass done,\n \"quoted\" bit next").unwrap();
1875 assert_eq!(out, format!("{id}: noted\n"));
1876
1877 let h = issue_at(&layout, "sample", &id);
1878 assert_eq!(h.state, "TODO");
1879 assert!(h.claimed_by().is_none());
1880 let notes: Vec<&str> = h.logbook.iter().filter_map(|e| e.note.as_deref()).collect();
1881 assert_eq!(notes, vec!["first pass done, 'quoted' bit next"]);
1883 }
1884
1885 #[test]
1886 fn the_logbook_reads_newest_first_however_an_entry_arrived() {
1887 let dir = tempfile::tempdir().unwrap();
1888 let layout = fresh_layout(dir.path());
1889 create(&layout, "sample", "ordered", CreateOpts::default()).unwrap();
1890 let id = only_id(&layout, "sample");
1891
1892 note(&layout, &id, "first note").unwrap();
1893 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1894 note(&layout, &id, "second note").unwrap();
1895
1896 let h = issue_at(&layout, "sample", &id);
1897 let summary: Vec<String> = h
1898 .logbook
1899 .iter()
1900 .map(|e| match (&e.note, &e.to_state) {
1901 (Some(note), _) => note.clone(),
1902 (_, Some(to)) => format!("state:{to}"),
1903 _ => "?".into(),
1904 })
1905 .collect();
1906 assert_eq!(
1907 summary,
1908 vec!["second note", "state:STARTED", "first note"],
1909 "{h:?}"
1910 );
1911 }
1912
1913 #[test]
1914 fn note_rejects_empty_text_and_unknown_ids() {
1915 let dir = tempfile::tempdir().unwrap();
1916 let layout = fresh_layout(dir.path());
1917 create(&layout, "sample", "target", CreateOpts::default()).unwrap();
1918 let id = only_id(&layout, "sample");
1919 assert!(note(&layout, &id, " ").is_err());
1920 assert!(note(&layout, "sample-zzz9", "text").is_err());
1921 }
1922
1923 #[test]
1924 fn fold_creates_issues_and_stamps_the_inbox_idempotently() {
1925 let dir = tempfile::tempdir().unwrap();
1926 let layout = fresh_layout(dir.path());
1927 create(&layout, "sample", "seed", CreateOpts::default()).unwrap();
1928
1929 let inbox = dir.path().join("inbox.org");
1930 fs::write(
1931 &inbox,
1932 "#+TITLE: inbox\n\n\
1933 * TODO first discovered thing\nSome body line.\nAnother line.\n\
1934 * DONE already handled elsewhere\n\
1935 * TODO second discovered thing\n",
1936 )
1937 .unwrap();
1938
1939 let out = fold(&layout, &inbox, "sample").unwrap();
1940 assert!(out.starts_with("folded 2: "), "got: {out}");
1941
1942 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1943 let titles: Vec<&str> = doc.headings.iter().map(|h| h.title.as_str()).collect();
1944 assert!(titles.contains(&"first discovered thing"));
1945 assert!(titles.contains(&"second discovered thing"));
1946 let folded = doc
1947 .headings
1948 .iter()
1949 .find(|h| h.title == "first discovered thing")
1950 .unwrap();
1951 assert!(folded.body.contains("Some body line."));
1952
1953 let stamped = fs::read_to_string(&inbox).unwrap();
1955 assert_eq!(stamped.matches("* DONE ").count(), 3);
1956 assert_eq!(stamped.matches(":VISSUE_ID: sample-").count(), 2);
1957 assert!(!stamped.contains("* TODO "));
1958
1959 let again = fold(&layout, &inbox, "sample").unwrap();
1961 assert_eq!(again, "folded 0 (nothing unstamped)\n");
1962 let doc2 = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1963 assert_eq!(doc2.headings.len(), doc.headings.len());
1964 }
1965
1966 #[test]
1967 fn refile_to_moves_across_two_layouts_and_leaves_no_shadow() {
1968 let src_dir = tempfile::tempdir().unwrap();
1969 let dst_dir = tempfile::tempdir().unwrap();
1970 let src_layout = fresh_layout(src_dir.path());
1971 let dst_layout = fresh_layout(dst_dir.path());
1972 create(&src_layout, "misc", "wrong board", CreateOpts::default()).unwrap();
1973 let id = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc"))
1974 .unwrap()
1975 .headings[0]
1976 .id
1977 .clone();
1978
1979 let out = refile_to(&src_layout, &id, &dst_layout, "surf").unwrap();
1980 assert!(out.contains("misc -> surf"), "{out}");
1981
1982 let moved = IssueDoc::parse_file("surf", &dst_layout.project_issues_path("surf")).unwrap();
1985 assert_eq!(moved.headings.len(), 1);
1986 assert_eq!(moved.headings[0].id, id);
1987 assert!(!src_layout.project_issues_path("surf").exists());
1988 let left = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc")).unwrap();
1989 assert!(left.headings.is_empty());
1990 }
1991
1992 #[test]
1993 fn reject_creates_the_successor_on_the_destination_layout() {
1994 let src_dir = tempfile::tempdir().unwrap();
1995 let dst_dir = tempfile::tempdir().unwrap();
1996 let src_layout = fresh_layout(src_dir.path());
1997 let dst_layout = fresh_layout(dst_dir.path());
1998 create(&src_layout, "misc", "old approach", CreateOpts::default()).unwrap();
1999 let src = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc"))
2000 .unwrap()
2001 .headings[0]
2002 .id
2003 .clone();
2004
2005 let twin_dir = tempfile::tempdir().unwrap();
2010 let twin_layout = fresh_layout(twin_dir.path());
2011 let twin_path = twin_layout.project_issues_path("surf");
2012 std::fs::create_dir_all(twin_path.parent().unwrap()).unwrap();
2013 std::fs::write(
2014 &twin_path,
2015 "#+TITLE: surf issues\n\n* TODO taken elsewhere\n:PROPERTIES:\n :ID: surf-aaaa\n:END:\n",
2016 )
2017 .unwrap();
2018 let twins = vec![twin_path.clone()];
2019 let out = reject(
2020 &src_layout,
2021 &src,
2022 RejectOpts {
2023 project: Some("surf"),
2024 title: Some("new approach"),
2025 dst_layout: Some(&dst_layout),
2026 dst_extra_id_paths: &twins,
2027 ..Default::default()
2028 },
2029 )
2030 .unwrap();
2031
2032 assert!(!src_layout.project_issues_path("surf").exists());
2033 let made = IssueDoc::parse_file("surf", &dst_layout.project_issues_path("surf")).unwrap();
2034 assert_eq!(made.headings.len(), 1);
2035 assert_ne!(made.headings[0].id, "surf-aaaa");
2036 assert!(out.contains(&made.headings[0].id), "{out}");
2037 assert_eq!(issue_at(&src_layout, "misc", &src).state, "CANCELLED");
2038 }
2039
2040 #[test]
2041 fn reject_to_an_existing_issue_cancels_and_wires_the_pair() {
2042 let dir = tempfile::tempdir().unwrap();
2043 let layout = fresh_layout(dir.path());
2044 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
2045 create(&layout, "sample", "new approach", CreateOpts::default()).unwrap();
2046 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2047 let src = doc.headings[0].id.clone();
2048 let dst = doc.headings[1].id.clone();
2049
2050 let out = reject(
2051 &layout,
2052 &src,
2053 RejectOpts {
2054 to: Some(&dst),
2055 ..Default::default()
2056 },
2057 )
2058 .unwrap();
2059 assert!(out.contains(&src) && out.contains(&dst), "{out}");
2060
2061 let src_h = issue_at(&layout, "sample", &src);
2062 assert_eq!(src_h.state, "CANCELLED");
2063 assert_eq!(
2064 src_h.properties.get("PIVOTED_TO").map(String::as_str),
2065 Some(dst.as_str())
2066 );
2067 let dst_h = issue_at(&layout, "sample", &dst);
2068 assert_eq!(
2069 dst_h.properties.get("DISCOVERED_FROM").map(String::as_str),
2070 Some(src.as_str())
2071 );
2072 }
2073
2074 #[test]
2075 fn reject_creates_the_destination_in_another_project() {
2076 let dir = tempfile::tempdir().unwrap();
2077 let layout = fresh_layout(dir.path());
2078 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
2079 let src = only_id(&layout, "sample");
2080
2081 let out = reject(
2082 &layout,
2083 &src,
2084 RejectOpts {
2085 project: Some("other"),
2086 title: Some("new approach"),
2087 ..Default::default()
2088 },
2089 )
2090 .unwrap();
2091
2092 let dst_doc = IssueDoc::parse_file("other", &layout.project_issues_path("other")).unwrap();
2093 assert_eq!(dst_doc.headings.len(), 1);
2094 let dst = &dst_doc.headings[0];
2095 assert_eq!(dst.title, "new approach");
2096 assert_eq!(
2097 dst.properties.get("DISCOVERED_FROM").map(String::as_str),
2098 Some(src.as_str())
2099 );
2100 assert!(out.contains(&src) && out.contains(&dst.id), "{out}");
2101
2102 let src_h = issue_at(&layout, "sample", &src);
2103 assert_eq!(src_h.state, "CANCELLED");
2104 assert_eq!(
2105 src_h.properties.get("PIVOTED_TO").map(String::as_str),
2106 Some(dst.id.as_str())
2107 );
2108 }
2109
2110 #[test]
2111 fn reject_refuses_an_unknown_source_or_destination() {
2112 let dir = tempfile::tempdir().unwrap();
2113 let layout = fresh_layout(dir.path());
2114 create(&layout, "sample", "only", CreateOpts::default()).unwrap();
2115 let src = only_id(&layout, "sample");
2116
2117 let missing_src = reject(
2118 &layout,
2119 "sample-zzzz",
2120 RejectOpts {
2121 to: Some(&src),
2122 ..Default::default()
2123 },
2124 )
2125 .unwrap_err();
2126 assert!(
2127 matches!(missing_src, Error::IssueNotFound { .. }),
2128 "{missing_src}"
2129 );
2130
2131 let missing_dst = reject(
2132 &layout,
2133 &src,
2134 RejectOpts {
2135 to: Some("sample-zzzz"),
2136 ..Default::default()
2137 },
2138 )
2139 .unwrap_err();
2140 assert!(
2141 matches!(missing_dst, Error::IssueNotFound { .. }),
2142 "{missing_dst}"
2143 );
2144 }
2145
2146 #[test]
2147 fn reject_does_not_overwrite_a_nonempty_discovered_from() {
2148 let dir = tempfile::tempdir().unwrap();
2149 let layout = fresh_layout(dir.path());
2150 create(&layout, "sample", "origin", CreateOpts::default()).unwrap();
2151 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
2152 create(&layout, "sample", "already sourced", CreateOpts::default()).unwrap();
2153 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2154 let origin = doc.headings[0].id.clone();
2155 let src = doc.headings[1].id.clone();
2156 let dst = doc.headings[2].id.clone();
2157
2158 let path = layout.project_issues_path("sample");
2159 let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
2160 doc.headings
2161 .iter_mut()
2162 .find(|h| h.id == dst)
2163 .unwrap()
2164 .properties
2165 .insert("DISCOVERED_FROM".into(), origin.clone());
2166 doc.write().unwrap();
2167
2168 reject(
2169 &layout,
2170 &src,
2171 RejectOpts {
2172 to: Some(&dst),
2173 ..Default::default()
2174 },
2175 )
2176 .unwrap();
2177 let dst_h = issue_at(&layout, "sample", &dst);
2178 assert_eq!(
2179 dst_h.properties.get("DISCOVERED_FROM").map(String::as_str),
2180 Some(origin.as_str()),
2181 "a filled DISCOVERED_FROM stays put"
2182 );
2183 }
2184
2185 #[test]
2186 fn create_sets_discovered_from_from_the_first_known_id_link() {
2187 let dir = tempfile::tempdir().unwrap();
2188 let layout = fresh_layout(dir.path());
2189 create(&layout, "sample", "source", CreateOpts::default()).unwrap();
2190 let known = only_id(&layout, "sample");
2191 create(
2192 &layout,
2193 "sample",
2194 "fell out of it",
2195 CreateOpts {
2196 body: Some(&format!("See [[id:{known}]] for the parent finding.")),
2197 ..Default::default()
2198 },
2199 )
2200 .unwrap();
2201 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2202 let child = doc
2203 .headings
2204 .iter()
2205 .find(|h| h.title == "fell out of it")
2206 .unwrap();
2207 assert_eq!(
2208 child.properties.get("DISCOVERED_FROM").map(String::as_str),
2209 Some(known.as_str())
2210 );
2211 }
2212
2213 #[test]
2214 fn create_ignores_an_id_link_that_is_not_in_the_corpus() {
2215 let dir = tempfile::tempdir().unwrap();
2216 let layout = fresh_layout(dir.path());
2217 create(
2218 &layout,
2219 "sample",
2220 "orphan mention",
2221 CreateOpts {
2222 body: Some("See [[id:sample-zzzz]] which does not exist."),
2223 ..Default::default()
2224 },
2225 )
2226 .unwrap();
2227 let h = issue_at(&layout, "sample", &only_id(&layout, "sample"));
2228 assert!(
2229 !h.properties.contains_key("DISCOVERED_FROM"),
2230 "unknown [[id:]] must not mint DISCOVERED_FROM: {h:?}"
2231 );
2232 assert!(
2233 !h.properties.contains_key("BLOCKED_BY"),
2234 "prose must not mint BLOCKED_BY: {h:?}"
2235 );
2236 }
2237
2238 #[test]
2239 fn related_after_reject_names_the_successor_without_a_body_link() {
2240 let dir = tempfile::tempdir().unwrap();
2241 let layout = fresh_layout(dir.path());
2242 create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
2243 create(&layout, "sample", "new approach", CreateOpts::default()).unwrap();
2244 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2245 let src = doc.headings[0].id.clone();
2246 let dst = doc.headings[1].id.clone();
2247 reject(
2248 &layout,
2249 &src,
2250 RejectOpts {
2251 to: Some(&dst),
2252 ..Default::default()
2253 },
2254 )
2255 .unwrap();
2256
2257 assert!(
2258 !issue_at(&layout, "sample", &src).body.contains(&dst),
2259 "the pair is wired by PIVOTED_TO, not prose"
2260 );
2261 let from_src = crate::related::related(&layout, &src, 1, 10, "text").unwrap();
2262 assert!(from_src.contains(&dst), "{from_src}");
2263 assert!(from_src.contains("pivoted_to"), "{from_src}");
2264
2265 let from_dst = crate::related::related(&layout, &dst, 1, 10, "text").unwrap();
2266 assert!(from_dst.contains(&src), "{from_dst}");
2267 assert!(from_dst.contains("successor_of"), "{from_dst}");
2268
2269 let waiting = crate::report::backlinks(&layout, &dst).unwrap();
2270 assert!(waiting.contains(&src), "{waiting}");
2271 }
2272
2273 #[test]
2274 fn update_to_cancelled_emits_state_change_with_the_id() {
2275 let dir = tempfile::tempdir().unwrap();
2276 let layout = fresh_layout(dir.path());
2277 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
2278 let id = only_id(&layout, "sample");
2279 let before = crate::events::generation(&layout);
2280 update(&layout, &id, Some("CANCELLED"), None, None, None).unwrap();
2281 let events = crate::events::since(&layout, before, 50).unwrap();
2282 assert!(
2283 events.iter().any(|e| {
2284 e.kind == "state_change"
2285 && e.id.as_deref() == Some(id.as_str())
2286 && e.detail.as_deref() == Some("TODO->CANCELLED")
2287 }),
2288 "{events:?}"
2289 );
2290 }
2291
2292 #[test]
2293 fn a_stale_done_after_reject_is_refused_and_the_source_stays_cancelled() {
2294 let dir = tempfile::tempdir().unwrap();
2295 let layout = fresh_layout(dir.path());
2296 create(&layout, "sample", "old plan", CreateOpts::default()).unwrap();
2297 create(&layout, "sample", "rewrite", CreateOpts::default()).unwrap();
2298 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2299 let src = doc.headings[0].id.clone();
2300 let dst = doc.headings[1].id.clone();
2301 reject(
2302 &layout,
2303 &src,
2304 RejectOpts {
2305 to: Some(&dst),
2306 ..Default::default()
2307 },
2308 )
2309 .unwrap();
2310
2311 let err = update_pred(
2312 &layout,
2313 &src,
2314 Some("DONE"),
2315 None,
2316 None,
2317 None,
2318 UpdatePred {
2319 if_state: Some("STARTED"),
2320 if_gen: None,
2321 },
2322 )
2323 .unwrap_err();
2324 assert!(
2325 matches!(
2326 err,
2327 Error::StaleWrite {
2328 ref actual_state,
2329 ref expected_state,
2330 ..
2331 } if actual_state == "CANCELLED" && expected_state.as_deref() == Some("STARTED")
2332 ),
2333 "{err:?}"
2334 );
2335 assert_eq!(issue_at(&layout, "sample", &src).state, "CANCELLED");
2336 }
2337
2338 #[test]
2339 fn if_gen_refuses_when_the_corpus_moved() {
2340 let dir = tempfile::tempdir().unwrap();
2341 let layout = fresh_layout(dir.path());
2342 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
2343 let id = only_id(&layout, "sample");
2344 let seen = crate::events::generation(&layout);
2345 update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
2346 let err = update_pred(
2347 &layout,
2348 &id,
2349 Some("DONE"),
2350 None,
2351 None,
2352 None,
2353 UpdatePred {
2354 if_state: None,
2355 if_gen: Some(seen),
2356 },
2357 )
2358 .unwrap_err();
2359 assert!(matches!(err, Error::StaleWrite { .. }), "{err:?}");
2360 assert_eq!(issue_at(&layout, "sample", &id).state, "STARTED");
2361 }
2362
2363 #[test]
2364 fn a_second_terminal_does_not_drop_the_first() {
2365 let dir = tempfile::tempdir().unwrap();
2366 let layout = fresh_layout(dir.path());
2367 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
2368 let id = only_id(&layout, "sample");
2369 update(&layout, &id, Some("DONE"), None, None, None).unwrap();
2370 update(&layout, &id, Some("CANCELLED"), None, None, None).unwrap();
2371 let h = issue_at(&layout, "sample", &id);
2372 assert_eq!(h.state, "DONE", "first terminal must stay");
2373 assert_eq!(
2374 crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL),
2375 Some("CANCELLED")
2376 );
2377
2378 resolve_terminal(&layout, &id, "CANCELLED").unwrap();
2379 let h = issue_at(&layout, "sample", &id);
2380 assert_eq!(h.state, "CANCELLED");
2381 assert!(crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_none());
2382 }
2383
2384 #[test]
2385 fn check_warns_on_reject_prose_done_and_a_mention_without_an_edge() {
2386 let dir = tempfile::tempdir().unwrap();
2387 let layout = fresh_layout(dir.path());
2388 create(&layout, "sample", "shipped", CreateOpts::default()).unwrap();
2389 create(&layout, "sample", "other", CreateOpts::default()).unwrap();
2390 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2391 let shipped = doc.headings[0].id.clone();
2392 let other = doc.headings[1].id.clone();
2393 update(&layout, &shipped, Some("DONE"), None, None, None).unwrap();
2394 append_body(&layout, &shipped, "superseded by the other one, bounced").unwrap();
2395 append_body(
2396 &layout,
2397 &other,
2398 &format!("discovered while reading [[id:{shipped}]]"),
2399 )
2400 .unwrap();
2401
2402 let report = crate::report::check(&layout).unwrap();
2403 assert!(
2404 report.text.contains(&shipped)
2405 && report.text.contains("DONE but the body reads as a reject"),
2406 "{}",
2407 report.text
2408 );
2409 assert!(
2410 report.text.contains(&other)
2411 && report
2412 .text
2413 .contains("as discovered or pivoted with no edge"),
2414 "{}",
2415 report.text
2416 );
2417 assert!(report.warnings >= 2, "{}", report.text);
2418 }
2419
2420 #[test]
2424 fn check_is_quiet_about_a_done_issue_that_merely_uses_the_word_rejected() {
2425 let dir = tempfile::tempdir().unwrap();
2426 let layout = fresh_layout(dir.path());
2427 create(&layout, "sample", "validation", CreateOpts::default()).unwrap();
2428 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2429 let id = doc.headings[0].id.clone();
2430 update(&layout, &id, Some("DONE"), None, None, None).unwrap();
2431 append_body(
2432 &layout,
2433 &id,
2434 "A compound spec is silently corrupted rather than rejected, and the \
2435 alternative parser was rejected as strictly dominated.",
2436 )
2437 .unwrap();
2438
2439 let report = crate::report::check(&layout).unwrap();
2440 assert!(
2441 !report.text.contains("reads as a reject"),
2442 "the word alone was read as an outcome: {}",
2443 report.text
2444 );
2445 }
2446
2447 #[test]
2450 fn check_reads_supersedes_as_a_roll_up_and_superseded_by_as_an_outcome() {
2451 let dir = tempfile::tempdir().unwrap();
2452 let layout = fresh_layout(dir.path());
2453 create(&layout, "sample", "umbrella", CreateOpts::default()).unwrap();
2454 create(&layout, "sample", "replaced", CreateOpts::default()).unwrap();
2455 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2456 let rollup = doc.headings[0].id.clone();
2457 let replaced = doc.headings[1].id.clone();
2458 update(&layout, &rollup, Some("DONE"), None, None, None).unwrap();
2459 update(&layout, &replaced, Some("DONE"), None, None, None).unwrap();
2460 append_body(&layout, &rollup, "** Supersedes\nrolls up the pieces").unwrap();
2461 append_body(&layout, &replaced, "superseded by the umbrella").unwrap();
2462
2463 let report = crate::report::check(&layout).unwrap();
2464 let flagged: Vec<&str> = report
2465 .text
2466 .lines()
2467 .filter(|l| l.contains("reads as a reject"))
2468 .collect();
2469
2470 assert!(
2471 flagged.iter().any(|l| l.contains(&replaced)),
2472 "an issue that says it was superseded was not flagged: {}",
2473 report.text
2474 );
2475 assert!(
2476 !flagged.iter().any(|l| l.contains(&rollup)),
2477 "a Supersedes roll-up was read as its own rejection: {}",
2478 report.text
2479 );
2480 }
2481
2482 #[test]
2485 fn check_is_quiet_about_a_mention_that_claims_no_relation() {
2486 let dir = tempfile::tempdir().unwrap();
2487 let layout = fresh_layout(dir.path());
2488 create(&layout, "sample", "umbrella", CreateOpts::default()).unwrap();
2489 create(&layout, "sample", "piece", CreateOpts::default()).unwrap();
2490 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2491 let umbrella = doc.headings[0].id.clone();
2492 let piece = doc.headings[1].id.clone();
2493 append_body(
2494 &layout,
2495 &umbrella,
2496 &format!("** Supersedes\nRolls up [[id:{piece}]], which it does not close."),
2497 )
2498 .unwrap();
2499
2500 let report = crate::report::check(&layout).unwrap();
2501 assert!(
2502 !report.text.contains("as discovered or pivoted"),
2503 "a roll-up was read as a discovery: {}",
2504 report.text
2505 );
2506 }
2507
2508 #[test]
2510 fn check_reads_a_discovery_claim_only_near_the_link_it_belongs_to() {
2511 let dir = tempfile::tempdir().unwrap();
2512 let layout = fresh_layout(dir.path());
2513 create(&layout, "sample", "long", CreateOpts::default()).unwrap();
2514 create(&layout, "sample", "elsewhere", CreateOpts::default()).unwrap();
2515 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2516 let long = doc.headings[0].id.clone();
2517 let elsewhere = doc.headings[1].id.clone();
2518 let filler = "prose ".repeat(120);
2519 append_body(
2520 &layout,
2521 &long,
2522 &format!("discovered while auditing the loader.\n{filler}\nsee [[id:{elsewhere}]]"),
2523 )
2524 .unwrap();
2525
2526 let report = crate::report::check(&layout).unwrap();
2527 assert!(
2528 !report.text.contains("as discovered or pivoted"),
2529 "a claim in another section was attached to this link: {}",
2530 report.text
2531 );
2532 }
2533
2534 #[test]
2536 fn check_is_quiet_about_a_mention_that_a_parent_edge_already_explains() {
2537 let dir = tempfile::tempdir().unwrap();
2538 let layout = fresh_layout(dir.path());
2539 create(&layout, "sample", "umbrella", CreateOpts::default()).unwrap();
2540 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2541 let parent = doc.headings[0].id.clone();
2542 create(
2543 &layout,
2544 "sample",
2545 "piece",
2546 CreateOpts {
2547 parent: Some(parent.as_str()),
2548 ..CreateOpts::default()
2549 },
2550 )
2551 .unwrap();
2552 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2553 let child = doc
2554 .headings
2555 .iter()
2556 .find(|h| h.id != parent)
2557 .map(|h| h.id.clone())
2558 .unwrap();
2559 append_body(
2564 &layout,
2565 &parent,
2566 &format!("discovered while reading [[id:{child}]]"),
2567 )
2568 .unwrap();
2569 create(&layout, "sample", "unrelated", CreateOpts::default()).unwrap();
2570 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2571 let stranger = doc
2572 .headings
2573 .iter()
2574 .find(|h| h.id != parent && h.id != child)
2575 .map(|h| h.id.clone())
2576 .unwrap();
2577 append_body(
2578 &layout,
2579 &stranger,
2580 &format!("discovered while reading [[id:{parent}]]"),
2581 )
2582 .unwrap();
2583
2584 let report = crate::report::check(&layout).unwrap();
2585 let flagged: Vec<&str> = report
2586 .text
2587 .lines()
2588 .filter(|l| l.contains("as discovered or pivoted"))
2589 .collect();
2590 assert!(
2591 flagged.iter().any(|l| l.contains(&stranger)),
2592 "the control pair with no edge was not flagged, so this test proves nothing: {}",
2593 report.text
2594 );
2595 assert!(
2596 !flagged
2597 .iter()
2598 .any(|l| l.contains(&parent) && l.contains(&child)),
2599 "a parent edge did not count as a relation: {}",
2600 report.text
2601 );
2602 }
2603
2604 #[test]
2605 fn check_names_a_file_missing_category_and_a_type_not_on_the_heading() {
2606 let dir = tempfile::tempdir().unwrap();
2607 let layout = fresh_layout(dir.path());
2608 let path = layout.project_issues_path("sample");
2609 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2610 std::fs::write(
2611 &path,
2612 "#+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",
2613 )
2614 .unwrap();
2615 let report = crate::report::check(&layout).unwrap();
2616 assert!(
2617 report.text.contains("sample: preamble has no #+CATEGORY:"),
2618 "{}",
2619 report.text
2620 );
2621 assert!(
2622 report
2623 .text
2624 .contains("have :TYPE: that is a legal Org tag but is not on the heading"),
2625 "{}",
2626 report.text
2627 );
2628 assert!(
2629 report
2630 .text
2631 .contains("preamble has no #+VISSUE: protocol stamp"),
2632 "{}",
2633 report.text
2634 );
2635 assert!(
2636 report.text.contains("preamble has no #+PRIORITIES:"),
2637 "{}",
2638 report.text
2639 );
2640 }
2641
2642 #[test]
2643 fn check_errors_on_a_newer_protocol_stamp() {
2644 let dir = tempfile::tempdir().unwrap();
2645 let layout = fresh_layout(dir.path());
2646 let path = layout.project_issues_path("sample");
2647 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2648 std::fs::write(
2649 &path,
2650 "#+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",
2651 )
2652 .unwrap();
2653 let report = crate::report::check(&layout).unwrap();
2654 assert!(report.errors >= 1, "{}", report.text);
2655 assert!(
2656 report
2657 .text
2658 .contains("#+VISSUE: 99 is newer than this vissue"),
2659 "{}",
2660 report.text
2661 );
2662 }
2663
2664 #[test]
2665 fn normalize_rewrites_legacy_keys_and_keeps_edna() {
2666 let dir = tempfile::tempdir().unwrap();
2667 let layout = fresh_layout(dir.path());
2668 let path = layout.project_issues_path("sample");
2669 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2670 std::fs::write(
2671 &path,
2672 "#+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",
2673 )
2674 .unwrap();
2675 let dry = normalize(&layout, Some("sample"), true).unwrap();
2676 assert!(dry.contains("would rewrite"), "{dry}");
2677 let on_disk = std::fs::read_to_string(&path).unwrap();
2678 assert!(on_disk.contains(":TYPE:"), "{on_disk}");
2679 let wrote = normalize(&layout, Some("sample"), false).unwrap();
2680 assert!(wrote.contains("rewrote"), "{wrote}");
2681 let after = std::fs::read_to_string(&path).unwrap();
2682 assert!(after.contains("#+CATEGORY: sample"), "{after}");
2683 assert!(after.contains("#+PRIORITIES: A C C"), "{after}");
2684 assert!(after.contains(":TYPE: bug"), "{after}");
2685 assert!(after.contains(":PARENT:"), "{after}");
2686 assert!(after.contains(":BLOCKED_BY:"), "{after}");
2687 assert!(
2688 !after.contains("ids(sample-bbbb)"),
2689 "normalize must not mint edna ids(): {after}"
2690 );
2691 assert!(after.contains("prev-sibling"), "{after}");
2692 }
2693 #[test]
2706 fn the_reservation_is_read_after_the_lock_is_held() {
2707 let dir = tempfile::tempdir().unwrap();
2708 let own_root = dir.path().join("own");
2709 let twin_root = dir.path().join("twin");
2710 std::fs::create_dir_all(&own_root).unwrap();
2711 std::fs::create_dir_all(&twin_root).unwrap();
2712 std::fs::write(own_root.join("vissue.toml"), "[issues]\nid_length = 2\n").unwrap();
2713 let own = fresh_layout(&own_root);
2714 let twin = fresh_layout(&twin_root);
2715
2716 let mut body = String::from("#+TITLE: sample issues\n\n");
2718 let alphabet = b"0123456789abcdefghijklmnopqrstuvwxyz";
2719 for a in alphabet {
2720 for b in alphabet {
2721 if *a == b'z' && *b == b'z' {
2722 continue;
2723 }
2724 let id = format!("sample-{}{}", *a as char, *b as char);
2725 body.push_str(&format!(
2726 "* TODO filler {id}\n:PROPERTIES:\n:ID: {id}\n:END:\n\n"
2727 ));
2728 }
2729 }
2730 let twin_path = twin.project_issues_path("sample");
2731 std::fs::create_dir_all(twin_path.parent().unwrap()).unwrap();
2732 std::fs::write(&twin_path, body).unwrap();
2733
2734 let twins = vec![twin_path.clone()];
2735 let id = create(
2736 &own,
2737 "sample",
2738 "the only suffix left",
2739 CreateOpts {
2740 quiet: true,
2741 extra_id_paths: &twins,
2742 ..Default::default()
2743 },
2744 )
2745 .expect("create failed")
2746 .trim()
2747 .to_string();
2748
2749 assert_eq!(
2750 id, "sample-zz",
2751 "the mint did not treat the twin file as taken, so it read the reservation \
2752 before the lock rather than after"
2753 );
2754 }
2755
2756 #[test]
2761 fn the_written_file_appearing_in_its_own_reservation_is_not_a_deadlock() {
2762 let dir = tempfile::tempdir().unwrap();
2763 let layout = fresh_layout(dir.path());
2764 let own_path = layout.project_issues_path("sample");
2765 let twins = vec![own_path.clone(), own_path.clone()];
2766 let id = create(
2767 &layout,
2768 "sample",
2769 "self referential reservation",
2770 CreateOpts {
2771 quiet: true,
2772 extra_id_paths: &twins,
2773 ..Default::default()
2774 },
2775 )
2776 .expect("create deadlocked or failed")
2777 .trim()
2778 .to_string();
2779 assert!(id.starts_with("sample-"), "{id}");
2780 }
2781 fn voted(layout: &Layout, id: &str, who: &str, choice: &str) -> String {
2784 vote(layout, id, Some(choice), who).expect("vote failed")
2785 }
2786
2787 #[test]
2788 fn one_agent_one_ballot_and_a_recast_replaces_it() {
2789 let dir = tempfile::tempdir().unwrap();
2790 let layout = fresh_layout(dir.path());
2791 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2792 let id = only_id(&layout, "sample");
2793
2794 voted(&layout, &id, "agent-a", "ship");
2795 let out = voted(&layout, &id, "agent-a", "hold");
2796 assert!(out.contains("changed ship to hold"), "{out}");
2797
2798 let tally = vote(&layout, &id, None, "reader").unwrap();
2799 assert!(tally.contains("1 vote from 1 option"), "{tally}");
2800 assert!(tally.contains("hold"), "{tally}");
2801 assert!(!tally.contains("ship"), "{tally}");
2802 }
2803
2804 #[test]
2805 fn two_agents_do_not_overwrite_each_other() {
2806 let dir = tempfile::tempdir().unwrap();
2807 let layout = fresh_layout(dir.path());
2808 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2809 let id = only_id(&layout, "sample");
2810
2811 voted(&layout, &id, "agent-a", "ship");
2812 voted(&layout, &id, "agent-b", "ship");
2813 let out = voted(&layout, &id, "agent-c", "hold");
2814
2815 assert!(out.contains("3 votes from 2 options"), "{out}");
2816 assert!(out.contains("consensus: ship (2 of 3)"), "{out}");
2817 }
2818
2819 #[test]
2822 fn a_tie_is_reported_as_no_consensus() {
2823 let dir = tempfile::tempdir().unwrap();
2824 let layout = fresh_layout(dir.path());
2825 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2826 let id = only_id(&layout, "sample");
2827
2828 voted(&layout, &id, "agent-a", "ship");
2829 let out = voted(&layout, &id, "agent-b", "hold");
2830
2831 assert!(out.contains("no consensus: 2 options tied at 1"), "{out}");
2832 assert!(!out.contains("consensus: ship"), "{out}");
2833 }
2834
2835 #[test]
2838 fn a_lead_short_of_a_majority_is_not_called_consensus() {
2839 let dir = tempfile::tempdir().unwrap();
2840 let layout = fresh_layout(dir.path());
2841 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2842 let id = only_id(&layout, "sample");
2843
2844 voted(&layout, &id, "agent-a", "ship");
2845 voted(&layout, &id, "agent-b", "ship");
2846 voted(&layout, &id, "agent-c", "hold");
2847 let out = voted(&layout, &id, "agent-d", "rework");
2848
2849 assert!(out.contains("plurality only: ship (2 of 4)"), "{out}");
2851 assert!(!out.contains("consensus: ship"), "{out}");
2852 }
2853
2854 #[test]
2855 fn votes_survive_a_rewrite_and_are_readable_in_the_file() {
2856 let dir = tempfile::tempdir().unwrap();
2857 let layout = fresh_layout(dir.path());
2858 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2859 let id = only_id(&layout, "sample");
2860 voted(&layout, &id, "agent-a", "ship");
2861
2862 append_body(&layout, &id, "some prose").unwrap();
2864 let text = std::fs::read_to_string(layout.project_issues_path("sample")).unwrap();
2865 assert!(text.contains(":VOTES:"), "{text}");
2866 assert!(text.contains("agent-a: ship"), "{text}");
2867
2868 let tally = vote(&layout, &id, None, "reader").unwrap();
2869 assert!(tally.contains("agent-a"), "{tally}");
2870 }
2871
2872 #[test]
2873 fn an_issue_with_no_votes_says_so_rather_than_showing_an_empty_table() {
2874 let dir = tempfile::tempdir().unwrap();
2875 let layout = fresh_layout(dir.path());
2876 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2877 let id = only_id(&layout, "sample");
2878 assert!(
2879 vote(&layout, &id, None, "reader")
2880 .unwrap()
2881 .contains("no votes")
2882 );
2883 }
2884
2885 #[test]
2886 fn a_blank_or_multiline_vote_is_refused() {
2887 let dir = tempfile::tempdir().unwrap();
2888 let layout = fresh_layout(dir.path());
2889 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2890 let id = only_id(&layout, "sample");
2891 assert!(vote(&layout, &id, Some(" "), "agent-a").is_err());
2892 assert!(vote(&layout, &id, Some("ship\nhold"), "agent-a").is_err());
2893 }
2894
2895 #[test]
2898 fn a_choice_containing_a_colon_round_trips() {
2899 let dir = tempfile::tempdir().unwrap();
2900 let layout = fresh_layout(dir.path());
2901 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2902 let id = only_id(&layout, "sample");
2903 voted(&layout, &id, "agent-a", "ship: after the audit");
2904 let tally = vote(&layout, &id, None, "reader").unwrap();
2905 assert!(tally.contains("ship: after the audit"), "{tally}");
2906 }
2907
2908 #[test]
2911 fn concurrent_voters_all_land() {
2912 use std::sync::Arc;
2913 use std::thread;
2914
2915 let dir = tempfile::tempdir().unwrap();
2916 let layout = Arc::new(fresh_layout(dir.path()));
2917 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2918 let id = only_id(&layout, "sample");
2919
2920 let n = 16usize;
2921 let handles: Vec<_> = (0..n)
2922 .map(|i| {
2923 let layout = Arc::clone(&layout);
2924 let id = id.clone();
2925 thread::spawn(move || vote(&layout, &id, Some("ship"), &format!("agent-{i:02}")))
2926 })
2927 .collect();
2928 for h in handles {
2929 h.join().expect("thread panicked").expect("vote failed");
2930 }
2931
2932 let tally = vote(&layout, &id, None, "reader").unwrap();
2933 assert!(
2934 tally.contains(&format!("{n} votes from 1 option")),
2935 "a ballot was lost: {tally}"
2936 );
2937 }
2938 #[test]
2941 fn a_single_ballot_is_not_called_a_consensus() {
2942 let dir = tempfile::tempdir().unwrap();
2943 let layout = fresh_layout(dir.path());
2944 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2945 let id = only_id(&layout, "sample");
2946
2947 let out = voted(&layout, &id, "agent-a", "ship");
2948 assert!(out.contains("one ballot only: ship"), "{out}");
2949 assert!(!out.contains("consensus: ship"), "{out}");
2950
2951 let out = voted(&layout, &id, "agent-b", "ship");
2953 assert!(out.contains("consensus: ship (2 of 2)"), "{out}");
2954 }
2955
2956 #[test]
2961 fn an_identity_that_the_line_format_cannot_hold_is_refused() {
2962 let dir = tempfile::tempdir().unwrap();
2963 let layout = fresh_layout(dir.path());
2964 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2965 let id = only_id(&layout, "sample");
2966
2967 let err = vote(&layout, &id, Some("ship"), "team: alpha").unwrap_err();
2968 assert!(err.to_string().contains("colon"), "{err}");
2969 assert!(vote(&layout, &id, Some("ship"), " ").is_err());
2970
2971 assert!(
2973 vote(&layout, &id, None, "reader")
2974 .unwrap()
2975 .contains("no votes")
2976 );
2977 }
2978
2979 #[test]
2983 fn a_hand_written_line_in_the_drawer_survives_a_vote() {
2984 let dir = tempfile::tempdir().unwrap();
2985 let layout = fresh_layout(dir.path());
2986 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
2987 let id = only_id(&layout, "sample");
2988 voted(&layout, &id, "agent-a", "ship");
2989
2990 let path = layout.project_issues_path("sample");
2992 let text = std::fs::read_to_string(&path).unwrap();
2993 let edited = text.replace(
2994 ":VOTES:\n",
2995 ":VOTES:\n# decided at the Tuesday review, do not clear\n",
2996 );
2997 std::fs::write(&path, edited).unwrap();
2998
2999 voted(&layout, &id, "agent-b", "hold");
3000
3001 let after = std::fs::read_to_string(&path).unwrap();
3002 assert!(
3003 after.contains("# decided at the Tuesday review, do not clear"),
3004 "the hand-written line was eaten: {after}"
3005 );
3006 assert!(after.contains("agent-a: ship"), "{after}");
3007 assert!(after.contains("agent-b: hold"), "{after}");
3008 }
3009
3010 #[cfg(unix)]
3025 #[test]
3026 fn one_file_named_two_ways_is_locked_once() {
3027 let dir = tempfile::tempdir().unwrap();
3028 let layout = fresh_layout(dir.path());
3029 let direct = layout.project_issues_path("sample");
3030 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
3031
3032 let link = dir.path().join("linked");
3034 std::os::unix::fs::symlink(dir.path().join(DEFAULT_PREFIX), &link).unwrap();
3035 let indirect = link.join("sample").join("issues.org");
3036 assert!(indirect.exists(), "the link does not reach the file");
3037 assert_ne!(
3038 direct.components().count(),
3039 0,
3040 "the two paths must differ by components or this proves nothing"
3041 );
3042 assert!(
3043 direct != indirect,
3044 "the two paths compare equal, so the plain dedup would already collapse them"
3045 );
3046
3047 let twins = vec![direct.clone(), indirect];
3048 let id = create(
3049 &layout,
3050 "sample",
3051 "second",
3052 CreateOpts {
3053 quiet: true,
3054 extra_id_paths: &twins,
3055 ..Default::default()
3056 },
3057 )
3058 .expect("create hung or failed on an aliased lock path")
3059 .trim()
3060 .to_string();
3061 assert!(id.starts_with("sample-"), "{id}");
3062 }
3063
3064 #[test]
3068 fn two_hand_written_lines_for_one_agent_collapse_to_the_last() {
3069 let dir = tempfile::tempdir().unwrap();
3070 let layout = fresh_layout(dir.path());
3071 create(&layout, "sample", "what to do", CreateOpts::default()).unwrap();
3072 let id = only_id(&layout, "sample");
3073 voted(&layout, &id, "agent-b", "hold");
3074
3075 let path = layout.project_issues_path("sample");
3076 let text = std::fs::read_to_string(&path).unwrap();
3077 let edited = text.replace(
3078 ":VOTES:\n",
3079 ":VOTES:\n[2026-01-01 Thu] agent-a: ship\n[2026-02-02 Mon] agent-a: rework\n",
3080 );
3081 std::fs::write(&path, edited).unwrap();
3082
3083 let tally = vote(&layout, &id, None, "reader").unwrap();
3084 assert!(tally.contains("2 votes from 2 options"), "{tally}");
3086 assert!(tally.contains("rework"), "{tally}");
3087 assert!(!tally.contains("ship"), "{tally}");
3088
3089 voted(&layout, &id, "agent-c", "hold");
3091 let after = std::fs::read_to_string(&path).unwrap();
3092 assert_eq!(after.matches("agent-a:").count(), 1, "{after}");
3093 }
3094}