Skip to main content

vissue_core/
ops.rs

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