Skip to main content

vissue_core/
ops.rs

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