Skip to main content

vissue_core/
ops.rs

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