Skip to main content

vissue_core/
ops.rs

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