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