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