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 _;
9
10use crate::config::{Layout, VissueConfig};
11use crate::error::Error;
12use crate::graph::DependencyGraph;
13use crate::model::{IssueHeading, LogEntry, TODO_KEYWORDS, today_inactive_bracket};
14use crate::store::{
15    IssueDoc, collect_org_ids, detect_project_from_ctx, find_by_id, generate_id, load_all,
16    resolve_existing_project_case, with_issues_lock, with_issues_locks,
17};
18
19/// Resolve the project to act on. An explicit name wins; otherwise walk up from
20/// the current directory for `.project-ctx.toml` and read `[project].name`.
21/// Neither available is an error, so nothing is ever guessed silently.
22///
23/// # Errors
24///
25/// Returns an error if the explicit project name is empty, no name can be
26/// resolved, the current directory cannot be read, or the name matches more
27/// than one project directory.
28pub fn resolve_project(layout: &Layout, explicit: Option<&str>) -> Result<String> {
29    if let Some(p) = explicit {
30        if p.is_empty() {
31            return Err(anyhow!("--project given but empty").into());
32        }
33        return resolve_existing_project_case(layout, p);
34    }
35    let cwd = std::env::current_dir()?;
36    let detected = detect_project_from_ctx(&cwd).ok_or_else(|| {
37        anyhow!(
38            "no --project given and no .project-ctx.toml found walking up from {}",
39            cwd.display()
40        )
41    })?;
42    resolve_existing_project_case(layout, &detected)
43}
44
45/// Optional fields on a new issue.
46#[derive(Debug, Default, Clone, Copy)]
47pub struct CreateOpts<'a> {
48    /// Priority cookie; the configured default is used when `None`.
49    pub priority: Option<char>,
50    /// `:TYPE:` property.
51    pub issue_type: Option<&'a str>,
52    /// Deadline as an org timestamp.
53    pub deadline: Option<&'a str>,
54    /// Scheduled date as an org timestamp.
55    pub scheduled: Option<&'a str>,
56    /// Comma- or colon-separated tags.
57    pub tags: Option<&'a str>,
58    /// `:PARENT:` id; must already exist somewhere under the prefix.
59    pub parent: Option<&'a str>,
60    /// Print only the new id.
61    pub quiet: bool,
62    /// Body prose written under the properties drawer.
63    pub body: Option<&'a str>,
64    /// Extra ids treated as taken when minting, so a twin file on another
65    /// layout cannot share a suffix with this create.
66    pub extra_ids: &'a [String],
67}
68
69/// Append a new TODO issue to the project's file and return the status text.
70///
71/// The first `[[id:XXX]]` in `body` that names a heading already in the
72/// corpus becomes `:DISCOVERED_FROM:`, unless that property is already set.
73/// Prose never writes `:BLOCKED_BY:`.
74///
75/// # Errors
76///
77/// Returns an error if the priority is not `A`/`B`/`C`, a date does not parse,
78/// `parent` is not a known org id, the id space is exhausted, or the file
79/// cannot be locked or rewritten.
80pub fn create(layout: &Layout, project: &str, title: &str, opts: CreateOpts<'_>) -> Result<String> {
81    let project = resolve_existing_project_case(layout, project)?;
82    let cfg = VissueConfig::load(layout)?;
83    let path = layout.project_issues_path(&project);
84    let (spec, named) = match IssueDoc::parse_file(&project, &path) {
85        Ok(doc) => (doc.priority_spec(), doc.priorities_are_named()),
86        Err(_) => (crate::org::PrioritySpec::default(), false),
87    };
88    let house_new = !path.exists();
89    let priority = opts.priority.unwrap_or(if named || house_new {
90        spec.default
91    } else {
92        cfg.issues.default_priority
93    });
94    if !spec.contains(priority) {
95        return Err(anyhow!(
96            "invalid priority {priority:?}; file allows [#{}]..[#{}]",
97            spec.highest,
98            spec.lowest
99        )
100        .into());
101    }
102
103    // Parent and body [[id:]] both need the corpus id set; scan once.
104    let known_ids = if opts.parent.is_some() || opts.body.is_some() {
105        collect_org_ids(layout)?
106    } else {
107        std::collections::HashSet::new()
108    };
109    if let Some(p) = opts.parent
110        && !known_ids.contains(p)
111    {
112        return Err(anyhow!("--parent {p} does not refer to any known id").into());
113    }
114
115    with_issues_lock(&path, || {
116        let mut doc = IssueDoc::parse_file(&project, &path)?;
117        let mut taken = doc.known_ids();
118        taken.extend(opts.extra_ids.iter().cloned());
119        let id = generate_id(&project, &taken, cfg.issues.id_length)?;
120
121        let mut props = BTreeMap::new();
122        props.insert("ID".into(), id.clone());
123        props.insert("CREATED".into(), today_inactive_bracket());
124        if crate::props::get(&props, crate::props::DISCOVERED_FROM).is_none()
125            && let Some(body) = opts.body
126            && let Some(origin) = first_existing_id_link(body, &known_ids)
127        {
128            crate::props::insert(&mut props, crate::props::DISCOVERED_FROM, origin);
129        }
130        let mut org_tags: Vec<String> = Vec::new();
131        if let Some(t) = opts.issue_type {
132            crate::props::insert(&mut props, crate::props::TYPE, t.into());
133            // Type is an Org tag when the character class allows it, so
134            // agenda tag search and C-c \ see `bug` / `feature` / `task`.
135            if t.chars().all(crate::model::is_org_tag_char)
136                && !t.is_empty()
137                && !org_tags.iter().any(|seen| seen == t)
138            {
139                org_tags.push(t.to_string());
140            }
141        }
142        if let Some(d) = opts.deadline {
143            validate_org_date(d)?;
144            props.insert("DEADLINE".into(), d.into());
145        }
146        if let Some(s) = opts.scheduled {
147            validate_org_date(s)?;
148            props.insert("SCHEDULED".into(), s.into());
149        }
150        // A tag Org can hold goes on the heading, where Org's own tag search
151        // and agenda read it. One Org would not accept, `needs-review` say,
152        // stays in the property so it survives instead of becoming title text.
153        if let Some(tags) = opts.tags {
154            let mut property_tags: Vec<String> = Vec::new();
155            for tag in tags.split([',', ':']).map(str::trim) {
156                if tag.is_empty() {
157                    continue;
158                }
159                if tag.chars().all(crate::model::is_org_tag_char) {
160                    if !org_tags.iter().any(|seen| seen == tag) {
161                        org_tags.push(tag.to_string());
162                    }
163                } else if !property_tags.iter().any(|seen| seen == tag) {
164                    property_tags.push(tag.to_string());
165                }
166            }
167            if !property_tags.is_empty() {
168                props.insert(crate::model::TAGS_PROPERTY.into(), property_tags.join(","));
169            }
170        }
171        if let Some(p) = opts.parent {
172            crate::props::insert(&mut props, crate::props::PARENT, p.into());
173        }
174
175        doc.headings.push(IssueHeading {
176            id: id.clone(),
177            title: title.to_string(),
178            state: "TODO".into(),
179            priority,
180            properties: props,
181            org_tags,
182            statistics: None,
183            property_order: Vec::new(),
184            extra_drawers: Vec::new(),
185            body: match opts.body {
186                Some(b) if !b.trim().is_empty() => format!("{}\n", b.trim_end()),
187                _ => String::new(),
188            },
189            logbook: Vec::new(),
190            line_start: 0,
191            line_end: 0,
192        });
193        doc.write()?;
194
195        if opts.quiet {
196            Ok(format!("{id}\n"))
197        } else {
198            Ok(format!(
199                "{id}  TODO  [#{priority}]  {title}\nfile: {}\n",
200                path.display()
201            ))
202        }
203    })
204}
205
206pub(crate) fn validate_org_date(s: &str) -> Result<()> {
207    let inner = s
208        .trim_start_matches(['<', '['])
209        .trim_end_matches(['>', ']']);
210    let token = inner.split_whitespace().next().unwrap_or("");
211    NaiveDate::parse_from_str(token, "%Y-%m-%d").with_context(|| {
212        format!("expected org date like <YYYY-MM-DD> or [YYYY-MM-DD], got {s:?}")
213    })?;
214    Ok(())
215}
216
217/// Change state, priority, or blocker edges. Adding a blocker to an open issue
218/// moves it to BLOCKED; clearing the last blocker moves it back to TODO.
219///
220/// # Errors
221///
222/// Returns an error if `id` is not in the corpus, the state or priority is
223/// invalid, adding the blocker would cycle, or the file cannot be rewritten.
224pub fn update(
225    layout: &Layout,
226    id: &str,
227    new_state: Option<&str>,
228    new_priority: Option<char>,
229    block_add: Option<&str>,
230    block_clear: Option<&str>,
231) -> Result<UpdateOutcome> {
232    let identity = crate::config::identity(layout);
233    update_as(
234        layout,
235        id,
236        new_state,
237        new_priority,
238        block_add,
239        block_clear,
240        &identity,
241    )
242}
243
244/// Last-seen state or generation a write must still match.
245///
246/// This is the causal context on a PUT: the caller read the heading, then
247/// writes only if nothing else closed or rewrote it.
248#[derive(Debug, Default, Clone, Copy)]
249pub struct UpdatePred<'a> {
250    /// Refuse unless the heading is still this state.
251    pub if_state: Option<&'a str>,
252    /// Refuse unless the corpus generation is still this value.
253    pub if_gen: Option<u64>,
254}
255
256/// [`update`] with a last-seen predicate.
257///
258/// # Errors
259///
260/// Same as [`update`], plus [`Error::StaleWrite`] when the predicate fails.
261pub fn update_pred(
262    layout: &Layout,
263    id: &str,
264    new_state: Option<&str>,
265    new_priority: Option<char>,
266    block_add: Option<&str>,
267    block_clear: Option<&str>,
268    pred: UpdatePred<'_>,
269) -> Result<UpdateOutcome> {
270    let identity = crate::config::identity(layout);
271    update_as_pred(
272        layout,
273        id,
274        new_state,
275        new_priority,
276        block_add,
277        block_clear,
278        &identity,
279        pred,
280    )
281}
282
283/// [`update`] with an explicit identity instead of [`crate::config::identity`].
284///
285/// # Errors
286///
287/// Returns an error if `id` is not in the corpus, the state or priority is
288/// invalid, adding the blocker would cycle, or the file cannot be rewritten.
289pub fn update_as(
290    layout: &Layout,
291    id: &str,
292    new_state: Option<&str>,
293    new_priority: Option<char>,
294    block_add: Option<&str>,
295    block_clear: Option<&str>,
296    identity: &str,
297) -> Result<UpdateOutcome> {
298    update_as_pred(
299        layout,
300        id,
301        new_state,
302        new_priority,
303        block_add,
304        block_clear,
305        identity,
306        UpdatePred::default(),
307    )
308}
309
310/// [`update_as`] with a last-seen predicate.
311///
312/// # Errors
313///
314/// Same as [`update_as`], plus [`Error::StaleWrite`] when the predicate fails.
315#[allow(clippy::too_many_arguments)]
316pub fn update_as_pred(
317    layout: &Layout,
318    id: &str,
319    new_state: Option<&str>,
320    new_priority: Option<char>,
321    block_add: Option<&str>,
322    block_clear: Option<&str>,
323    identity: &str,
324    pred: UpdatePred<'_>,
325) -> Result<UpdateOutcome> {
326    let (_h0, path, project) =
327        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
328
329    let (transition, changed) = with_issues_lock(&path, || {
330        // Read the graph inside the lock. Built before it, the check answers
331        // for a corpus a peer may already have moved on from.
332        let graph = if block_add.is_some() {
333            Some(DependencyGraph::from_issues(&load_all(layout)?)?)
334        } else {
335            None
336        };
337        let mut doc = IssueDoc::parse_file(&project, &path)?;
338        let spec = doc.priority_spec();
339        let h = doc
340            .headings
341            .iter_mut()
342            .find(|x| x.id == id)
343            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
344
345        let original = h.state.clone();
346        let mut changed = Vec::new();
347
348        if pred.if_state.is_some() || pred.if_gen.is_some() {
349            let seen = crate::events::generation(layout);
350            if let Some(want) = pred.if_state {
351                if !TODO_KEYWORDS.contains(&want) {
352                    return Err(
353                        anyhow!("invalid --if-state {want:?}; allowed: {TODO_KEYWORDS:?}").into(),
354                    );
355                }
356                if h.state != want {
357                    return Err(Error::StaleWrite {
358                        id: id.to_string(),
359                        expected_state: Some(want.to_string()),
360                        actual_state: h.state.clone(),
361                        expected_gen: pred.if_gen,
362                        actual_gen: Some(seen),
363                    });
364                }
365            }
366            if let Some(want_gen) = pred.if_gen
367                && seen != want_gen
368            {
369                return Err(Error::StaleWrite {
370                    id: id.to_string(),
371                    expected_state: pred.if_state.map(str::to_string),
372                    actual_state: h.state.clone(),
373                    expected_gen: Some(want_gen),
374                    actual_gen: Some(seen),
375                });
376            }
377        }
378
379        if let Some(s) = new_state {
380            if !TODO_KEYWORDS.contains(&s) {
381                return Err(anyhow!("invalid state {s:?}; allowed: {TODO_KEYWORDS:?}").into());
382            }
383            if h.state != s {
384                if is_terminal(&h.state) && is_terminal(s) {
385                    record_sibling_terminal(h, s);
386                    changed.push(format!("sibling terminal {s} (held {})", h.state));
387                } else {
388                    let from = h.state.clone();
389                    h.record_state_change(s);
390                    changed.push(format!("state {from} -> {s}"));
391                    for note in settle_claim(h, &from, s, identity) {
392                        changed.push(note);
393                    }
394                }
395            }
396        }
397
398        if let Some(p) = new_priority {
399            if !spec.contains(p) {
400                return Err(anyhow!(
401                    "invalid priority {p:?}; file allows [#{}]..[#{}]",
402                    spec.highest,
403                    spec.lowest
404                )
405                .into());
406            }
407            if h.priority != p {
408                h.priority = p;
409                changed.push(format!("priority -> [#{p}]"));
410            }
411        }
412
413        if let Some(blk) = block_add {
414            let mut current = h.blocked_by();
415            if !current.iter().any(|x| x == blk) {
416                if let Some(graph) = &graph {
417                    graph.accepts_edge(blk, id)?;
418                }
419                current.push(blk.to_string());
420                crate::props::insert(
421                    &mut h.properties,
422                    crate::props::BLOCKED_BY,
423                    current.join(" "),
424                );
425                if h.state == "TODO" || h.state == "STARTED" {
426                    let from = h.state.clone();
427                    h.record_state_change("BLOCKED");
428                    changed.push(format!("state {from} -> BLOCKED (auto on block)"));
429                }
430                changed.push(format!("blocked_by += {blk}"));
431            }
432        }
433
434        if let Some(blk) = block_clear {
435            let mut current = h.blocked_by();
436            let before = current.len();
437            current.retain(|x| x != blk);
438            if current.len() < before {
439                if current.is_empty() {
440                    crate::props::remove(&mut h.properties, crate::props::BLOCKED_BY);
441                    if h.state == "BLOCKED" {
442                        let from = h.state.clone();
443                        h.record_state_change("TODO");
444                        changed.push("state BLOCKED -> TODO (auto on unblock)".to_string());
445                        for note in settle_claim(h, &from, "TODO", identity) {
446                            changed.push(note);
447                        }
448                    }
449                } else {
450                    crate::props::insert(
451                        &mut h.properties,
452                        crate::props::BLOCKED_BY,
453                        current.join(" "),
454                    );
455                }
456                changed.push(format!("blocked_by -= {blk}"));
457            }
458        }
459
460        if changed.is_empty() {
461            return Ok((None, Vec::new()));
462        }
463
464        let final_state = h.state.clone();
465        doc.write()?;
466        let transition = (original != final_state).then_some((original, final_state));
467        Ok((transition, changed))
468    })?;
469
470    if changed.is_empty() {
471        return Ok(UpdateOutcome {
472            report: format!("{id}: no change\n"),
473            hints: Vec::new(),
474        });
475    }
476
477    if let Some((from, to)) = &transition {
478        let _ = crate::events::emit_state_change(layout, &project, id, from, to);
479    }
480
481    let mut hints = Vec::new();
482    if matches!(
483        transition.as_ref().map(|(_, to)| to.as_str()),
484        Some("DONE") | Some("CANCELLED")
485    ) {
486        for (other_project, other) in load_all(layout)? {
487            if !other.blocked_by().iter().any(|b| b == id) {
488                continue;
489            }
490            if other.state == "DONE" || other.state == "CANCELLED" {
491                continue;
492            }
493            hints.push(format!(
494                "{} (in {}) lists this as a blocker; clear with `vissue update {} --unblock {}`",
495                other.id, other_project, other.id, id
496            ));
497        }
498    }
499    Ok(UpdateOutcome {
500        report: format!("{id}: {}\n", changed.join(", ")),
501        hints,
502    })
503}
504
505/// States that keep a claim: someone still holds the issue even when it is
506/// waiting on something else. Leaving for TODO, DONE, or CANCELLED gives it up.
507fn keeps_claim(state: &str) -> bool {
508    matches!(state, "STARTED" | "BLOCKED")
509}
510
511fn is_terminal(state: &str) -> bool {
512    matches!(state, "DONE" | "CANCELLED")
513}
514
515fn record_sibling_terminal(h: &mut IssueHeading, attempted: &str) {
516    crate::props::insert(
517        &mut h.properties,
518        crate::props::SIBLING_TERMINAL,
519        attempted.to_string(),
520    );
521}
522
523/// Pick one terminal after a sibling close. Clears `:SIBLING_TERMINAL:`.
524///
525/// # Errors
526///
527/// Returns an error if `id` is missing, `state` is not DONE or CANCELLED, or
528/// the file cannot be rewritten.
529pub fn resolve_terminal(layout: &Layout, id: &str, state: &str) -> Result<String> {
530    if !is_terminal(state) {
531        return Err(anyhow!("resolve state must be DONE or CANCELLED, got {state:?}").into());
532    }
533    let identity = crate::config::identity(layout);
534    let (_h0, path, project) =
535        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
536    let from = with_issues_lock(&path, || {
537        let mut doc = IssueDoc::parse_file(&project, &path)?;
538        let h = doc
539            .headings
540            .iter_mut()
541            .find(|x| x.id == id)
542            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
543        let from = h.state.clone();
544        if from != state {
545            h.record_state_change(state);
546            settle_claim(h, &from, state, &identity);
547        }
548        crate::props::remove(&mut h.properties, crate::props::SIBLING_TERMINAL);
549        doc.write()?;
550        Ok(from)
551    })?;
552    if from != state {
553        let _ = crate::events::emit_state_change(layout, &project, id, &from, state);
554    }
555    Ok(format!("resolved {id} -> {state}\n"))
556}
557
558/// Take or give up the claim as the state moves.
559///
560/// Entering STARTED unclaimed stamps the identity; leaving for a state that
561/// holds no claim releases it, and the logbook keeps who held it and since
562/// when.
563fn settle_claim(h: &mut IssueHeading, from: &str, to: &str, identity: &str) -> Vec<String> {
564    let mut notes = Vec::new();
565    if to == "STARTED" && h.claimed_by().is_none() {
566        h.set_claim(identity);
567        notes.push(format!("claimed by {identity}"));
568    } else if keeps_claim(from)
569        && !keeps_claim(to)
570        && let Some((who, _when)) = h.release_claim()
571    {
572        notes.push(format!("claim released ({who})"));
573    }
574    notes
575}
576
577/// Take an issue: move it to STARTED and stamp the claim.
578///
579/// A claim held by another identity is refused unless `force`, which records
580/// the takeover in the logbook rather than losing it.
581///
582/// # Errors
583///
584/// Returns an error if `id` is not in the corpus, the issue is DONE or
585/// CANCELLED, another identity holds it and `force` is false, or the file
586/// cannot be rewritten.
587pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
588    let identity = crate::config::identity(layout);
589    claim_as(layout, id, force, &identity)
590}
591
592/// [`claim`] with an explicit identity instead of [`crate::config::identity`].
593///
594/// # Errors
595///
596/// Returns an error if `id` is not in the corpus, the issue is DONE or
597/// CANCELLED, another identity holds it and `force` is false, or the file
598/// cannot be rewritten.
599pub fn claim_as(layout: &Layout, id: &str, force: bool, identity: &str) -> Result<String> {
600    let (_h0, path, project) =
601        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
602
603    let report = with_issues_lock(&path, || {
604        let mut doc = IssueDoc::parse_file(&project, &path)?;
605        let h = doc
606            .headings
607            .iter_mut()
608            .find(|x| x.id == id)
609            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
610
611        if h.state == "DONE" || h.state == "CANCELLED" {
612            return Err(Error::InvalidState {
613                id: id.to_string(),
614                state: h.state.clone(),
615            });
616        }
617        if let Some(holder) = h.claimed_by() {
618            if holder != identity && !force {
619                return Err(Error::ClaimConflict {
620                    id: id.to_string(),
621                    holder: holder.to_string(),
622                    claimed_at: h.claimed_at().map(str::to_string),
623                });
624            }
625            if holder != identity {
626                let previous = holder.to_string();
627                let from = h.state.clone();
628                h.release_claim();
629                h.set_claim(identity);
630                h.record_state_change("STARTED");
631                doc.write()?;
632                if from != "STARTED" {
633                    let _ =
634                        crate::events::emit_state_change(layout, &project, id, &from, "STARTED");
635                }
636                return Ok(format!("claimed {id} (taken over from {previous})\n"));
637            }
638        }
639
640        let was = h.state.clone();
641        h.record_state_change("STARTED");
642        if h.claimed_by().is_none() {
643            h.set_claim(identity);
644        }
645        doc.write()?;
646        if was != "STARTED" {
647            let _ = crate::events::emit_state_change(layout, &project, id, &was, "STARTED");
648        }
649        if was == "STARTED" {
650            Ok(format!("claimed {id} by {identity}\n"))
651        } else {
652            Ok(format!("claimed {id} by {identity} ({was} -> STARTED)\n"))
653        }
654    })?;
655    Ok(report)
656}
657
658/// What an update changed, plus advice about issues left dangling by it.
659#[derive(Debug, Clone)]
660pub struct UpdateOutcome {
661    /// One-line change summary, or `{id}: no change`.
662    pub report: String,
663    /// Issues that still list this one as a blocker after it closed.
664    pub hints: Vec<String>,
665}
666
667/// Add a dated note to the top of an issue's logbook. State, claim, and
668/// properties stay untouched, so an agent can record progress without owning
669/// the issue.
670///
671/// # Errors
672///
673/// Returns an error if `text` is empty, `id` is not in the corpus, or the
674/// file cannot be rewritten.
675pub fn note(layout: &Layout, id: &str, text: &str) -> Result<String> {
676    // One line in the drawer: fold internal whitespace, and swap double
677    // quotes for singles so the rendered `- Note: "..."` line re-parses.
678    let text = text
679        .split_whitespace()
680        .collect::<Vec<_>>()
681        .join(" ")
682        .replace('"', "'");
683    if text.is_empty() {
684        return Err(anyhow!("note text is empty").into());
685    }
686    let (_h0, path, project) =
687        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
688    with_issues_lock(&path, || {
689        let mut doc = IssueDoc::parse_file(&project, &path)?;
690        let h = doc
691            .headings
692            .iter_mut()
693            .find(|x| x.id == id)
694            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
695        // Newest first, matching state transitions and claim releases. A
696        // drawer written from both ends reads as sorted by neither.
697        h.logbook.insert(
698            0,
699            LogEntry {
700                timestamp: LogEntry::now(),
701                from_state: None,
702                to_state: None,
703                note: Some(text.clone()),
704                raw: None,
705            },
706        );
707        doc.write()?;
708        Ok(format!("{id}: noted\n"))
709    })
710}
711
712/// Append prose to an issue's body, stamped with the date and identity.
713///
714/// The logbook holds one line per event, so a written report does not fit in
715/// it: [`note`] folds its text to a single line by design. Work that has been
716/// done and needs recording belongs under the heading as prose, which is
717/// where a reader looks for what the issue is about.
718///
719/// The text is kept as given. Lines that would end the issue are indented on
720/// the way out, so markdown is safe to append.
721///
722/// # Errors
723///
724/// Returns an error if `text` is empty, `id` is not in the corpus, or the
725/// file cannot be rewritten.
726pub fn append_body(layout: &Layout, id: &str, text: &str) -> Result<String> {
727    append_body_as(layout, id, text, &crate::config::identity(layout))
728}
729
730/// [`append_body`] with the recorded identity passed in.
731///
732/// # Errors
733///
734/// Returns an error if `text` is empty, `id` is not in the corpus, or the
735/// file cannot be rewritten.
736pub fn append_body_as(layout: &Layout, id: &str, text: &str, identity: &str) -> Result<String> {
737    let text = text.trim_end();
738    if text.trim().is_empty() {
739        return Err(anyhow!("append text is empty").into());
740    }
741    let (_h0, path, project) =
742        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
743    with_issues_lock(&path, || {
744        let mut doc = IssueDoc::parse_file(&project, &path)?;
745        let h = doc
746            .headings
747            .iter_mut()
748            .find(|x| x.id == id)
749            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
750        let stamp = format!("{} {identity}", today_inactive_bracket());
751        if !h.body.trim().is_empty() {
752            h.body = h.body.trim_end().to_string();
753            h.body.push_str("\n\n");
754        } else {
755            h.body.clear();
756        }
757        h.body.push_str(&stamp);
758        h.body.push('\n');
759        h.body.push_str(text);
760        h.body.push('\n');
761        doc.write()?;
762        let lines = text.lines().count();
763        Ok(format!("{id}: appended {lines} line(s)\n"))
764    })
765}
766
767/// Fold an inbox-convention org file into tracked issues.
768///
769/// Each top-level `* TODO <title>` heading that does not already carry a
770/// `:VISSUE_ID:` line becomes an issue in `project` (body = the heading's
771/// text up to the next heading). The heading is then flipped to DONE and
772/// stamped with the assigned id in place, so a second run is a no-op:
773/// stamped headings are skipped, and folding is idempotent.
774///
775/// # Errors
776///
777/// Returns an error if the inbox cannot be read or written, `project` cannot
778/// be resolved, or creating a folded issue fails. Headings already stamped
779/// before a failure stay stamped.
780pub fn fold(layout: &Layout, inbox: &std::path::Path, project: &str) -> Result<String> {
781    let project = resolve_existing_project_case(layout, project)?;
782    let text = std::fs::read_to_string(inbox)
783        .with_context(|| format!("read inbox {}", inbox.display()))?;
784    let lines: Vec<String> = text.lines().map(str::to_string).collect();
785
786    struct Entry {
787        line: usize,
788        title: String,
789        body: String,
790        stamped: bool,
791    }
792    let mut entries: Vec<Entry> = Vec::new();
793    let mut i = 0;
794    let mut nest = crate::org::OrgScan::new();
795    while i < lines.len() {
796        if nest.observe(&lines[i]) {
797            i += 1;
798            continue;
799        }
800        if let Some(title) = lines[i].strip_prefix("* TODO ") {
801            let start = i + 1;
802            let mut end_nest = crate::org::OrgScan::new();
803            let end = {
804                let mut j = start;
805                while j < lines.len() {
806                    if !end_nest.observe(&lines[j]) && lines[j].starts_with("* ") {
807                        break;
808                    }
809                    j += 1;
810                }
811                j
812            };
813            let stamped = lines[start..end]
814                .iter()
815                .any(|l| l.trim_start().starts_with(":VISSUE_ID:"));
816            let body = lines[start..end].join("\n").trim().to_string();
817            entries.push(Entry {
818                line: i,
819                title: title.trim().to_string(),
820                body,
821                stamped,
822            });
823            i = end;
824        } else {
825            i += 1;
826        }
827    }
828
829    // Stamping inserts lines, so rewrite from the bottom up to keep the
830    // recorded line numbers valid.
831    let mut out = lines.clone();
832    let mut created: Vec<String> = Vec::new();
833    let mut failure = None;
834    for e in entries.iter().rev() {
835        if e.stamped {
836            continue;
837        }
838        let printed = create(
839            layout,
840            &project,
841            &e.title,
842            CreateOpts {
843                quiet: true,
844                body: if e.body.is_empty() {
845                    None
846                } else {
847                    Some(&e.body)
848                },
849                ..CreateOpts::default()
850            },
851        );
852        let id = match printed {
853            Ok(printed) => printed.trim().to_string(),
854            Err(e) => {
855                // Stop, but stamp what already exists below. Returning here
856                // with the inbox untouched would leave every issue created so
857                // far unstamped, and the next run would create them again.
858                failure = Some(e);
859                break;
860            }
861        };
862        out[e.line] = format!("* DONE {}", e.title);
863        out.insert(e.line + 1, format!(":VISSUE_ID: {id}"));
864        created.push(id);
865    }
866    created.reverse();
867
868    if !created.is_empty() {
869        let mut rendered = out.join("\n");
870        if text.ends_with('\n') {
871            rendered.push('\n');
872        }
873        std::fs::write(inbox, rendered)
874            .with_context(|| format!("write inbox {}", inbox.display()))?;
875    }
876    if let Some(error) = failure {
877        return Err(crate::error::Error::Other(
878            anyhow::Error::from(error).context(format!(
879                "folded {} before failing: {}",
880                created.len(),
881                created.join(" ")
882            )),
883        ));
884    }
885    if created.is_empty() {
886        return Ok("folded 0 (nothing unstamped)\n".into());
887    }
888    Ok(format!("folded {}: {}\n", created.len(), created.join(" ")))
889}
890
891/// Move one issue's heading to another project's file. The id is not
892/// regenerated, so cross-project blocker edges keep resolving.
893///
894/// # Errors
895///
896/// Returns an error if `id` is not in the corpus, `to_project` cannot be
897/// resolved, or either file cannot be locked or rewritten.
898pub fn refile(layout: &Layout, id: &str, to_project: &str) -> Result<String> {
899    refile_to(layout, id, layout, to_project)
900}
901
902/// Move one issue's heading onto a destination that may live on another
903/// tracker layout. A router resolves the destination project name before
904/// calling this, so a routed name lands on its own checkout instead of
905/// growing a shadow directory under the source root.
906///
907/// # Errors
908///
909/// Same as [`refile`].
910pub fn refile_to(
911    layout: &Layout,
912    id: &str,
913    dst_layout: &Layout,
914    to_project: &str,
915) -> Result<String> {
916    let to_project = resolve_existing_project_case(dst_layout, to_project)?;
917    let target_path = dst_layout.project_issues_path(&to_project);
918    let (_heading, src_path, src_project) =
919        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
920    if src_path == target_path {
921        return Ok(format!("{id} already in {to_project}; nothing to do\n"));
922    }
923    with_issues_locks(&[&src_path, &target_path], || {
924        let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
925        let heading = src_doc
926            .remove(id)
927            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
928
929        // Two files cannot be replaced in one atomic step, so choose which
930        // half-finished state a failure leaves behind. Writing the target
931        // first means a failed source write duplicates the id, which `check`
932        // reports and a person can resolve; the other order deletes the issue
933        // with nothing left naming it.
934        let mut tgt_doc = IssueDoc::parse_file(&to_project, &target_path)?;
935        tgt_doc.upsert(heading);
936        tgt_doc.write()?;
937        src_doc.write()?;
938        Ok(())
939    })?;
940    Ok(format!("{id}: {src_project} -> {to_project}\n"))
941}
942
943/// Optional fields on [`reject`].
944#[derive(Debug, Default, Clone, Copy)]
945pub struct RejectOpts<'a> {
946    /// Existing destination id. When set, that heading is the successor.
947    pub to: Option<&'a str>,
948    /// Project to create the destination in when [`Self::to`] is absent.
949    pub project: Option<&'a str>,
950    /// Title of a created destination. The source title is used when omitted.
951    pub title: Option<&'a str>,
952    /// Prose appended to the cancelled source.
953    pub reason: Option<&'a str>,
954    /// Tracker that holds the destination. `None` keeps the source's.
955    pub dst_layout: Option<&'a Layout>,
956    /// Ids treated as taken when minting a successor, so a twin file on
957    /// another layout cannot share a suffix with it.
958    pub dst_extra_ids: &'a [String],
959}
960
961/// Cancel `src` and point it at a successor in one graph edit.
962///
963/// Writes `src` to CANCELLED, sets `:PIVOTED_TO:` to the destination, and
964/// settles any claim on `src`. A created destination, or an existing one
965/// whose `:DISCOVERED_FROM:` is empty, records `src` as its origin. A
966/// non-empty `:DISCOVERED_FROM:` is left alone.
967///
968/// # Errors
969///
970/// Returns an error if `src` is not in the corpus, `--to` names no heading,
971/// neither a destination nor a create project is given, or a file cannot be
972/// rewritten.
973pub fn reject(layout: &Layout, src: &str, opts: RejectOpts<'_>) -> Result<String> {
974    let identity = crate::config::identity(layout);
975    let (src0, src_path, src_project) =
976        find_by_id(layout, src)?.ok_or_else(|| Error::IssueNotFound {
977            id: src.to_string(),
978        })?;
979
980    let dst_layout = opts.dst_layout.unwrap_or(layout);
981    let existing_dst = if let Some(to) = opts.to {
982        if to == src {
983            return Err(anyhow!("reject destination cannot be the source {src}").into());
984        }
985        Some(
986            find_by_id(dst_layout, to)?
987                .ok_or_else(|| Error::IssueNotFound { id: to.to_string() })?,
988        )
989    } else {
990        None
991    };
992
993    let creating = existing_dst.is_none();
994    if creating && opts.project.is_none() {
995        return Err(anyhow!("reject needs --to DST or --project to create a successor").into());
996    }
997
998    let dst_project = if let Some((_, _, ref project)) = existing_dst {
999        project.clone()
1000    } else {
1001        resolve_existing_project_case(dst_layout, opts.project.unwrap_or(&src_project))?
1002    };
1003    let dst_path = dst_layout.project_issues_path(&dst_project);
1004    let dst_title = opts.title.unwrap_or(src0.title.as_str());
1005    let cfg = VissueConfig::load(layout)?;
1006
1007    let (dst_id, old_state, new_state) = with_issues_locks(&[&src_path, &dst_path], || {
1008        if src_path == dst_path {
1009            let mut doc = IssueDoc::parse_file(&src_project, &src_path)?;
1010            let dst_id = if creating {
1011                push_successor(
1012                    &mut doc,
1013                    &dst_project,
1014                    dst_title,
1015                    src,
1016                    &cfg,
1017                    opts.dst_extra_ids,
1018                )?
1019            } else {
1020                let to = reject_to(opts)?;
1021                set_discovered_from_if_empty(&mut doc, to, src)?;
1022                to.to_string()
1023            };
1024            let (old_state, new_state) =
1025                cancel_and_pivot(&mut doc, src, &dst_id, opts.reason, &identity)?;
1026            doc.write()?;
1027            Ok((dst_id, old_state, new_state))
1028        } else {
1029            let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
1030            let mut dst_doc = IssueDoc::parse_file(&dst_project, &dst_path)?;
1031            let dst_id = if creating {
1032                push_successor(
1033                    &mut dst_doc,
1034                    &dst_project,
1035                    dst_title,
1036                    src,
1037                    &cfg,
1038                    opts.dst_extra_ids,
1039                )?
1040            } else {
1041                let to = reject_to(opts)?;
1042                set_discovered_from_if_empty(&mut dst_doc, to, src)?;
1043                to.to_string()
1044            };
1045            let (old_state, new_state) =
1046                cancel_and_pivot(&mut src_doc, src, &dst_id, opts.reason, &identity)?;
1047            dst_doc.write()?;
1048            src_doc.write()?;
1049            Ok((dst_id, old_state, new_state))
1050        }
1051    })?;
1052
1053    if old_state != new_state {
1054        let _ = crate::events::emit_state_change(layout, &src_project, src, &old_state, &new_state);
1055    }
1056    Ok(format!("rejected {src} -> {dst_id}\n"))
1057}
1058
1059fn reject_to(opts: RejectOpts<'_>) -> Result<&str> {
1060    opts.to
1061        .ok_or_else(|| anyhow!("reject destination missing after --to was required").into())
1062}
1063
1064fn push_successor(
1065    doc: &mut IssueDoc,
1066    project: &str,
1067    title: &str,
1068    src: &str,
1069    cfg: &VissueConfig,
1070    extra_ids: &[String],
1071) -> Result<String> {
1072    let mut taken = doc.known_ids();
1073    taken.extend(extra_ids.iter().cloned());
1074    let id = generate_id(project, &taken, cfg.issues.id_length)?;
1075    let mut props = BTreeMap::new();
1076    props.insert("ID".into(), id.clone());
1077    props.insert("CREATED".into(), today_inactive_bracket());
1078    crate::props::insert(&mut props, crate::props::DISCOVERED_FROM, src.to_string());
1079    doc.headings.push(IssueHeading {
1080        id: id.clone(),
1081        title: title.to_string(),
1082        state: "TODO".into(),
1083        priority: doc.default_create_priority(cfg.issues.default_priority),
1084        properties: props,
1085        org_tags: Vec::new(),
1086        statistics: None,
1087        property_order: Vec::new(),
1088        extra_drawers: Vec::new(),
1089        body: String::new(),
1090        logbook: Vec::new(),
1091        line_start: 0,
1092        line_end: 0,
1093    });
1094    Ok(id)
1095}
1096
1097fn set_discovered_from_if_empty(doc: &mut IssueDoc, id: &str, src: &str) -> Result<()> {
1098    let h = doc
1099        .headings
1100        .iter_mut()
1101        .find(|h| h.id == id)
1102        .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
1103    let empty = crate::props::get(&h.properties, crate::props::DISCOVERED_FROM)
1104        .is_none_or(|s| s.trim().is_empty());
1105    if empty {
1106        crate::props::insert(
1107            &mut h.properties,
1108            crate::props::DISCOVERED_FROM,
1109            src.to_string(),
1110        );
1111    }
1112    Ok(())
1113}
1114
1115fn cancel_and_pivot(
1116    doc: &mut IssueDoc,
1117    src: &str,
1118    dst: &str,
1119    reason: Option<&str>,
1120    identity: &str,
1121) -> Result<(String, String)> {
1122    let h = doc
1123        .headings
1124        .iter_mut()
1125        .find(|h| h.id == src)
1126        .ok_or_else(|| Error::IssueNotFound {
1127            id: src.to_string(),
1128        })?;
1129    let old_state = h.state.clone();
1130    if is_terminal(&old_state) && old_state != "CANCELLED" {
1131        record_sibling_terminal(h, "CANCELLED");
1132    } else if old_state != "CANCELLED" {
1133        h.record_state_change("CANCELLED");
1134        settle_claim(h, &old_state, "CANCELLED", identity);
1135    }
1136    crate::props::insert(&mut h.properties, crate::props::PIVOTED_TO, dst.to_string());
1137    if let Some(reason) = reason {
1138        append_reason(h, reason, identity);
1139    }
1140    Ok((old_state, h.state.clone()))
1141}
1142
1143fn append_reason(h: &mut IssueHeading, text: &str, identity: &str) {
1144    let text = text.trim_end();
1145    if text.trim().is_empty() {
1146        return;
1147    }
1148    let stamp = format!("{} {identity}", today_inactive_bracket());
1149    if !h.body.trim().is_empty() {
1150        h.body = h.body.trim_end().to_string();
1151        h.body.push_str("\n\n");
1152    } else {
1153        h.body.clear();
1154    }
1155    h.body.push_str(&stamp);
1156    h.body.push('\n');
1157    h.body.push_str(text);
1158    h.body.push('\n');
1159}
1160
1161/// First `[[id:XXX]]` (optionally `[[id:XXX][label]]`) whose id is in `known`.
1162fn first_existing_id_link(body: &str, known: &std::collections::HashSet<String>) -> Option<String> {
1163    let mut rest = body;
1164    while let Some(start) = rest.find("[[") {
1165        let after_start = &rest[start + 2..];
1166        let end = after_start.find("]]")?;
1167        let raw = &after_start[..end];
1168        let target = raw.split_once("][").map_or(raw, |(target, _)| target);
1169        let target = target.trim();
1170        if let Some(id) = target.strip_prefix("id:") {
1171            let id = id.trim();
1172            if known.contains(id) {
1173                return Some(id.to_string());
1174            }
1175        }
1176        rest = &after_start[end + 2..];
1177    }
1178    None
1179}
1180
1181/// Rewrite project files onto the Org / ELPA / vissue property split.
1182///
1183/// Folds typos (`BLOCKEDBY`, drawer `TAGS`) and a bare `:BLOCKER:` id
1184/// list into `:BLOCKED_BY:`. A real org-edna condition stays. Puts legal
1185/// types on the heading and inserts a missing `#+CATEGORY:`. Does not
1186/// mint `:BLOCKER: ids(...)`.
1187///
1188/// # Errors
1189///
1190/// Returns an error if a project file cannot be read or rewritten.
1191pub fn normalize(layout: &Layout, project: Option<&str>, dry_run: bool) -> Result<String> {
1192    let projects = match project {
1193        Some(name) => vec![resolve_existing_project_case(layout, name)?],
1194        None => crate::store::list_projects(layout)?,
1195    };
1196    let mut out = String::new();
1197    let mut files = 0usize;
1198    let mut headings = 0usize;
1199    let mut changed = 0usize;
1200    for project in projects {
1201        let path = layout.project_issues_path(&project);
1202        if !path.exists() {
1203            continue;
1204        }
1205        files += 1;
1206        let before =
1207            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1208        let report = with_issues_lock(&path, || {
1209            let mut doc = IssueDoc::parse_file(&project, &path)?;
1210            let mut moved = 0usize;
1211            for h in &mut doc.headings {
1212                moved += crate::props::settle(&mut h.org_tags, &mut h.properties);
1213            }
1214            let after = doc.render_string();
1215            if after != before {
1216                if !dry_run {
1217                    doc.write()?;
1218                }
1219                Ok(Some((moved, after.len())))
1220            } else {
1221                Ok(None)
1222            }
1223        })?;
1224        headings += IssueDoc::parse(&project, path.clone(), &before)
1225            .map(|d| d.headings.len())
1226            .unwrap_or(0);
1227        if let Some((moved, _)) = report {
1228            changed += 1;
1229            let verb = if dry_run { "would rewrite" } else { "rewrote" };
1230            writeln!(out, "{verb} {project} ({moved} key move(s))")?;
1231        }
1232    }
1233    let mode = if dry_run { "dry-run" } else { "wrote" };
1234    writeln!(
1235        out,
1236        "normalize {mode}: {changed}/{files} file(s) changed, {headings} heading(s) scanned"
1237    )?;
1238    Ok(out)
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243    use super::*;
1244    use crate::config::DEFAULT_PREFIX;
1245    use std::fs;
1246    use std::path::Path;
1247
1248    fn fresh_layout(dir: &Path) -> Layout {
1249        fs::create_dir_all(dir.join(DEFAULT_PREFIX)).unwrap();
1250        Layout::new(dir, DEFAULT_PREFIX)
1251    }
1252
1253    fn issue_at(layout: &Layout, project: &str, id: &str) -> IssueHeading {
1254        IssueDoc::parse_file(project, &layout.project_issues_path(project))
1255            .unwrap()
1256            .headings
1257            .into_iter()
1258            .find(|h| h.id == id)
1259            .expect("issue not found")
1260    }
1261
1262    fn only_id(layout: &Layout, project: &str) -> String {
1263        IssueDoc::parse_file(project, &layout.project_issues_path(project))
1264            .unwrap()
1265            .headings[0]
1266            .id
1267            .clone()
1268    }
1269
1270    #[test]
1271    fn create_rejects_a_parent_that_does_not_exist() {
1272        let dir = tempfile::tempdir().unwrap();
1273        let layout = fresh_layout(dir.path());
1274        let err = create(
1275            &layout,
1276            "sample",
1277            "child without parent",
1278            CreateOpts {
1279                parent: Some("sample-zzz9"),
1280                ..Default::default()
1281            },
1282        )
1283        .unwrap_err();
1284        assert!(err.to_string().contains("does not refer to any known id"));
1285    }
1286
1287    #[test]
1288    fn create_accepts_a_parent_defined_in_a_design_document() {
1289        let dir = tempfile::tempdir().unwrap();
1290        let layout = fresh_layout(dir.path());
1291        let parent_id = "sample-spec-20260615";
1292        let project_dir = layout.projects_dir().join("sample");
1293        fs::create_dir_all(&project_dir).unwrap();
1294        fs::write(
1295            project_dir.join("design.org"),
1296            format!("#+TITLE: sample design\n\n* Design\n:PROPERTIES:\n:ID:         {parent_id}\n:END:\n"),
1297        )
1298        .unwrap();
1299
1300        create(
1301            &layout,
1302            "sample",
1303            "child under design",
1304            CreateOpts {
1305                parent: Some(parent_id),
1306                ..Default::default()
1307            },
1308        )
1309        .unwrap();
1310        assert!(only_id(&layout, "sample").starts_with("sample-"));
1311        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1312        assert_eq!(doc.headings[0].parent(), Some(parent_id));
1313    }
1314
1315    #[test]
1316    fn a_state_update_writes_a_logbook_entry() {
1317        let dir = tempfile::tempdir().unwrap();
1318        let layout = fresh_layout(dir.path());
1319        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1320        let id = only_id(&layout, "sample");
1321        update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1322        let h = issue_at(&layout, "sample", &id);
1323        assert_eq!(h.state, "STARTED");
1324        assert_eq!(h.logbook[0].from_state.as_deref(), Some("TODO"));
1325        assert_eq!(h.logbook[0].to_state.as_deref(), Some("STARTED"));
1326    }
1327
1328    #[test]
1329    fn blocking_and_unblocking_drive_the_state() {
1330        let dir = tempfile::tempdir().unwrap();
1331        let layout = fresh_layout(dir.path());
1332        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1333        create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1334        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1335        let first = doc.headings[0].id.clone();
1336        let blocker = doc.headings[1].id.clone();
1337
1338        update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1339        let h = issue_at(&layout, "sample", &first);
1340        assert_eq!(h.state, "BLOCKED");
1341        assert!(h.blocked_by().contains(&blocker));
1342
1343        update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
1344        let h = issue_at(&layout, "sample", &first);
1345        assert_eq!(h.state, "TODO");
1346        assert!(h.blocked_by().is_empty());
1347    }
1348
1349    #[test]
1350    fn auto_unblock_to_todo_releases_the_claim() {
1351        let dir = tempfile::tempdir().unwrap();
1352        let layout = fresh_layout(dir.path());
1353        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1354        create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1355        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1356        let first = doc.headings[0].id.clone();
1357        let blocker = doc.headings[1].id.clone();
1358
1359        crate::agent::claim(&layout, &first, false).unwrap();
1360        update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1361        assert!(issue_at(&layout, "sample", &first).claimed_by().is_some());
1362
1363        update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
1364        let h = issue_at(&layout, "sample", &first);
1365        assert_eq!(h.state, "TODO");
1366        assert!(h.claimed_by().is_none(), "claim stuck on TODO: {h:?}");
1367    }
1368
1369    #[test]
1370    fn blocker_cycle_is_rejected_before_writing() {
1371        let dir = tempfile::tempdir().unwrap();
1372        let layout = fresh_layout(dir.path());
1373        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1374        create(&layout, "sample", "second", CreateOpts::default()).unwrap();
1375        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1376        let first = doc.headings[0].id.clone();
1377        let second = doc.headings[1].id.clone();
1378
1379        update(&layout, &first, None, None, Some(&second), None).unwrap();
1380        let err = update(&layout, &second, None, None, Some(&first), None).unwrap_err();
1381        assert!(err.to_string().contains("blocker cycle"), "{err}");
1382        assert!(issue_at(&layout, "sample", &second).blocked_by().is_empty());
1383    }
1384
1385    #[test]
1386    fn closing_a_blocker_reports_the_issues_still_pointing_at_it() {
1387        let dir = tempfile::tempdir().unwrap();
1388        let layout = fresh_layout(dir.path());
1389        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1390        create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
1391        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1392        let first = doc.headings[0].id.clone();
1393        let blocker = doc.headings[1].id.clone();
1394        update(&layout, &first, None, None, Some(&blocker), None).unwrap();
1395
1396        let outcome = update(&layout, &blocker, Some("DONE"), None, None, None).unwrap();
1397        assert_eq!(outcome.hints.len(), 1, "{:?}", outcome.hints);
1398        assert!(outcome.hints[0].contains(&first), "{:?}", outcome.hints);
1399    }
1400
1401    #[test]
1402    fn refile_moves_the_heading_between_projects() {
1403        let dir = tempfile::tempdir().unwrap();
1404        let layout = fresh_layout(dir.path());
1405        create(&layout, "source", "the issue", CreateOpts::default()).unwrap();
1406        let id = only_id(&layout, "source");
1407        refile(&layout, &id, "target").unwrap();
1408
1409        let src = IssueDoc::parse_file("source", &layout.project_issues_path("source")).unwrap();
1410        let tgt = IssueDoc::parse_file("target", &layout.project_issues_path("target")).unwrap();
1411        assert!(src.headings.is_empty());
1412        assert_eq!(tgt.headings[0].id, id);
1413    }
1414
1415    #[test]
1416    fn deadlines_must_parse_as_org_dates() {
1417        let dir = tempfile::tempdir().unwrap();
1418        let layout = fresh_layout(dir.path());
1419        let err = create(
1420            &layout,
1421            "sample",
1422            "bad date",
1423            CreateOpts {
1424                deadline: Some("not-a-date"),
1425                ..Default::default()
1426            },
1427        )
1428        .unwrap_err();
1429        assert!(err.to_string().contains("expected org date"));
1430
1431        for (i, d) in ["<2026-05-15 Fri>", "[2026-05-15]"].iter().enumerate() {
1432            create(
1433                &layout,
1434                "sample",
1435                &format!("issue {i}"),
1436                CreateOpts {
1437                    deadline: Some(d),
1438                    ..Default::default()
1439                },
1440            )
1441            .unwrap();
1442        }
1443        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1444        assert_eq!(doc.headings.len(), 2);
1445        assert!(doc.headings.iter().all(|h| h.deadline().is_some()));
1446    }
1447
1448    #[test]
1449    fn org_safe_tags_go_on_the_heading_and_the_rest_stay_in_the_property() {
1450        let dir = tempfile::tempdir().unwrap();
1451        let layout = fresh_layout(dir.path());
1452        create(
1453            &layout,
1454            "sample",
1455            "tagged",
1456            CreateOpts {
1457                tags: Some("rust: perf ,, scaling, needs-review"),
1458                ..Default::default()
1459            },
1460        )
1461        .unwrap();
1462        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1463        let h = &doc.headings[0];
1464        assert_eq!(h.org_tags, vec!["rust", "perf", "scaling"]);
1465        assert_eq!(
1466            h.properties
1467                .get(crate::model::TAGS_PROPERTY)
1468                .map(|s| s.as_str()),
1469            Some("needs-review"),
1470            "a tag Org cannot hold keeps the property"
1471        );
1472        // Whichever half a tag landed in, a query sees all of them.
1473        assert_eq!(
1474            h.tags(),
1475            vec!["needs-review", "rust", "perf", "scaling"],
1476            "{h:?}"
1477        );
1478    }
1479
1480    #[test]
1481    fn create_puts_a_legal_type_on_the_heading() {
1482        let dir = tempfile::tempdir().unwrap();
1483        let layout = fresh_layout(dir.path());
1484        create(
1485            &layout,
1486            "sample",
1487            "a bug",
1488            CreateOpts {
1489                issue_type: Some("bug"),
1490                ..Default::default()
1491            },
1492        )
1493        .unwrap();
1494        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1495        let h = &doc.headings[0];
1496        assert_eq!(
1497            crate::props::get(&h.properties, crate::props::TYPE),
1498            Some("bug")
1499        );
1500        assert_eq!(h.org_tags, vec!["bug"]);
1501        let written = std::fs::read_to_string(layout.project_issues_path("sample")).unwrap();
1502        assert!(written.contains("#+CATEGORY: sample"), "{written}");
1503        assert!(written.contains(":bug:"), "{written}");
1504    }
1505
1506    #[test]
1507    fn resolve_project_needs_a_name_from_somewhere() {
1508        let dir = tempfile::tempdir().unwrap();
1509        let layout = fresh_layout(dir.path());
1510        assert_eq!(
1511            resolve_project(&layout, Some("fromcli")).unwrap(),
1512            "fromcli"
1513        );
1514        assert!(
1515            resolve_project(&layout, Some(""))
1516                .unwrap_err()
1517                .to_string()
1518                .contains("empty")
1519        );
1520    }
1521
1522    /// Parallel creates must not lose headings or fail the temporary rename.
1523    #[test]
1524    fn concurrent_creates_preserve_every_heading() {
1525        use std::sync::Arc;
1526        use std::thread;
1527
1528        let dir = tempfile::tempdir().unwrap();
1529        let layout = Arc::new(fresh_layout(dir.path()));
1530        let n = 24usize;
1531        let handles: Vec<_> = (0..n)
1532            .map(|i| {
1533                let layout = Arc::clone(&layout);
1534                thread::spawn(move || {
1535                    create(
1536                        &layout,
1537                        "sample",
1538                        &format!("parallel title {i}"),
1539                        CreateOpts {
1540                            quiet: true,
1541                            ..Default::default()
1542                        },
1543                    )
1544                })
1545            })
1546            .collect();
1547        let mut ids: Vec<String> = handles
1548            .into_iter()
1549            .map(|h| {
1550                h.join()
1551                    .expect("thread panicked")
1552                    .expect("create failed")
1553                    .trim()
1554                    .to_string()
1555            })
1556            .collect();
1557        ids.sort();
1558        ids.dedup();
1559        assert_eq!(ids.len(), n, "expected {n} unique ids, got {ids:?}");
1560
1561        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1562        let mut on_disk: Vec<String> = doc.headings.iter().map(|h| h.id.clone()).collect();
1563        on_disk.sort();
1564        assert_eq!(on_disk, ids);
1565    }
1566
1567    #[test]
1568    fn note_appends_to_the_logbook_and_leaves_state_alone() {
1569        let dir = tempfile::tempdir().unwrap();
1570        let layout = fresh_layout(dir.path());
1571        create(&layout, "sample", "carries a note", CreateOpts::default()).unwrap();
1572        let id = only_id(&layout, "sample");
1573
1574        let out = note(&layout, &id, "first pass done,\n  \"quoted\" bit next").unwrap();
1575        assert_eq!(out, format!("{id}: noted\n"));
1576
1577        let h = issue_at(&layout, "sample", &id);
1578        assert_eq!(h.state, "TODO");
1579        assert!(h.claimed_by().is_none());
1580        let notes: Vec<&str> = h.logbook.iter().filter_map(|e| e.note.as_deref()).collect();
1581        // Whitespace collapses to single spaces; double quotes become single.
1582        assert_eq!(notes, vec!["first pass done, 'quoted' bit next"]);
1583    }
1584
1585    #[test]
1586    fn the_logbook_reads_newest_first_however_an_entry_arrived() {
1587        let dir = tempfile::tempdir().unwrap();
1588        let layout = fresh_layout(dir.path());
1589        create(&layout, "sample", "ordered", CreateOpts::default()).unwrap();
1590        let id = only_id(&layout, "sample");
1591
1592        note(&layout, &id, "first note").unwrap();
1593        update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1594        note(&layout, &id, "second note").unwrap();
1595
1596        let h = issue_at(&layout, "sample", &id);
1597        let summary: Vec<String> = h
1598            .logbook
1599            .iter()
1600            .map(|e| match (&e.note, &e.to_state) {
1601                (Some(note), _) => note.clone(),
1602                (_, Some(to)) => format!("state:{to}"),
1603                _ => "?".into(),
1604            })
1605            .collect();
1606        assert_eq!(
1607            summary,
1608            vec!["second note", "state:STARTED", "first note"],
1609            "{h:?}"
1610        );
1611    }
1612
1613    #[test]
1614    fn note_rejects_empty_text_and_unknown_ids() {
1615        let dir = tempfile::tempdir().unwrap();
1616        let layout = fresh_layout(dir.path());
1617        create(&layout, "sample", "target", CreateOpts::default()).unwrap();
1618        let id = only_id(&layout, "sample");
1619        assert!(note(&layout, &id, "   ").is_err());
1620        assert!(note(&layout, "sample-zzz9", "text").is_err());
1621    }
1622
1623    #[test]
1624    fn fold_creates_issues_and_stamps_the_inbox_idempotently() {
1625        let dir = tempfile::tempdir().unwrap();
1626        let layout = fresh_layout(dir.path());
1627        create(&layout, "sample", "seed", CreateOpts::default()).unwrap();
1628
1629        let inbox = dir.path().join("inbox.org");
1630        fs::write(
1631            &inbox,
1632            "#+TITLE: inbox\n\n\
1633             * TODO first discovered thing\nSome body line.\nAnother line.\n\
1634             * DONE already handled elsewhere\n\
1635             * TODO second discovered thing\n",
1636        )
1637        .unwrap();
1638
1639        let out = fold(&layout, &inbox, "sample").unwrap();
1640        assert!(out.starts_with("folded 2: "), "got: {out}");
1641
1642        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1643        let titles: Vec<&str> = doc.headings.iter().map(|h| h.title.as_str()).collect();
1644        assert!(titles.contains(&"first discovered thing"));
1645        assert!(titles.contains(&"second discovered thing"));
1646        let folded = doc
1647            .headings
1648            .iter()
1649            .find(|h| h.title == "first discovered thing")
1650            .unwrap();
1651        assert!(folded.body.contains("Some body line."));
1652
1653        // Headings flipped to DONE and stamped with the assigned id.
1654        let stamped = fs::read_to_string(&inbox).unwrap();
1655        assert_eq!(stamped.matches("* DONE ").count(), 3);
1656        assert_eq!(stamped.matches(":VISSUE_ID: sample-").count(), 2);
1657        assert!(!stamped.contains("* TODO "));
1658
1659        // Second fold finds nothing unstamped and creates nothing.
1660        let again = fold(&layout, &inbox, "sample").unwrap();
1661        assert_eq!(again, "folded 0 (nothing unstamped)\n");
1662        let doc2 = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1663        assert_eq!(doc2.headings.len(), doc.headings.len());
1664    }
1665
1666    #[test]
1667    fn refile_to_moves_across_two_layouts_and_leaves_no_shadow() {
1668        let src_dir = tempfile::tempdir().unwrap();
1669        let dst_dir = tempfile::tempdir().unwrap();
1670        let src_layout = fresh_layout(src_dir.path());
1671        let dst_layout = fresh_layout(dst_dir.path());
1672        create(&src_layout, "misc", "wrong board", CreateOpts::default()).unwrap();
1673        let id = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc"))
1674            .unwrap()
1675            .headings[0]
1676            .id
1677            .clone();
1678
1679        let out = refile_to(&src_layout, &id, &dst_layout, "surf").unwrap();
1680        assert!(out.contains("misc -> surf"), "{out}");
1681
1682        // The heading is on the destination tracker, and the source root has
1683        // no `surf` directory standing in for it.
1684        let moved = IssueDoc::parse_file("surf", &dst_layout.project_issues_path("surf")).unwrap();
1685        assert_eq!(moved.headings.len(), 1);
1686        assert_eq!(moved.headings[0].id, id);
1687        assert!(!src_layout.project_issues_path("surf").exists());
1688        let left = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc")).unwrap();
1689        assert!(left.headings.is_empty());
1690    }
1691
1692    #[test]
1693    fn reject_creates_the_successor_on_the_destination_layout() {
1694        let src_dir = tempfile::tempdir().unwrap();
1695        let dst_dir = tempfile::tempdir().unwrap();
1696        let src_layout = fresh_layout(src_dir.path());
1697        let dst_layout = fresh_layout(dst_dir.path());
1698        create(&src_layout, "misc", "old approach", CreateOpts::default()).unwrap();
1699        let src = IssueDoc::parse_file("misc", &src_layout.project_issues_path("misc"))
1700            .unwrap()
1701            .headings[0]
1702            .id
1703            .clone();
1704
1705        // A twin id the destination file does not hold yet: the successor
1706        // must not mint it, because the routed board already uses it.
1707        let taken = vec!["surf-aaaa".to_string()];
1708        let out = reject(
1709            &src_layout,
1710            &src,
1711            RejectOpts {
1712                project: Some("surf"),
1713                title: Some("new approach"),
1714                dst_layout: Some(&dst_layout),
1715                dst_extra_ids: &taken,
1716                ..Default::default()
1717            },
1718        )
1719        .unwrap();
1720
1721        assert!(!src_layout.project_issues_path("surf").exists());
1722        let made = IssueDoc::parse_file("surf", &dst_layout.project_issues_path("surf")).unwrap();
1723        assert_eq!(made.headings.len(), 1);
1724        assert_ne!(made.headings[0].id, "surf-aaaa");
1725        assert!(out.contains(&made.headings[0].id), "{out}");
1726        assert_eq!(issue_at(&src_layout, "misc", &src).state, "CANCELLED");
1727    }
1728
1729    #[test]
1730    fn reject_to_an_existing_issue_cancels_and_wires_the_pair() {
1731        let dir = tempfile::tempdir().unwrap();
1732        let layout = fresh_layout(dir.path());
1733        create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1734        create(&layout, "sample", "new approach", CreateOpts::default()).unwrap();
1735        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1736        let src = doc.headings[0].id.clone();
1737        let dst = doc.headings[1].id.clone();
1738
1739        let out = reject(
1740            &layout,
1741            &src,
1742            RejectOpts {
1743                to: Some(&dst),
1744                ..Default::default()
1745            },
1746        )
1747        .unwrap();
1748        assert!(out.contains(&src) && out.contains(&dst), "{out}");
1749
1750        let src_h = issue_at(&layout, "sample", &src);
1751        assert_eq!(src_h.state, "CANCELLED");
1752        assert_eq!(
1753            src_h.properties.get("PIVOTED_TO").map(String::as_str),
1754            Some(dst.as_str())
1755        );
1756        let dst_h = issue_at(&layout, "sample", &dst);
1757        assert_eq!(
1758            dst_h.properties.get("DISCOVERED_FROM").map(String::as_str),
1759            Some(src.as_str())
1760        );
1761    }
1762
1763    #[test]
1764    fn reject_creates_the_destination_in_another_project() {
1765        let dir = tempfile::tempdir().unwrap();
1766        let layout = fresh_layout(dir.path());
1767        create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1768        let src = only_id(&layout, "sample");
1769
1770        let out = reject(
1771            &layout,
1772            &src,
1773            RejectOpts {
1774                project: Some("other"),
1775                title: Some("new approach"),
1776                ..Default::default()
1777            },
1778        )
1779        .unwrap();
1780
1781        let dst_doc = IssueDoc::parse_file("other", &layout.project_issues_path("other")).unwrap();
1782        assert_eq!(dst_doc.headings.len(), 1);
1783        let dst = &dst_doc.headings[0];
1784        assert_eq!(dst.title, "new approach");
1785        assert_eq!(
1786            dst.properties.get("DISCOVERED_FROM").map(String::as_str),
1787            Some(src.as_str())
1788        );
1789        assert!(out.contains(&src) && out.contains(&dst.id), "{out}");
1790
1791        let src_h = issue_at(&layout, "sample", &src);
1792        assert_eq!(src_h.state, "CANCELLED");
1793        assert_eq!(
1794            src_h.properties.get("PIVOTED_TO").map(String::as_str),
1795            Some(dst.id.as_str())
1796        );
1797    }
1798
1799    #[test]
1800    fn reject_refuses_an_unknown_source_or_destination() {
1801        let dir = tempfile::tempdir().unwrap();
1802        let layout = fresh_layout(dir.path());
1803        create(&layout, "sample", "only", CreateOpts::default()).unwrap();
1804        let src = only_id(&layout, "sample");
1805
1806        let missing_src = reject(
1807            &layout,
1808            "sample-zzzz",
1809            RejectOpts {
1810                to: Some(&src),
1811                ..Default::default()
1812            },
1813        )
1814        .unwrap_err();
1815        assert!(
1816            matches!(missing_src, Error::IssueNotFound { .. }),
1817            "{missing_src}"
1818        );
1819
1820        let missing_dst = reject(
1821            &layout,
1822            &src,
1823            RejectOpts {
1824                to: Some("sample-zzzz"),
1825                ..Default::default()
1826            },
1827        )
1828        .unwrap_err();
1829        assert!(
1830            matches!(missing_dst, Error::IssueNotFound { .. }),
1831            "{missing_dst}"
1832        );
1833    }
1834
1835    #[test]
1836    fn reject_does_not_overwrite_a_nonempty_discovered_from() {
1837        let dir = tempfile::tempdir().unwrap();
1838        let layout = fresh_layout(dir.path());
1839        create(&layout, "sample", "origin", CreateOpts::default()).unwrap();
1840        create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1841        create(&layout, "sample", "already sourced", CreateOpts::default()).unwrap();
1842        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1843        let origin = doc.headings[0].id.clone();
1844        let src = doc.headings[1].id.clone();
1845        let dst = doc.headings[2].id.clone();
1846
1847        let path = layout.project_issues_path("sample");
1848        let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
1849        doc.headings
1850            .iter_mut()
1851            .find(|h| h.id == dst)
1852            .unwrap()
1853            .properties
1854            .insert("DISCOVERED_FROM".into(), origin.clone());
1855        doc.write().unwrap();
1856
1857        reject(
1858            &layout,
1859            &src,
1860            RejectOpts {
1861                to: Some(&dst),
1862                ..Default::default()
1863            },
1864        )
1865        .unwrap();
1866        let dst_h = issue_at(&layout, "sample", &dst);
1867        assert_eq!(
1868            dst_h.properties.get("DISCOVERED_FROM").map(String::as_str),
1869            Some(origin.as_str()),
1870            "a filled DISCOVERED_FROM stays put"
1871        );
1872    }
1873
1874    #[test]
1875    fn create_sets_discovered_from_from_the_first_known_id_link() {
1876        let dir = tempfile::tempdir().unwrap();
1877        let layout = fresh_layout(dir.path());
1878        create(&layout, "sample", "source", CreateOpts::default()).unwrap();
1879        let known = only_id(&layout, "sample");
1880        create(
1881            &layout,
1882            "sample",
1883            "fell out of it",
1884            CreateOpts {
1885                body: Some(&format!("See [[id:{known}]] for the parent finding.")),
1886                ..Default::default()
1887            },
1888        )
1889        .unwrap();
1890        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1891        let child = doc
1892            .headings
1893            .iter()
1894            .find(|h| h.title == "fell out of it")
1895            .unwrap();
1896        assert_eq!(
1897            child.properties.get("DISCOVERED_FROM").map(String::as_str),
1898            Some(known.as_str())
1899        );
1900    }
1901
1902    #[test]
1903    fn create_ignores_an_id_link_that_is_not_in_the_corpus() {
1904        let dir = tempfile::tempdir().unwrap();
1905        let layout = fresh_layout(dir.path());
1906        create(
1907            &layout,
1908            "sample",
1909            "orphan mention",
1910            CreateOpts {
1911                body: Some("See [[id:sample-zzzz]] which does not exist."),
1912                ..Default::default()
1913            },
1914        )
1915        .unwrap();
1916        let h = issue_at(&layout, "sample", &only_id(&layout, "sample"));
1917        assert!(
1918            !h.properties.contains_key("DISCOVERED_FROM"),
1919            "unknown [[id:]] must not mint DISCOVERED_FROM: {h:?}"
1920        );
1921        assert!(
1922            !h.properties.contains_key("BLOCKED_BY"),
1923            "prose must not mint BLOCKED_BY: {h:?}"
1924        );
1925    }
1926
1927    #[test]
1928    fn related_after_reject_names_the_successor_without_a_body_link() {
1929        let dir = tempfile::tempdir().unwrap();
1930        let layout = fresh_layout(dir.path());
1931        create(&layout, "sample", "old approach", CreateOpts::default()).unwrap();
1932        create(&layout, "sample", "new approach", CreateOpts::default()).unwrap();
1933        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1934        let src = doc.headings[0].id.clone();
1935        let dst = doc.headings[1].id.clone();
1936        reject(
1937            &layout,
1938            &src,
1939            RejectOpts {
1940                to: Some(&dst),
1941                ..Default::default()
1942            },
1943        )
1944        .unwrap();
1945
1946        assert!(
1947            !issue_at(&layout, "sample", &src).body.contains(&dst),
1948            "the pair is wired by PIVOTED_TO, not prose"
1949        );
1950        let from_src = crate::related::related(&layout, &src, 1, 10, "text").unwrap();
1951        assert!(from_src.contains(&dst), "{from_src}");
1952        assert!(from_src.contains("pivoted_to"), "{from_src}");
1953
1954        let from_dst = crate::related::related(&layout, &dst, 1, 10, "text").unwrap();
1955        assert!(from_dst.contains(&src), "{from_dst}");
1956        assert!(from_dst.contains("successor_of"), "{from_dst}");
1957
1958        let waiting = crate::report::backlinks(&layout, &dst).unwrap();
1959        assert!(waiting.contains(&src), "{waiting}");
1960    }
1961
1962    #[test]
1963    fn update_to_cancelled_emits_state_change_with_the_id() {
1964        let dir = tempfile::tempdir().unwrap();
1965        let layout = fresh_layout(dir.path());
1966        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
1967        let id = only_id(&layout, "sample");
1968        let before = crate::events::generation(&layout);
1969        update(&layout, &id, Some("CANCELLED"), None, None, None).unwrap();
1970        let events = crate::events::since(&layout, before, 50).unwrap();
1971        assert!(
1972            events.iter().any(|e| {
1973                e.kind == "state_change"
1974                    && e.id.as_deref() == Some(id.as_str())
1975                    && e.detail.as_deref() == Some("TODO->CANCELLED")
1976            }),
1977            "{events:?}"
1978        );
1979    }
1980
1981    #[test]
1982    fn a_stale_done_after_reject_is_refused_and_the_source_stays_cancelled() {
1983        let dir = tempfile::tempdir().unwrap();
1984        let layout = fresh_layout(dir.path());
1985        create(&layout, "sample", "old plan", CreateOpts::default()).unwrap();
1986        create(&layout, "sample", "rewrite", CreateOpts::default()).unwrap();
1987        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1988        let src = doc.headings[0].id.clone();
1989        let dst = doc.headings[1].id.clone();
1990        reject(
1991            &layout,
1992            &src,
1993            RejectOpts {
1994                to: Some(&dst),
1995                ..Default::default()
1996            },
1997        )
1998        .unwrap();
1999
2000        let err = update_pred(
2001            &layout,
2002            &src,
2003            Some("DONE"),
2004            None,
2005            None,
2006            None,
2007            UpdatePred {
2008                if_state: Some("STARTED"),
2009                if_gen: None,
2010            },
2011        )
2012        .unwrap_err();
2013        assert!(
2014            matches!(
2015                err,
2016                Error::StaleWrite {
2017                    ref actual_state,
2018                    ref expected_state,
2019                    ..
2020                } if actual_state == "CANCELLED" && expected_state.as_deref() == Some("STARTED")
2021            ),
2022            "{err:?}"
2023        );
2024        assert_eq!(issue_at(&layout, "sample", &src).state, "CANCELLED");
2025    }
2026
2027    #[test]
2028    fn if_gen_refuses_when_the_corpus_moved() {
2029        let dir = tempfile::tempdir().unwrap();
2030        let layout = fresh_layout(dir.path());
2031        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
2032        let id = only_id(&layout, "sample");
2033        let seen = crate::events::generation(&layout);
2034        update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
2035        let err = update_pred(
2036            &layout,
2037            &id,
2038            Some("DONE"),
2039            None,
2040            None,
2041            None,
2042            UpdatePred {
2043                if_state: None,
2044                if_gen: Some(seen),
2045            },
2046        )
2047        .unwrap_err();
2048        assert!(matches!(err, Error::StaleWrite { .. }), "{err:?}");
2049        assert_eq!(issue_at(&layout, "sample", &id).state, "STARTED");
2050    }
2051
2052    #[test]
2053    fn a_second_terminal_does_not_drop_the_first() {
2054        let dir = tempfile::tempdir().unwrap();
2055        let layout = fresh_layout(dir.path());
2056        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
2057        let id = only_id(&layout, "sample");
2058        update(&layout, &id, Some("DONE"), None, None, None).unwrap();
2059        update(&layout, &id, Some("CANCELLED"), None, None, None).unwrap();
2060        let h = issue_at(&layout, "sample", &id);
2061        assert_eq!(h.state, "DONE", "first terminal must stay");
2062        assert_eq!(
2063            crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL),
2064            Some("CANCELLED")
2065        );
2066
2067        resolve_terminal(&layout, &id, "CANCELLED").unwrap();
2068        let h = issue_at(&layout, "sample", &id);
2069        assert_eq!(h.state, "CANCELLED");
2070        assert!(crate::props::get(&h.properties, crate::props::SIBLING_TERMINAL).is_none());
2071    }
2072
2073    #[test]
2074    fn check_warns_on_reject_prose_done_and_a_mention_without_an_edge() {
2075        let dir = tempfile::tempdir().unwrap();
2076        let layout = fresh_layout(dir.path());
2077        create(&layout, "sample", "shipped", CreateOpts::default()).unwrap();
2078        create(&layout, "sample", "other", CreateOpts::default()).unwrap();
2079        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
2080        let shipped = doc.headings[0].id.clone();
2081        let other = doc.headings[1].id.clone();
2082        update(&layout, &shipped, Some("DONE"), None, None, None).unwrap();
2083        append_body(&layout, &shipped, "rejected in the append, bounced").unwrap();
2084        append_body(&layout, &other, &format!("see [[id:{shipped}]]")).unwrap();
2085
2086        let report = crate::report::check(&layout).unwrap();
2087        assert!(
2088            report.text.contains(&shipped)
2089                && report.text.contains("DONE but the body reads as a reject"),
2090            "{}",
2091            report.text
2092        );
2093        assert!(
2094            report.text.contains(&other)
2095                && report.text.contains("no DISCOVERED_FROM or PIVOTED_TO"),
2096            "{}",
2097            report.text
2098        );
2099        assert!(report.warnings >= 2, "{}", report.text);
2100    }
2101
2102    #[test]
2103    fn check_names_a_file_missing_category_and_a_type_not_on_the_heading() {
2104        let dir = tempfile::tempdir().unwrap();
2105        let layout = fresh_layout(dir.path());
2106        let path = layout.project_issues_path("sample");
2107        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2108        std::fs::write(
2109            &path,
2110            "#+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",
2111        )
2112        .unwrap();
2113        let report = crate::report::check(&layout).unwrap();
2114        assert!(
2115            report.text.contains("sample: preamble has no #+CATEGORY:"),
2116            "{}",
2117            report.text
2118        );
2119        assert!(
2120            report
2121                .text
2122                .contains("have :TYPE: that is a legal Org tag but is not on the heading"),
2123            "{}",
2124            report.text
2125        );
2126        assert!(
2127            report
2128                .text
2129                .contains("preamble has no #+VISSUE: protocol stamp"),
2130            "{}",
2131            report.text
2132        );
2133        assert!(
2134            report.text.contains("preamble has no #+PRIORITIES:"),
2135            "{}",
2136            report.text
2137        );
2138    }
2139
2140    #[test]
2141    fn check_errors_on_a_newer_protocol_stamp() {
2142        let dir = tempfile::tempdir().unwrap();
2143        let layout = fresh_layout(dir.path());
2144        let path = layout.project_issues_path("sample");
2145        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2146        std::fs::write(
2147            &path,
2148            "#+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",
2149        )
2150        .unwrap();
2151        let report = crate::report::check(&layout).unwrap();
2152        assert!(report.errors >= 1, "{}", report.text);
2153        assert!(
2154            report
2155                .text
2156                .contains("#+VISSUE: 99 is newer than this vissue"),
2157            "{}",
2158            report.text
2159        );
2160    }
2161
2162    #[test]
2163    fn normalize_rewrites_legacy_keys_and_keeps_edna() {
2164        let dir = tempfile::tempdir().unwrap();
2165        let layout = fresh_layout(dir.path());
2166        let path = layout.project_issues_path("sample");
2167        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2168        std::fs::write(
2169            &path,
2170            "#+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",
2171        )
2172        .unwrap();
2173        let dry = normalize(&layout, Some("sample"), true).unwrap();
2174        assert!(dry.contains("would rewrite"), "{dry}");
2175        let on_disk = std::fs::read_to_string(&path).unwrap();
2176        assert!(on_disk.contains(":TYPE:"), "{on_disk}");
2177        let wrote = normalize(&layout, Some("sample"), false).unwrap();
2178        assert!(wrote.contains("rewrote"), "{wrote}");
2179        let after = std::fs::read_to_string(&path).unwrap();
2180        assert!(after.contains("#+CATEGORY: sample"), "{after}");
2181        assert!(after.contains("#+PRIORITIES: A C C"), "{after}");
2182        assert!(after.contains(":TYPE:       bug"), "{after}");
2183        assert!(after.contains(":PARENT:"), "{after}");
2184        assert!(after.contains(":BLOCKED_BY:"), "{after}");
2185        assert!(
2186            !after.contains("ids(sample-bbbb)"),
2187            "normalize must not mint edna ids(): {after}"
2188        );
2189        assert!(after.contains("prev-sibling"), "{after}");
2190    }
2191}