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}
64
65/// Append a new TODO issue to the project's file and return the status text.
66///
67/// # Errors
68///
69/// Returns an error if the priority is not `A`/`B`/`C`, a date does not parse,
70/// `parent` is not a known org id, the id space is exhausted, or the file
71/// cannot be locked or rewritten.
72pub fn create(layout: &Layout, project: &str, title: &str, opts: CreateOpts<'_>) -> Result<String> {
73    let project = resolve_existing_project_case(layout, project)?;
74    let cfg = VissueConfig::load(layout)?;
75    let priority = opts.priority.unwrap_or(cfg.issues.default_priority);
76    if !"ABC".contains(priority) {
77        return Err(anyhow!("invalid priority {priority:?}; allowed: A B C").into());
78    }
79    let path = layout.project_issues_path(&project);
80
81    // Validating the parent scans every org file, so do it outside the lock.
82    if let Some(p) = opts.parent
83        && !collect_org_ids(layout)?.contains(p)
84    {
85        return Err(anyhow!("--parent {p} does not refer to any known id").into());
86    }
87
88    with_issues_lock(&path, || {
89        let mut doc = IssueDoc::parse_file(&project, &path)?;
90        let id = generate_id(&project, &doc.known_ids(), cfg.issues.id_length)?;
91
92        let mut props = BTreeMap::new();
93        props.insert("ID".into(), id.clone());
94        props.insert("CREATED".into(), today_inactive_bracket());
95        if let Some(t) = opts.issue_type {
96            props.insert("TYPE".into(), t.into());
97        }
98        if let Some(d) = opts.deadline {
99            validate_org_date(d)?;
100            props.insert("DEADLINE".into(), d.into());
101        }
102        if let Some(s) = opts.scheduled {
103            validate_org_date(s)?;
104            props.insert("SCHEDULED".into(), s.into());
105        }
106        // A tag Org can hold goes on the heading, where Org's own tag search
107        // and agenda read it. One Org would not accept, `needs-review` say,
108        // stays in the property so it survives instead of becoming title text.
109        let mut org_tags: Vec<String> = Vec::new();
110        if let Some(tags) = opts.tags {
111            let mut property_tags: Vec<String> = Vec::new();
112            for tag in tags.split([',', ':']).map(str::trim) {
113                if tag.is_empty() {
114                    continue;
115                }
116                if tag.chars().all(crate::model::is_org_tag_char) {
117                    if !org_tags.iter().any(|seen| seen == tag) {
118                        org_tags.push(tag.to_string());
119                    }
120                } else if !property_tags.iter().any(|seen| seen == tag) {
121                    property_tags.push(tag.to_string());
122                }
123            }
124            if !property_tags.is_empty() {
125                props.insert(crate::model::TAGS_PROPERTY.into(), property_tags.join(","));
126            }
127        }
128        if let Some(p) = opts.parent {
129            props.insert("PARENT".into(), p.into());
130        }
131
132        doc.headings.push(IssueHeading {
133            id: id.clone(),
134            title: title.to_string(),
135            state: "TODO".into(),
136            priority,
137            properties: props,
138            org_tags,
139            property_order: Vec::new(),
140            body: match opts.body {
141                Some(b) if !b.trim().is_empty() => format!("{}\n", b.trim_end()),
142                _ => String::new(),
143            },
144            logbook: Vec::new(),
145            line_start: 0,
146            line_end: 0,
147        });
148        doc.write()?;
149
150        if opts.quiet {
151            Ok(format!("{id}\n"))
152        } else {
153            Ok(format!(
154                "{id}  TODO  [#{priority}]  {title}\nfile: {}\n",
155                path.display()
156            ))
157        }
158    })
159}
160
161pub(crate) fn validate_org_date(s: &str) -> Result<()> {
162    let inner = s
163        .trim_start_matches(['<', '['])
164        .trim_end_matches(['>', ']']);
165    let token = inner.split_whitespace().next().unwrap_or("");
166    NaiveDate::parse_from_str(token, "%Y-%m-%d").with_context(|| {
167        format!("expected org date like <YYYY-MM-DD> or [YYYY-MM-DD], got {s:?}")
168    })?;
169    Ok(())
170}
171
172/// Change state, priority, or blocker edges. Adding a blocker to an open issue
173/// moves it to BLOCKED; clearing the last blocker moves it back to TODO.
174///
175/// # Errors
176///
177/// Returns an error if `id` is not in the corpus, the state or priority is
178/// invalid, adding the blocker would cycle, or the file cannot be rewritten.
179pub fn update(
180    layout: &Layout,
181    id: &str,
182    new_state: Option<&str>,
183    new_priority: Option<char>,
184    block_add: Option<&str>,
185    block_clear: Option<&str>,
186) -> Result<UpdateOutcome> {
187    let identity = crate::config::identity(layout);
188    update_as(
189        layout,
190        id,
191        new_state,
192        new_priority,
193        block_add,
194        block_clear,
195        &identity,
196    )
197}
198
199/// [`update`] with an explicit identity instead of [`crate::config::identity`].
200///
201/// # Errors
202///
203/// Returns an error if `id` is not in the corpus, the state or priority is
204/// invalid, adding the blocker would cycle, or the file cannot be rewritten.
205pub fn update_as(
206    layout: &Layout,
207    id: &str,
208    new_state: Option<&str>,
209    new_priority: Option<char>,
210    block_add: Option<&str>,
211    block_clear: Option<&str>,
212    identity: &str,
213) -> Result<UpdateOutcome> {
214    let (_h0, path, project) =
215        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
216
217    let (final_state, changed) = with_issues_lock(&path, || {
218        // Read the graph inside the lock. Built before it, the check answers
219        // for a corpus a peer may already have moved on from.
220        let graph = if block_add.is_some() {
221            Some(DependencyGraph::from_issues(&load_all(layout)?)?)
222        } else {
223            None
224        };
225        let mut doc = IssueDoc::parse_file(&project, &path)?;
226        let h = doc
227            .headings
228            .iter_mut()
229            .find(|x| x.id == id)
230            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
231
232        let mut changed = Vec::new();
233
234        if let Some(s) = new_state {
235            if !TODO_KEYWORDS.contains(&s) {
236                return Err(anyhow!("invalid state {s:?}; allowed: {TODO_KEYWORDS:?}").into());
237            }
238            if h.state != s {
239                let from = h.state.clone();
240                h.record_state_change(s);
241                changed.push(format!("state {from} -> {s}"));
242                for note in settle_claim(h, &from, s, identity) {
243                    changed.push(note);
244                }
245            }
246        }
247
248        if let Some(p) = new_priority {
249            if !"ABC".contains(p) {
250                return Err(anyhow!("invalid priority {p:?}; allowed: A B C").into());
251            }
252            if h.priority != p {
253                h.priority = p;
254                changed.push(format!("priority -> [#{p}]"));
255            }
256        }
257
258        if let Some(blk) = block_add {
259            let mut current = h.blocked_by();
260            if !current.iter().any(|x| x == blk) {
261                if let Some(graph) = &graph {
262                    graph.accepts_edge(blk, id)?;
263                }
264                current.push(blk.to_string());
265                h.properties.insert("BLOCKED_BY".into(), current.join(","));
266                if h.state == "TODO" || h.state == "STARTED" {
267                    let from = h.state.clone();
268                    h.record_state_change("BLOCKED");
269                    changed.push(format!("state {from} -> BLOCKED (auto on block)"));
270                }
271                changed.push(format!("blocked_by += {blk}"));
272            }
273        }
274
275        if let Some(blk) = block_clear {
276            let mut current = h.blocked_by();
277            let before = current.len();
278            current.retain(|x| x != blk);
279            if current.len() < before {
280                if current.is_empty() {
281                    h.properties.remove("BLOCKED_BY");
282                    if h.state == "BLOCKED" {
283                        let from = h.state.clone();
284                        h.record_state_change("TODO");
285                        changed.push("state BLOCKED -> TODO (auto on unblock)".to_string());
286                        for note in settle_claim(h, &from, "TODO", identity) {
287                            changed.push(note);
288                        }
289                    }
290                } else {
291                    h.properties.insert("BLOCKED_BY".into(), current.join(","));
292                }
293                changed.push(format!("blocked_by -= {blk}"));
294            }
295        }
296
297        if changed.is_empty() {
298            return Ok((None, Vec::new()));
299        }
300
301        let final_state = h.state.clone();
302        doc.write()?;
303        Ok((Some(final_state), changed))
304    })?;
305
306    if changed.is_empty() {
307        return Ok(UpdateOutcome {
308            report: format!("{id}: no change\n"),
309            hints: Vec::new(),
310        });
311    }
312
313    let mut hints = Vec::new();
314    if matches!(final_state.as_deref(), Some("DONE") | Some("CANCELLED")) {
315        for (other_project, other) in load_all(layout)? {
316            if !other.blocked_by().iter().any(|b| b == id) {
317                continue;
318            }
319            if other.state == "DONE" || other.state == "CANCELLED" {
320                continue;
321            }
322            hints.push(format!(
323                "{} (in {}) lists this as a blocker; clear with `vissue update {} --unblock {}`",
324                other.id, other_project, other.id, id
325            ));
326        }
327    }
328    Ok(UpdateOutcome {
329        report: format!("{id}: {}\n", changed.join(", ")),
330        hints,
331    })
332}
333
334/// States that keep a claim: someone still holds the issue even when it is
335/// waiting on something else. Leaving for TODO, DONE, or CANCELLED gives it up.
336fn keeps_claim(state: &str) -> bool {
337    matches!(state, "STARTED" | "BLOCKED")
338}
339
340/// Take or give up the claim as the state moves.
341///
342/// Entering STARTED unclaimed stamps the identity; leaving for a state that
343/// holds no claim releases it, and the logbook keeps who held it and since
344/// when.
345fn settle_claim(h: &mut IssueHeading, from: &str, to: &str, identity: &str) -> Vec<String> {
346    let mut notes = Vec::new();
347    if to == "STARTED" && h.claimed_by().is_none() {
348        h.set_claim(identity);
349        notes.push(format!("claimed by {identity}"));
350    } else if keeps_claim(from)
351        && !keeps_claim(to)
352        && let Some((who, _when)) = h.release_claim()
353    {
354        notes.push(format!("claim released ({who})"));
355    }
356    notes
357}
358
359/// Take an issue: move it to STARTED and stamp the claim.
360///
361/// A claim held by another identity is refused unless `force`, which records
362/// the takeover in the logbook rather than losing it.
363///
364/// # Errors
365///
366/// Returns an error if `id` is not in the corpus, the issue is DONE or
367/// CANCELLED, another identity holds it and `force` is false, or the file
368/// cannot be rewritten.
369pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
370    let identity = crate::config::identity(layout);
371    claim_as(layout, id, force, &identity)
372}
373
374/// [`claim`] with an explicit identity instead of [`crate::config::identity`].
375///
376/// # Errors
377///
378/// Returns an error if `id` is not in the corpus, the issue is DONE or
379/// CANCELLED, another identity holds it and `force` is false, or the file
380/// cannot be rewritten.
381pub fn claim_as(layout: &Layout, id: &str, force: bool, identity: &str) -> Result<String> {
382    let (_h0, path, project) =
383        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
384
385    let report = with_issues_lock(&path, || {
386        let mut doc = IssueDoc::parse_file(&project, &path)?;
387        let h = doc
388            .headings
389            .iter_mut()
390            .find(|x| x.id == id)
391            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
392
393        if h.state == "DONE" || h.state == "CANCELLED" {
394            return Err(Error::InvalidState {
395                id: id.to_string(),
396                state: h.state.clone(),
397            });
398        }
399        if let Some(holder) = h.claimed_by() {
400            if holder != identity && !force {
401                return Err(Error::ClaimConflict {
402                    id: id.to_string(),
403                    holder: holder.to_string(),
404                    claimed_at: h.claimed_at().map(str::to_string),
405                });
406            }
407            if holder != identity {
408                let previous = holder.to_string();
409                h.release_claim();
410                h.set_claim(identity);
411                h.record_state_change("STARTED");
412                doc.write()?;
413                return Ok(format!("claimed {id} (taken over from {previous})\n"));
414            }
415        }
416
417        let was = h.state.clone();
418        h.record_state_change("STARTED");
419        if h.claimed_by().is_none() {
420            h.set_claim(identity);
421        }
422        doc.write()?;
423        if was == "STARTED" {
424            Ok(format!("claimed {id} by {identity}\n"))
425        } else {
426            Ok(format!("claimed {id} by {identity} ({was} -> STARTED)\n"))
427        }
428    })?;
429    Ok(report)
430}
431
432/// What an update changed, plus advice about issues left dangling by it.
433#[derive(Debug, Clone)]
434pub struct UpdateOutcome {
435    /// One-line change summary, or `{id}: no change`.
436    pub report: String,
437    /// Issues that still list this one as a blocker after it closed.
438    pub hints: Vec<String>,
439}
440
441/// Add a dated note to the top of an issue's logbook. State, claim, and
442/// properties stay untouched, so an agent can record progress without owning
443/// the issue.
444///
445/// # Errors
446///
447/// Returns an error if `text` is empty, `id` is not in the corpus, or the
448/// file cannot be rewritten.
449pub fn note(layout: &Layout, id: &str, text: &str) -> Result<String> {
450    // One line in the drawer: fold internal whitespace, and swap double
451    // quotes for singles so the rendered `- Note: "..."` line re-parses.
452    let text = text
453        .split_whitespace()
454        .collect::<Vec<_>>()
455        .join(" ")
456        .replace('"', "'");
457    if text.is_empty() {
458        return Err(anyhow!("note text is empty").into());
459    }
460    let (_h0, path, project) =
461        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
462    with_issues_lock(&path, || {
463        let mut doc = IssueDoc::parse_file(&project, &path)?;
464        let h = doc
465            .headings
466            .iter_mut()
467            .find(|x| x.id == id)
468            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
469        // Newest first, matching state transitions and claim releases. A
470        // drawer written from both ends reads as sorted by neither.
471        h.logbook.insert(
472            0,
473            LogEntry {
474                timestamp: LogEntry::now(),
475                from_state: None,
476                to_state: None,
477                note: Some(text.clone()),
478                raw: None,
479            },
480        );
481        doc.write()?;
482        Ok(format!("{id}: noted\n"))
483    })
484}
485
486/// Append prose to an issue's body, stamped with the date and identity.
487///
488/// The logbook holds one line per event, so a written report does not fit in
489/// it: [`note`] folds its text to a single line by design. Work that has been
490/// done and needs recording belongs under the heading as prose, which is
491/// where a reader looks for what the issue is about.
492///
493/// The text is kept as given. Lines that would end the issue are indented on
494/// the way out, so markdown is safe to append.
495///
496/// # Errors
497///
498/// Returns an error if `text` is empty, `id` is not in the corpus, or the
499/// file cannot be rewritten.
500pub fn append_body(layout: &Layout, id: &str, text: &str) -> Result<String> {
501    append_body_as(layout, id, text, &crate::config::identity(layout))
502}
503
504/// [`append_body`] with the recorded identity passed in.
505///
506/// # Errors
507///
508/// Returns an error if `text` is empty, `id` is not in the corpus, or the
509/// file cannot be rewritten.
510pub fn append_body_as(layout: &Layout, id: &str, text: &str, identity: &str) -> Result<String> {
511    let text = text.trim_end();
512    if text.trim().is_empty() {
513        return Err(anyhow!("append text is empty").into());
514    }
515    let (_h0, path, project) =
516        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
517    with_issues_lock(&path, || {
518        let mut doc = IssueDoc::parse_file(&project, &path)?;
519        let h = doc
520            .headings
521            .iter_mut()
522            .find(|x| x.id == id)
523            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
524        let stamp = format!("{} {identity}", today_inactive_bracket());
525        if !h.body.trim().is_empty() {
526            h.body = h.body.trim_end().to_string();
527            h.body.push_str("\n\n");
528        } else {
529            h.body.clear();
530        }
531        h.body.push_str(&stamp);
532        h.body.push('\n');
533        h.body.push_str(text);
534        h.body.push('\n');
535        doc.write()?;
536        let lines = text.lines().count();
537        Ok(format!("{id}: appended {lines} line(s)\n"))
538    })
539}
540
541/// Fold an inbox-convention org file into tracked issues.
542///
543/// Each top-level `* TODO <title>` heading that does not already carry a
544/// `:VISSUE_ID:` line becomes an issue in `project` (body = the heading's
545/// text up to the next heading). The heading is then flipped to DONE and
546/// stamped with the assigned id in place, so a second run is a no-op:
547/// stamped headings are skipped, and folding is idempotent.
548///
549/// # Errors
550///
551/// Returns an error if the inbox cannot be read or written, `project` cannot
552/// be resolved, or creating a folded issue fails. Headings already stamped
553/// before a failure stay stamped.
554pub fn fold(layout: &Layout, inbox: &std::path::Path, project: &str) -> Result<String> {
555    let project = resolve_existing_project_case(layout, project)?;
556    let text = std::fs::read_to_string(inbox)
557        .with_context(|| format!("read inbox {}", inbox.display()))?;
558    let lines: Vec<String> = text.lines().map(str::to_string).collect();
559
560    struct Entry {
561        line: usize,
562        title: String,
563        body: String,
564        stamped: bool,
565    }
566    let mut entries: Vec<Entry> = Vec::new();
567    let mut i = 0;
568    while i < lines.len() {
569        if let Some(title) = lines[i].strip_prefix("* TODO ") {
570            let start = i + 1;
571            let end = lines[start..]
572                .iter()
573                .position(|l| l.starts_with("* "))
574                .map(|off| start + off)
575                .unwrap_or(lines.len());
576            let stamped = lines[start..end]
577                .iter()
578                .any(|l| l.trim_start().starts_with(":VISSUE_ID:"));
579            let body = lines[start..end].join("\n").trim().to_string();
580            entries.push(Entry {
581                line: i,
582                title: title.trim().to_string(),
583                body,
584                stamped,
585            });
586            i = end;
587        } else {
588            i += 1;
589        }
590    }
591
592    // Stamping inserts lines, so rewrite from the bottom up to keep the
593    // recorded line numbers valid.
594    let mut out = lines.clone();
595    let mut created: Vec<String> = Vec::new();
596    let mut failure = None;
597    for e in entries.iter().rev() {
598        if e.stamped {
599            continue;
600        }
601        let printed = create(
602            layout,
603            &project,
604            &e.title,
605            CreateOpts {
606                quiet: true,
607                body: if e.body.is_empty() {
608                    None
609                } else {
610                    Some(&e.body)
611                },
612                ..CreateOpts::default()
613            },
614        );
615        let id = match printed {
616            Ok(printed) => printed.trim().to_string(),
617            Err(e) => {
618                // Stop, but stamp what already exists below. Returning here
619                // with the inbox untouched would leave every issue created so
620                // far unstamped, and the next run would create them again.
621                failure = Some(e);
622                break;
623            }
624        };
625        out[e.line] = format!("* DONE {}", e.title);
626        out.insert(e.line + 1, format!(":VISSUE_ID: {id}"));
627        created.push(id);
628    }
629    created.reverse();
630
631    if !created.is_empty() {
632        let mut rendered = out.join("\n");
633        if text.ends_with('\n') {
634            rendered.push('\n');
635        }
636        std::fs::write(inbox, rendered)
637            .with_context(|| format!("write inbox {}", inbox.display()))?;
638    }
639    if let Some(error) = failure {
640        return Err(crate::error::Error::Other(
641            anyhow::Error::from(error).context(format!(
642                "folded {} before failing: {}",
643                created.len(),
644                created.join(" ")
645            )),
646        ));
647    }
648    if created.is_empty() {
649        return Ok("folded 0 (nothing unstamped)\n".into());
650    }
651    Ok(format!("folded {}: {}\n", created.len(), created.join(" ")))
652}
653
654/// Move one issue's heading to another project's file. The id is not
655/// regenerated, so cross-project blocker edges keep resolving.
656///
657/// # Errors
658///
659/// Returns an error if `id` is not in the corpus, `to_project` cannot be
660/// resolved, or either file cannot be locked or rewritten.
661pub fn refile(layout: &Layout, id: &str, to_project: &str) -> Result<String> {
662    let to_project = resolve_existing_project_case(layout, to_project)?;
663    let target_path = layout.project_issues_path(&to_project);
664    let (_heading, src_path, src_project) =
665        find_by_id(layout, id)?.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
666    if src_project == to_project {
667        return Ok(format!("{id} already in {to_project}; nothing to do\n"));
668    }
669    with_issues_locks(&[&src_path, &target_path], || {
670        let mut src_doc = IssueDoc::parse_file(&src_project, &src_path)?;
671        let heading = src_doc
672            .remove(id)
673            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
674
675        // Two files cannot be replaced in one atomic step, so choose which
676        // half-finished state a failure leaves behind. Writing the target
677        // first means a failed source write duplicates the id, which `check`
678        // reports and a person can resolve; the other order deletes the issue
679        // with nothing left naming it.
680        let mut tgt_doc = IssueDoc::parse_file(&to_project, &target_path)?;
681        tgt_doc.upsert(heading);
682        tgt_doc.write()?;
683        src_doc.write()?;
684        Ok(())
685    })?;
686    Ok(format!("{id}: {src_project} -> {to_project}\n"))
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::config::DEFAULT_PREFIX;
693    use std::fs;
694    use std::path::Path;
695
696    fn fresh_layout(dir: &Path) -> Layout {
697        fs::create_dir_all(dir.join(DEFAULT_PREFIX)).unwrap();
698        Layout::new(dir, DEFAULT_PREFIX)
699    }
700
701    fn issue_at(layout: &Layout, project: &str, id: &str) -> IssueHeading {
702        IssueDoc::parse_file(project, &layout.project_issues_path(project))
703            .unwrap()
704            .headings
705            .into_iter()
706            .find(|h| h.id == id)
707            .expect("issue not found")
708    }
709
710    fn only_id(layout: &Layout, project: &str) -> String {
711        IssueDoc::parse_file(project, &layout.project_issues_path(project))
712            .unwrap()
713            .headings[0]
714            .id
715            .clone()
716    }
717
718    #[test]
719    fn create_rejects_a_parent_that_does_not_exist() {
720        let dir = tempfile::tempdir().unwrap();
721        let layout = fresh_layout(dir.path());
722        let err = create(
723            &layout,
724            "sample",
725            "child without parent",
726            CreateOpts {
727                parent: Some("sample-zzz9"),
728                ..Default::default()
729            },
730        )
731        .unwrap_err();
732        assert!(err.to_string().contains("does not refer to any known id"));
733    }
734
735    #[test]
736    fn create_accepts_a_parent_defined_in_a_design_document() {
737        let dir = tempfile::tempdir().unwrap();
738        let layout = fresh_layout(dir.path());
739        let parent_id = "sample-spec-20260615";
740        let project_dir = layout.projects_dir().join("sample");
741        fs::create_dir_all(&project_dir).unwrap();
742        fs::write(
743            project_dir.join("design.org"),
744            format!("#+TITLE: sample design\n\n* Design\n:PROPERTIES:\n:ID:         {parent_id}\n:END:\n"),
745        )
746        .unwrap();
747
748        create(
749            &layout,
750            "sample",
751            "child under design",
752            CreateOpts {
753                parent: Some(parent_id),
754                ..Default::default()
755            },
756        )
757        .unwrap();
758        assert!(only_id(&layout, "sample").starts_with("sample-"));
759        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
760        assert_eq!(doc.headings[0].parent(), Some(parent_id));
761    }
762
763    #[test]
764    fn a_state_update_writes_a_logbook_entry() {
765        let dir = tempfile::tempdir().unwrap();
766        let layout = fresh_layout(dir.path());
767        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
768        let id = only_id(&layout, "sample");
769        update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
770        let h = issue_at(&layout, "sample", &id);
771        assert_eq!(h.state, "STARTED");
772        assert_eq!(h.logbook[0].from_state.as_deref(), Some("TODO"));
773        assert_eq!(h.logbook[0].to_state.as_deref(), Some("STARTED"));
774    }
775
776    #[test]
777    fn blocking_and_unblocking_drive_the_state() {
778        let dir = tempfile::tempdir().unwrap();
779        let layout = fresh_layout(dir.path());
780        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
781        create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
782        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
783        let first = doc.headings[0].id.clone();
784        let blocker = doc.headings[1].id.clone();
785
786        update(&layout, &first, None, None, Some(&blocker), None).unwrap();
787        let h = issue_at(&layout, "sample", &first);
788        assert_eq!(h.state, "BLOCKED");
789        assert!(h.blocked_by().contains(&blocker));
790
791        update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
792        let h = issue_at(&layout, "sample", &first);
793        assert_eq!(h.state, "TODO");
794        assert!(h.blocked_by().is_empty());
795    }
796
797    #[test]
798    fn auto_unblock_to_todo_releases_the_claim() {
799        let dir = tempfile::tempdir().unwrap();
800        let layout = fresh_layout(dir.path());
801        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
802        create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
803        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
804        let first = doc.headings[0].id.clone();
805        let blocker = doc.headings[1].id.clone();
806
807        crate::agent::claim(&layout, &first, false).unwrap();
808        update(&layout, &first, None, None, Some(&blocker), None).unwrap();
809        assert!(issue_at(&layout, "sample", &first).claimed_by().is_some());
810
811        update(&layout, &first, None, None, None, Some(&blocker)).unwrap();
812        let h = issue_at(&layout, "sample", &first);
813        assert_eq!(h.state, "TODO");
814        assert!(h.claimed_by().is_none(), "claim stuck on TODO: {h:?}");
815    }
816
817    #[test]
818    fn blocker_cycle_is_rejected_before_writing() {
819        let dir = tempfile::tempdir().unwrap();
820        let layout = fresh_layout(dir.path());
821        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
822        create(&layout, "sample", "second", CreateOpts::default()).unwrap();
823        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
824        let first = doc.headings[0].id.clone();
825        let second = doc.headings[1].id.clone();
826
827        update(&layout, &first, None, None, Some(&second), None).unwrap();
828        let err = update(&layout, &second, None, None, Some(&first), None).unwrap_err();
829        assert!(err.to_string().contains("blocker cycle"), "{err}");
830        assert!(issue_at(&layout, "sample", &second).blocked_by().is_empty());
831    }
832
833    #[test]
834    fn closing_a_blocker_reports_the_issues_still_pointing_at_it() {
835        let dir = tempfile::tempdir().unwrap();
836        let layout = fresh_layout(dir.path());
837        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
838        create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
839        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
840        let first = doc.headings[0].id.clone();
841        let blocker = doc.headings[1].id.clone();
842        update(&layout, &first, None, None, Some(&blocker), None).unwrap();
843
844        let outcome = update(&layout, &blocker, Some("DONE"), None, None, None).unwrap();
845        assert_eq!(outcome.hints.len(), 1, "{:?}", outcome.hints);
846        assert!(outcome.hints[0].contains(&first), "{:?}", outcome.hints);
847    }
848
849    #[test]
850    fn refile_moves_the_heading_between_projects() {
851        let dir = tempfile::tempdir().unwrap();
852        let layout = fresh_layout(dir.path());
853        create(&layout, "source", "the issue", CreateOpts::default()).unwrap();
854        let id = only_id(&layout, "source");
855        refile(&layout, &id, "target").unwrap();
856
857        let src = IssueDoc::parse_file("source", &layout.project_issues_path("source")).unwrap();
858        let tgt = IssueDoc::parse_file("target", &layout.project_issues_path("target")).unwrap();
859        assert!(src.headings.is_empty());
860        assert_eq!(tgt.headings[0].id, id);
861    }
862
863    #[test]
864    fn deadlines_must_parse_as_org_dates() {
865        let dir = tempfile::tempdir().unwrap();
866        let layout = fresh_layout(dir.path());
867        let err = create(
868            &layout,
869            "sample",
870            "bad date",
871            CreateOpts {
872                deadline: Some("not-a-date"),
873                ..Default::default()
874            },
875        )
876        .unwrap_err();
877        assert!(err.to_string().contains("expected org date"));
878
879        for (i, d) in ["<2026-05-15 Fri>", "[2026-05-15]"].iter().enumerate() {
880            create(
881                &layout,
882                "sample",
883                &format!("issue {i}"),
884                CreateOpts {
885                    deadline: Some(d),
886                    ..Default::default()
887                },
888            )
889            .unwrap();
890        }
891        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
892        assert_eq!(doc.headings.len(), 2);
893        assert!(doc.headings.iter().all(|h| h.deadline().is_some()));
894    }
895
896    #[test]
897    fn org_safe_tags_go_on_the_heading_and_the_rest_stay_in_the_property() {
898        let dir = tempfile::tempdir().unwrap();
899        let layout = fresh_layout(dir.path());
900        create(
901            &layout,
902            "sample",
903            "tagged",
904            CreateOpts {
905                tags: Some("rust: perf ,, scaling, needs-review"),
906                ..Default::default()
907            },
908        )
909        .unwrap();
910        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
911        let h = &doc.headings[0];
912        assert_eq!(h.org_tags, vec!["rust", "perf", "scaling"]);
913        assert_eq!(
914            h.properties
915                .get(crate::model::TAGS_PROPERTY)
916                .map(|s| s.as_str()),
917            Some("needs-review"),
918            "a tag Org cannot hold keeps the property"
919        );
920        // Whichever half a tag landed in, a query sees all of them.
921        assert_eq!(
922            h.tags(),
923            vec!["needs-review", "rust", "perf", "scaling"],
924            "{h:?}"
925        );
926    }
927
928    #[test]
929    fn resolve_project_needs_a_name_from_somewhere() {
930        let dir = tempfile::tempdir().unwrap();
931        let layout = fresh_layout(dir.path());
932        assert_eq!(
933            resolve_project(&layout, Some("fromcli")).unwrap(),
934            "fromcli"
935        );
936        assert!(
937            resolve_project(&layout, Some(""))
938                .unwrap_err()
939                .to_string()
940                .contains("empty")
941        );
942    }
943
944    /// Parallel creates must not lose headings or fail the temporary rename.
945    #[test]
946    fn concurrent_creates_preserve_every_heading() {
947        use std::sync::Arc;
948        use std::thread;
949
950        let dir = tempfile::tempdir().unwrap();
951        let layout = Arc::new(fresh_layout(dir.path()));
952        let n = 24usize;
953        let handles: Vec<_> = (0..n)
954            .map(|i| {
955                let layout = Arc::clone(&layout);
956                thread::spawn(move || {
957                    create(
958                        &layout,
959                        "sample",
960                        &format!("parallel title {i}"),
961                        CreateOpts {
962                            quiet: true,
963                            ..Default::default()
964                        },
965                    )
966                })
967            })
968            .collect();
969        let mut ids: Vec<String> = handles
970            .into_iter()
971            .map(|h| {
972                h.join()
973                    .expect("thread panicked")
974                    .expect("create failed")
975                    .trim()
976                    .to_string()
977            })
978            .collect();
979        ids.sort();
980        ids.dedup();
981        assert_eq!(ids.len(), n, "expected {n} unique ids, got {ids:?}");
982
983        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
984        let mut on_disk: Vec<String> = doc.headings.iter().map(|h| h.id.clone()).collect();
985        on_disk.sort();
986        assert_eq!(on_disk, ids);
987    }
988
989    #[test]
990    fn note_appends_to_the_logbook_and_leaves_state_alone() {
991        let dir = tempfile::tempdir().unwrap();
992        let layout = fresh_layout(dir.path());
993        create(&layout, "sample", "carries a note", CreateOpts::default()).unwrap();
994        let id = only_id(&layout, "sample");
995
996        let out = note(&layout, &id, "first pass done,\n  \"quoted\" bit next").unwrap();
997        assert_eq!(out, format!("{id}: noted\n"));
998
999        let h = issue_at(&layout, "sample", &id);
1000        assert_eq!(h.state, "TODO");
1001        assert!(h.claimed_by().is_none());
1002        let notes: Vec<&str> = h.logbook.iter().filter_map(|e| e.note.as_deref()).collect();
1003        // Whitespace collapses to single spaces; double quotes become single.
1004        assert_eq!(notes, vec!["first pass done, 'quoted' bit next"]);
1005    }
1006
1007    #[test]
1008    fn the_logbook_reads_newest_first_however_an_entry_arrived() {
1009        let dir = tempfile::tempdir().unwrap();
1010        let layout = fresh_layout(dir.path());
1011        create(&layout, "sample", "ordered", CreateOpts::default()).unwrap();
1012        let id = only_id(&layout, "sample");
1013
1014        note(&layout, &id, "first note").unwrap();
1015        update(&layout, &id, Some("STARTED"), None, None, None).unwrap();
1016        note(&layout, &id, "second note").unwrap();
1017
1018        let h = issue_at(&layout, "sample", &id);
1019        let summary: Vec<String> = h
1020            .logbook
1021            .iter()
1022            .map(|e| match (&e.note, &e.to_state) {
1023                (Some(note), _) => note.clone(),
1024                (_, Some(to)) => format!("state:{to}"),
1025                _ => "?".into(),
1026            })
1027            .collect();
1028        assert_eq!(
1029            summary,
1030            vec!["second note", "state:STARTED", "first note"],
1031            "{h:?}"
1032        );
1033    }
1034
1035    #[test]
1036    fn note_rejects_empty_text_and_unknown_ids() {
1037        let dir = tempfile::tempdir().unwrap();
1038        let layout = fresh_layout(dir.path());
1039        create(&layout, "sample", "target", CreateOpts::default()).unwrap();
1040        let id = only_id(&layout, "sample");
1041        assert!(note(&layout, &id, "   ").is_err());
1042        assert!(note(&layout, "sample-zzz9", "text").is_err());
1043    }
1044
1045    #[test]
1046    fn fold_creates_issues_and_stamps_the_inbox_idempotently() {
1047        let dir = tempfile::tempdir().unwrap();
1048        let layout = fresh_layout(dir.path());
1049        create(&layout, "sample", "seed", CreateOpts::default()).unwrap();
1050
1051        let inbox = dir.path().join("inbox.org");
1052        fs::write(
1053            &inbox,
1054            "#+TITLE: inbox\n\n\
1055             * TODO first discovered thing\nSome body line.\nAnother line.\n\
1056             * DONE already handled elsewhere\n\
1057             * TODO second discovered thing\n",
1058        )
1059        .unwrap();
1060
1061        let out = fold(&layout, &inbox, "sample").unwrap();
1062        assert!(out.starts_with("folded 2: "), "got: {out}");
1063
1064        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1065        let titles: Vec<&str> = doc.headings.iter().map(|h| h.title.as_str()).collect();
1066        assert!(titles.contains(&"first discovered thing"));
1067        assert!(titles.contains(&"second discovered thing"));
1068        let folded = doc
1069            .headings
1070            .iter()
1071            .find(|h| h.title == "first discovered thing")
1072            .unwrap();
1073        assert!(folded.body.contains("Some body line."));
1074
1075        // Headings flipped to DONE and stamped with the assigned id.
1076        let stamped = fs::read_to_string(&inbox).unwrap();
1077        assert_eq!(stamped.matches("* DONE ").count(), 3);
1078        assert_eq!(stamped.matches(":VISSUE_ID: sample-").count(), 2);
1079        assert!(!stamped.contains("* TODO "));
1080
1081        // Second fold finds nothing unstamped and creates nothing.
1082        let again = fold(&layout, &inbox, "sample").unwrap();
1083        assert_eq!(again, "folded 0 (nothing unstamped)\n");
1084        let doc2 = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
1085        assert_eq!(doc2.headings.len(), doc.headings.len());
1086    }
1087}