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