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