Skip to main content

vissue_core/
store.rs

1//! The on-disk store: one `issues.org` per project, parsed and rewritten whole.
2
3use anyhow::{Context, anyhow};
4
5use crate::error::Result;
6use fs2::FileExt;
7use std::collections::{BTreeMap, HashMap};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex, OnceLock};
12use std::time::{SystemTime, UNIX_EPOCH};
13use xxhash_rust::xxh3::xxh3_64_with_seed;
14
15use crate::config::Layout;
16use crate::model::{IssueHeading, LogEntry, TODO_HEADER, parse_log_line, today_inactive_bracket};
17use crate::org::{
18    OrgScan, TagSettings, ensure_org_preamble, is_headline, is_issue_headline, is_planning_line,
19    is_top_level_headline, opens_a_drawer, parse_headline_bits, parse_planning_line,
20    property_key_and_append, split_statistics_cookies, tag_settings_from_preamble,
21    todo_keywords_from_lines,
22};
23
24/// Process-local mutex per path, so concurrent async handlers in one process
25/// serialize even where an advisory file lock would not (same process, many
26/// descriptors).
27static PROCESS_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
28static WRITE_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
29
30const ID_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
31
32struct CrossProcessLock {
33    file: fs::File,
34}
35
36impl CrossProcessLock {
37    fn acquire(path: &Path) -> Result<Self> {
38        let lock_path = issues_lock_path(path);
39        if let Some(parent) = lock_path.parent() {
40            fs::create_dir_all(parent)
41                .with_context(|| format!("create lock parent {}", parent.display()))?;
42        }
43        let file = fs::OpenOptions::new()
44            .create(true)
45            .read(true)
46            .append(true)
47            .open(&lock_path)
48            .with_context(|| format!("open lock {}", lock_path.display()))?;
49        file.lock_exclusive()
50            .with_context(|| format!("lock {}", lock_path.display()))?;
51        Ok(Self { file })
52    }
53}
54
55impl Drop for CrossProcessLock {
56    fn drop(&mut self) {
57        let _ = fs2::FileExt::unlock(&self.file);
58    }
59}
60
61/// Write `bytes` and flush them to the device, so the caller may rename the
62/// file knowing the contents are durable.
63fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
64    use std::io::Write as _;
65    let mut file = fs::File::create(path)?;
66    file.write_all(bytes)?;
67    file.sync_all()
68}
69
70/// Replace `path` with `body` through a flushed temporary and a rename.
71///
72/// For generated output a reader shares, which is a mirror: a plain write
73/// truncates first, so a reader mid-pull, or a crash, sees a half file where
74/// a whole one was.
75///
76/// # Errors
77///
78/// Returns an error if the parent directory cannot be created, the temporary
79/// cannot be written, or the rename cannot publish it.
80pub fn replace_file_atomically(path: &Path, body: &str) -> Result<()> {
81    let parent = path
82        .parent()
83        .filter(|p| !p.as_os_str().is_empty())
84        .map(Path::to_path_buf)
85        .unwrap_or_else(|| PathBuf::from("."));
86    fs::create_dir_all(&parent).with_context(|| format!("create {}", parent.display()))?;
87    let seq = WRITE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
88    let base = path
89        .file_name()
90        .and_then(|s| s.to_str())
91        .unwrap_or("output");
92    let tmp = parent.join(format!(".{}.tmp.{}-{}", base, std::process::id(), seq));
93    if let Err(e) = write_synced(&tmp, body.as_bytes()) {
94        let _ = fs::remove_file(&tmp);
95        return Err(e)
96            .with_context(|| format!("write temp {}", tmp.display()))
97            .map_err(crate::error::Error::from);
98    }
99    if let Err(e) = fs::rename(&tmp, path) {
100        let _ = fs::remove_file(&tmp);
101        return Err(e)
102            .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))
103            .map_err(crate::error::Error::from);
104    }
105    Ok(())
106}
107
108fn issues_lock_path(path: &Path) -> PathBuf {
109    let mut s = path.as_os_str().to_owned();
110    s.push(".lock");
111    PathBuf::from(s)
112}
113
114fn process_mutex_for(path: &Path) -> Arc<Mutex<()>> {
115    let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
116    let mut map = PROCESS_LOCKS
117        .get_or_init(|| Mutex::new(HashMap::new()))
118        .lock()
119        .unwrap_or_else(|p| p.into_inner());
120    map.entry(key)
121        .or_insert_with(|| Arc::new(Mutex::new(())))
122        .clone()
123}
124
125/// Serialize every read-modify-write cycle on one `issues.org`.
126///
127/// Without this, parallel creates race on the temporary file rename and lose
128/// updates: the last writer wins and peers see a vanished temporary.
129///
130/// # Errors
131///
132/// Returns an error if the lock file cannot be created or acquired, or if `f`
133/// itself fails.
134pub fn with_issues_lock<R, F>(path: &Path, f: F) -> Result<R>
135where
136    F: FnOnce() -> Result<R>,
137{
138    let mutex = process_mutex_for(path);
139    let _proc = mutex.lock().unwrap_or_else(|p| p.into_inner());
140    let _cross = CrossProcessLock::acquire(path)?;
141    f()
142}
143
144/// Lock several files in sorted order, which makes a cross-project move
145/// deadlock-free.
146///
147/// # Errors
148///
149/// Returns an error if a lock file cannot be created or acquired, or if `f`
150/// itself fails.
151pub fn with_issues_locks<R, F>(paths: &[&Path], f: F) -> Result<R>
152where
153    F: FnOnce() -> Result<R>,
154{
155    // Deduplicated by the file each path resolves to, not by how it was spelled.
156    //
157    // The process mutex is keyed on the canonical path, so two spellings of one
158    // file hand back the same non-reentrant mutex; locking it twice in this loop
159    // is a self-deadlock, and the second advisory lock on the same file from a
160    // second descriptor blocks as well. Two configured roots that are links to
161    // one tree, or the same root written two ways, are enough to reach it, and a
162    // mint now locks every twin file rather than one.
163    let mut seen: Vec<PathBuf> = Vec::new();
164    let mut keys: Vec<PathBuf> = Vec::new();
165    let mut ordered: Vec<PathBuf> = paths.iter().map(|p| (*p).to_path_buf()).collect();
166    ordered.sort();
167    for path in ordered {
168        let identity = path.canonicalize().unwrap_or_else(|_| path.clone());
169        if seen.contains(&identity) {
170            continue;
171        }
172        seen.push(identity);
173        keys.push(path);
174    }
175    let mutexes: Vec<Arc<Mutex<()>>> = keys.iter().map(|k| process_mutex_for(k)).collect();
176    let mut proc_guards = Vec::with_capacity(mutexes.len());
177    let mut cross_guards = Vec::with_capacity(keys.len());
178    for (key, mutex) in keys.iter().zip(mutexes.iter()) {
179        proc_guards.push(mutex.lock().unwrap_or_else(|p| p.into_inner()));
180        cross_guards.push(CrossProcessLock::acquire(key)?);
181    }
182    f()
183}
184
185/// One project's `issues.org`: a preamble followed by top-level headings.
186#[derive(Debug, Clone)]
187pub struct IssueDoc {
188    /// Project directory name this file belongs to.
189    pub project: String,
190    /// Path of the `issues.org` this document was parsed from or will write to.
191    pub path: PathBuf,
192    /// File header above the first heading, including `#+TODO:`.
193    pub preamble: String,
194    /// `#+FILETAGS:`, `#+TAGS:`, and export tag keywords from the preamble.
195    pub tag_settings: TagSettings,
196    /// Top-level issue headings, in file order.
197    pub headings: Vec<IssueHeading>,
198    /// Org that follows each issue (COMMENT trees, notes headings). Same
199    /// length as [`Self::headings`] after a parse; a write pads missing
200    /// slots with the usual blank line.
201    after: Vec<String>,
202}
203
204impl IssueDoc {
205    /// An empty document with the house preamble and no headings.
206    pub fn empty(project: &str, path: PathBuf) -> Self {
207        let preamble = default_preamble(project);
208        IssueDoc {
209            project: project.to_string(),
210            path,
211            tag_settings: tag_settings_from_preamble(&preamble),
212            preamble,
213            headings: Vec::new(),
214            after: Vec::new(),
215        }
216    }
217
218    /// Parse `path`, or produce an empty document when the file is absent.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the file exists but cannot be read or parsed.
223    pub fn parse_file(project: &str, path: &Path) -> Result<Self> {
224        if !path.exists() {
225            return Ok(Self::empty(project, path.to_path_buf()));
226        }
227        let content =
228            fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
229        Self::parse(project, path.to_path_buf(), &content)
230    }
231
232    /// Parse `content` as an `issues.org` for `project`.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if an issue heading has no `:ID:`. A heading
237    /// whose first word is not a TODO keyword is Org around the issues,
238    /// not a failed parse.
239    pub fn parse(project: &str, path: PathBuf, content: &str) -> Result<Self> {
240        let lines: Vec<&str> = content.lines().collect();
241        let preamble_end = {
242            let mut nest = OrgScan::new();
243            lines
244                .iter()
245                .position(|line| {
246                    if nest.observe(line) {
247                        return false;
248                    }
249                    is_headline(line)
250                })
251                .unwrap_or(lines.len())
252        };
253        let raw_preamble = if preamble_end == 0 {
254            String::new()
255        } else {
256            lines[..preamble_end].join("\n").trim_end().to_string()
257        };
258        let settings = crate::org::merge_setupfile_settings(&raw_preamble, path.parent());
259        let mut keyword_src = settings.clone();
260        keyword_src.push('\n');
261        keyword_src.push_str(content);
262        let keyword_lines: Vec<&str> = keyword_src.lines().collect();
263        let keywords = todo_keywords_from_lines(&keyword_lines);
264        let mut nest = OrgScan::new();
265        let first_heading = lines
266            .iter()
267            .enumerate()
268            .position(|(i, line)| {
269                if nest.observe(line) {
270                    return false;
271                }
272                is_vissue_headline(&lines, i, &keywords)
273            })
274            .unwrap_or(lines.len());
275        let preamble = if first_heading == 0 {
276            default_preamble(project)
277        } else {
278            lines[..first_heading].join("\n").trim_end().to_string()
279        };
280        let mut headings = Vec::new();
281        let mut after = Vec::new();
282        let mut i = first_heading;
283        while i < lines.len() {
284            if !is_vissue_headline(&lines, i, &keywords) {
285                i += 1;
286                continue;
287            }
288            let (heading, body_end) = parse_heading(
289                &lines,
290                i,
291                &keywords,
292                crate::org::priorities_from_preamble(&settings).default,
293            )
294            .with_context(|| format!("at {}:{}", path.display(), i + 1))?;
295            headings.push(heading);
296            i = body_end;
297            let inter_start = i;
298            let mut nest = OrgScan::new();
299            while i < lines.len() {
300                if !nest.observe(lines[i]) && is_vissue_headline(&lines, i, &keywords) {
301                    break;
302                }
303                i += 1;
304            }
305            let raw = lines[inter_start..i].join("\n");
306            after.push(if raw.trim().is_empty() {
307                String::new()
308            } else {
309                raw.trim_start_matches('\n').to_string()
310            });
311        }
312        let parent = path.parent().map(std::path::Path::to_path_buf);
313        Ok(IssueDoc {
314            project: project.to_string(),
315            path,
316            tag_settings: tag_settings_from_preamble(&crate::org::merge_setupfile_settings(
317                &preamble,
318                parent.as_deref(),
319            )),
320            preamble,
321            headings,
322            after,
323        })
324    }
325
326    /// The file body this document would write.
327    pub fn render_string(&self) -> String {
328        let mut out = String::new();
329        let preamble = if self.preamble.trim().is_empty() {
330            default_preamble(&self.project)
331        } else {
332            ensure_org_preamble(&self.preamble, &self.project)
333        };
334        out.push_str(preamble.trim_end());
335        out.push_str("\n\n");
336        for (i, h) in self.headings.iter().enumerate() {
337            out.push_str(&h.render());
338            out.push('\n');
339            if let Some(extra) = self.after.get(i)
340                && !extra.is_empty()
341            {
342                out.push_str(extra);
343                if !extra.ends_with('\n') {
344                    out.push('\n');
345                }
346            }
347        }
348        out
349    }
350
351    /// Render and replace the file through a uniquely named temporary. Callers
352    /// hold [`with_issues_lock`] around the parse and this write.
353    ///
354    /// # Errors
355    ///
356    /// Returns an error if the parent directory cannot be created, the
357    /// temporary cannot be written, or the rename cannot publish it.
358    pub fn write(&self) -> Result<()> {
359        if let Some(parent) = self.path.parent() {
360            fs::create_dir_all(parent)?;
361        }
362        let out = self.render_string();
363        // A shared temporary name races: a peer renames it out from under this
364        // writer and the rename fails with ENOENT.
365        let seq = WRITE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
366        let nanos = SystemTime::now()
367            .duration_since(UNIX_EPOCH)
368            .map(|d| d.as_nanos())
369            .unwrap_or(0);
370        let base = self
371            .path
372            .file_name()
373            .and_then(|s| s.to_str())
374            .unwrap_or("issues.org");
375        let tmp = self
376            .path
377            .parent()
378            .unwrap_or_else(|| Path::new("."))
379            .join(format!(
380                ".{}.tmp.{}-{}-{}",
381                base,
382                std::process::id(),
383                nanos,
384                seq
385            ));
386        // Flush the bytes to the device before the rename publishes them.
387        // Rename is atomic against a concurrent reader, not against a crash:
388        // an unsynced temporary can land as a truncated issues.org.
389        if let Err(e) = write_synced(&tmp, out.as_bytes()) {
390            let _ = fs::remove_file(&tmp);
391            return Err(e)
392                .with_context(|| format!("write temp {}", tmp.display()))
393                .map_err(crate::error::Error::from);
394        }
395        if let Err(e) = fs::rename(&tmp, &self.path) {
396            let _ = fs::remove_file(&tmp);
397            return Err(e)
398                .with_context(|| format!("rename {} -> {}", tmp.display(), self.path.display()))
399                .map_err(crate::error::Error::from);
400        }
401        self.announce_write();
402        Ok(())
403    }
404
405    /// Tell pollers the file moved. The event files live beside the project
406    /// directories, which is this file's grandparent. Failure here is not a
407    /// failed write: the issue is already on disk.
408    fn announce_write(&self) {
409        if !crate::events::enabled() {
410            return;
411        }
412        let Some(dir) = self.path.parent().and_then(|p| p.parent()) else {
413            return;
414        };
415        let _ = crate::events::emit_issues_write(dir, &self.project, &self.path);
416        let _ = crate::events::ensure_gitignore_hint(dir);
417    }
418
419    /// `#+PRIORITIES:` from this file and any local `#+SETUPFILE:`.
420    pub fn priority_spec(&self) -> crate::org::PrioritySpec {
421        crate::org::priorities_from_preamble(&self.merged_inbuffer_settings())
422    }
423
424    /// Whether the file or a local setupfile names `#+PRIORITIES:`.
425    pub fn priorities_are_named(&self) -> bool {
426        crate::org::preamble_has_keyword(&self.merged_inbuffer_settings(), "PRIORITIES")
427    }
428
429    /// Cookie for a new heading: the file default when named, else `cfg_default`.
430    pub fn default_create_priority(&self, cfg_default: char) -> char {
431        if self.priorities_are_named() {
432            self.priority_spec().default
433        } else {
434            cfg_default
435        }
436    }
437
438    fn merged_inbuffer_settings(&self) -> String {
439        crate::org::merge_setupfile_settings(&self.preamble, self.path.parent())
440    }
441
442    /// Every heading id in this document, in file order.
443    pub fn known_ids(&self) -> Vec<String> {
444        self.headings.iter().map(|h| h.id.clone()).collect()
445    }
446
447    /// Replace a heading with the same id, or append if none matches.
448    pub fn upsert(&mut self, heading: IssueHeading) {
449        if let Some(slot) = self.headings.iter_mut().find(|h| h.id == heading.id) {
450            *slot = heading;
451        } else {
452            self.headings.push(heading);
453            self.after.push(String::new());
454        }
455    }
456
457    /// Remove the heading with `id`, if it is in this document.
458    pub fn remove(&mut self, id: &str) -> Option<IssueHeading> {
459        let idx = self.headings.iter().position(|h| h.id == id)?;
460        let heading = self.headings.remove(idx);
461        let extra = if idx < self.after.len() {
462            self.after.remove(idx)
463        } else {
464            String::new()
465        };
466        if !extra.trim().is_empty() {
467            if idx == 0 {
468                if !self.preamble.is_empty() && !self.preamble.ends_with('\n') {
469                    self.preamble.push('\n');
470                }
471                if !self.preamble.is_empty() {
472                    self.preamble.push('\n');
473                }
474                self.preamble.push_str(&extra);
475            } else if let Some(prev) = self.after.get_mut(idx - 1) {
476                if !prev.is_empty() && !prev.ends_with('\n') {
477                    prev.push('\n');
478                }
479                prev.push_str(&extra);
480            }
481        }
482        Some(heading)
483    }
484}
485
486fn is_vissue_headline(lines: &[&str], i: usize, keywords: &[String]) -> bool {
487    if !is_issue_headline(lines[i], keywords) {
488        return false;
489    }
490    peek_heading_id(lines, i).is_none_or(|id| !crate::org::is_gcal_event_id(&id))
491}
492
493fn peek_heading_id(lines: &[&str], start: usize) -> Option<String> {
494    let mut i = start + 1;
495    while i < lines.len() && !parse_planning_line(lines[i]).is_empty() {
496        i += 1;
497    }
498    while i < lines.len() {
499        let trimmed = lines[i].trim();
500        if trimmed.is_empty() {
501            i += 1;
502            continue;
503        }
504        if !opens_a_drawer(trimmed) {
505            break;
506        }
507        if trimmed.eq_ignore_ascii_case(":PROPERTIES:") {
508            i += 1;
509            while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
510                let line = lines[i].trim();
511                if let Some(rest) = line.strip_prefix(':')
512                    && let Some((key, value)) = rest.split_once(':')
513                {
514                    let (key, _) = property_key_and_append(key.trim());
515                    if key.eq_ignore_ascii_case("ID") {
516                        let value = value.trim();
517                        if !value.is_empty() {
518                            return Some(value.to_string());
519                        }
520                    }
521                }
522                i += 1;
523            }
524            return None;
525        }
526        i += 1;
527        while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
528            i += 1;
529        }
530        if i < lines.len() {
531            i += 1;
532        }
533    }
534    None
535}
536
537fn parse_heading(
538    lines: &[&str],
539    start: usize,
540    keywords: &[String],
541    default_priority: char,
542) -> Result<(IssueHeading, usize)> {
543    let header = lines[start];
544    let stripped = header
545        .strip_prefix("* ")
546        .ok_or_else(|| anyhow!("not a heading"))?;
547    let bits = parse_headline_bits(stripped, keywords);
548    let state = bits
549        .keyword
550        .ok_or_else(|| anyhow!("not an issue heading"))?
551        .to_string();
552    let priority = bits.priority.unwrap_or(default_priority);
553    let (title_and_cookies, org_tags) = crate::model::split_headline_tags(bits.rest);
554    let (title, statistics) = split_statistics_cookies(&title_and_cookies);
555
556    let mut properties = BTreeMap::new();
557    let mut property_order = Vec::new();
558    let mut logbook: Vec<LogEntry> = Vec::new();
559    let mut extra_drawers: Vec<String> = Vec::new();
560    let mut i = start + 1;
561
562    // Org writes DEADLINE, SCHEDULED, and CLOSED on a planning line between
563    // the heading and the drawer. Several planning lines are legal; a blank
564    // or a keyword does not end the drawer site (manual 2.7, 8.1).
565    while i < lines.len() {
566        let found = parse_planning_line(lines[i]);
567        if found.is_empty() {
568            break;
569        }
570        for (key, value) in found {
571            if !property_order.contains(&key) {
572                property_order.push(key.clone());
573            }
574            properties.insert(key, value);
575        }
576        i += 1;
577    }
578
579    while i < lines.len() {
580        let trimmed = lines[i].trim();
581        if trimmed.is_empty() {
582            i += 1;
583            continue;
584        }
585        if !opens_a_drawer(trimmed) {
586            break;
587        }
588        if trimmed.eq_ignore_ascii_case(":PROPERTIES:") {
589            i += 1;
590            while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
591                let line = lines[i].trim();
592                if let Some(rest) = line.strip_prefix(':')
593                    && let Some(idx) = rest.find(':')
594                {
595                    let raw_key = &rest[..idx];
596                    let (key, append) = property_key_and_append(raw_key);
597                    let val = rest[idx + 1..].trim().to_string();
598                    if append {
599                        properties
600                            .entry(key.to_string())
601                            .and_modify(|existing| {
602                                if !val.is_empty() {
603                                    if !existing.is_empty() {
604                                        existing.push(' ');
605                                    }
606                                    existing.push_str(&val);
607                                }
608                            })
609                            .or_insert(val);
610                    } else {
611                        properties.insert(key.to_string(), val);
612                    }
613                    if !property_order.iter().any(|k| k == key) {
614                        property_order.push(key.to_string());
615                    }
616                }
617                i += 1;
618            }
619            if i < lines.len() {
620                i += 1;
621            }
622            continue;
623        }
624        if trimmed.eq_ignore_ascii_case(":LOGBOOK:") {
625            i += 1;
626            while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
627                if !lines[i].trim().is_empty() {
628                    logbook.push(parse_log_line(lines[i]));
629                }
630                i += 1;
631            }
632            if i < lines.len() {
633                i += 1;
634            }
635            continue;
636        }
637        let drawer_start = i;
638        i += 1;
639        while i < lines.len() && !lines[i].trim().eq_ignore_ascii_case(":END:") {
640            i += 1;
641        }
642        if i < lines.len() {
643            i += 1;
644        }
645        let mut drawer = lines[drawer_start..i].join("\n");
646        drawer.push('\n');
647        extra_drawers.push(drawer);
648    }
649
650    let body_start = i;
651    let mut body_end = body_start;
652    let mut nest = OrgScan::new();
653    while body_end < lines.len() {
654        if !nest.observe(lines[body_end]) && is_top_level_headline(lines[body_end]) {
655            break;
656        }
657        body_end += 1;
658    }
659    let body = lines[body_start..body_end]
660        .join("\n")
661        .trim_matches('\n')
662        .trim_end()
663        .to_string();
664
665    // Carry a drawer written under the old name forward, so an existing
666    // tracker reads the same and the next rewrite settles on the name Org
667    // does not reserve.
668    if let Some(legacy) = properties.remove(crate::model::LEGACY_TAGS_PROPERTY) {
669        property_order.retain(|key| key != crate::model::LEGACY_TAGS_PROPERTY);
670        properties
671            .entry(crate::model::TAGS_PROPERTY.to_string())
672            .or_insert(legacy);
673    }
674
675    let id = properties
676        .get("ID")
677        .cloned()
678        .ok_or_else(|| anyhow!(":ID: property missing"))?;
679
680    Ok((
681        IssueHeading {
682            id,
683            title,
684            state,
685            priority,
686            properties,
687            org_tags,
688            statistics,
689            property_order,
690            extra_drawers,
691            body,
692            logbook,
693            line_start: start + 1,
694            line_end: if body_end == 0 { 1 } else { body_end },
695        },
696        body_end,
697    ))
698}
699
700/// The header a fresh project file gets.
701///
702/// `#+CATEGORY:` names the project, because Org otherwise takes the category
703/// from the file name and every project's file is `issues.org`: an agenda
704/// spanning several projects would label every row `issues`.
705pub fn default_preamble(project: &str) -> String {
706    format!(
707        "#+TITLE: {project} issues\n#+VISSUE: {}\n#+CATEGORY: {project}\n#+FILETAGS: :issues:{project}:noexport:\n{}\n{}\n{}\n#+EXCLUDE_TAGS: noexport\n#+SELECT_TAGS: export\n#+DATE: {}\n#+DESCRIPTION: Issue tracking file for {project} specs, plans, and implementation tasks.\n#+STATUS: Active\n{}",
708        crate::org::PROTOCOL_VERSION,
709        crate::org::HOUSE_TAGS_LINES[0],
710        crate::org::HOUSE_TAGS_LINES[1],
711        crate::org::HOUSE_PRIORITIES_LINE,
712        today_inactive_bracket(),
713        TODO_HEADER
714    )
715}
716
717/// Every project directory under the layout prefix that holds an `issues.org`.
718///
719/// # Errors
720///
721/// Returns an error if the projects directory exists but cannot be read.
722pub fn list_projects(layout: &Layout) -> Result<Vec<String>> {
723    let dir = layout.projects_dir();
724    if !dir.exists() {
725        return Ok(Vec::new());
726    }
727    let mut projects = Vec::new();
728    for entry in fs::read_dir(&dir).with_context(|| format!("read dir {}", dir.display()))? {
729        let entry = entry?;
730        let path = entry.path();
731        if path.is_dir()
732            && path.join("issues.org").exists()
733            && let Some(name) = path.file_name().and_then(|n| n.to_str())
734        {
735            projects.push(name.to_string());
736        }
737    }
738    projects.sort();
739    Ok(projects)
740}
741
742/// Map a project name onto the directory that already exists, ignoring case.
743/// An unmatched name is returned unchanged so `create` can make it.
744///
745/// # Errors
746///
747/// Returns an error if more than one project directory matches ignoring case.
748pub fn resolve_existing_project_case(layout: &Layout, project: &str) -> Result<String> {
749    if project.is_empty() {
750        return Ok(project.to_string());
751    }
752    if layout.project_issues_path(project).exists() {
753        return Ok(project.to_string());
754    }
755    let project_lower = project.to_lowercase();
756    let matches: Vec<String> = list_projects(layout)?
757        .into_iter()
758        .filter(|candidate| candidate.to_lowercase() == project_lower)
759        .collect();
760    match matches.as_slice() {
761        [] => Ok(project.to_string()),
762        [canonical] => Ok(canonical.clone()),
763        _ => Err(anyhow!(
764            "project {project:?} is ambiguous; case-insensitive matches: {}",
765            matches.join(", ")
766        )
767        .into()),
768    }
769}
770
771/// Whether a project belongs to a `--project` selection.
772///
773/// Case folds, because the directory on disk is what names a project and
774/// [`resolve_existing_project_case`] already folds case for every verb that
775/// writes. A query that dropped `-p Atlas` on a tracker holding `atlas` would
776/// answer "no issues" to a question that has issues.
777pub fn project_selected(project: &str, filter: Option<&str>) -> bool {
778    match filter {
779        None => true,
780        Some(p) => project.eq_ignore_ascii_case(p),
781    }
782}
783
784/// Locate one issue by id across every project.
785///
786/// # Errors
787///
788/// Returns an error if a project file cannot be read or parsed.
789pub fn find_by_id(layout: &Layout, id: &str) -> Result<Option<(IssueHeading, PathBuf, String)>> {
790    for project in list_projects(layout)? {
791        let path = layout.project_issues_path(&project);
792        let doc = IssueDoc::parse_file(&project, &path)?;
793        for h in doc.headings {
794            if h.id == id {
795                return Ok(Some((h, path, project)));
796            }
797        }
798    }
799    Ok(None)
800}
801
802/// Snapshot every heading across every project, tagged with its project.
803///
804/// # Errors
805///
806/// Returns an error if a project file cannot be read or parsed.
807pub fn load_all(layout: &Layout) -> Result<Vec<(String, IssueHeading)>> {
808    let mut all = Vec::new();
809    for project in list_projects(layout)? {
810        let path = layout.project_issues_path(&project);
811        let doc = IssueDoc::parse_file(&project, &path)?;
812        for h in doc.headings {
813            all.push((project.clone(), h));
814        }
815    }
816    Ok(all)
817}
818
819/// `<project>-<base36 suffix>`, retried until it does not collide.
820///
821/// Fails rather than looping forever when the suffix space is full. That is
822/// reachable, not hypothetical: `id_length = 2` is 1296 suffixes, so a
823/// project can outgrow it, and the answer is a longer id rather than a
824/// crash inside a write.
825///
826/// # Errors
827///
828/// Returns an error if every suffix of `length` is already taken.
829/// The seed the id probes are drawn from.
830///
831/// The clock by default, so ids do not repeat between runs. `VISSUE_ID_SEED`
832/// overrides it, which makes a minting sequence reproducible.
833///
834/// That override is not only a convenience for tests, it is what lets a test have
835/// any power over the reservation. With a clock seed two racing creates draw from
836/// 36^4 suffixes and never collide by luck, so a test for a stale reservation
837/// passes whether or not the bug is there. Pin the seed and both racers probe the
838/// same suffix first, which turns that race into a certainty.
839fn id_seed() -> u64 {
840    if let Ok(raw) = crate::process_env::var(ID_SEED_ENV)
841        && let Ok(seed) = raw.trim().parse::<u64>()
842    {
843        return seed;
844    }
845    SystemTime::now()
846        .duration_since(UNIX_EPOCH)
847        .map(|d| d.as_nanos() as u64)
848        .unwrap_or(1)
849}
850
851/// Environment variable that pins the id seed.
852pub const ID_SEED_ENV: &str = "VISSUE_ID_SEED";
853
854/// A free `project-suffix` id, or an error when the space is full.
855///
856/// Minting is deterministic in its inputs: the starting suffix is xxh3 over the
857/// project and `subject` keyed by [`id_seed`], and each further probe steps one
858/// along the base-36 space from there. The same project, subject and seed
859/// therefore ask for the same id every time, which makes a mint reproducible and
860/// replayable instead of a function of what nanosecond it ran in.
861///
862/// It also stops two agents contending for one suffix in the ordinary case. Two
863/// creates with different subjects start from different points, so neither has to
864/// see the other's write to avoid it, and the reservation is what settles the case
865/// where two agents genuinely ask for the same subject at once: the first takes
866/// the shared start and the second walks one along.
867///
868/// Hashed start, systematic walk, and both halves are load-bearing.
869///
870/// The hash replaces one multiply by Knuth's constant over a nanosecond clock,
871/// whose base-36 digits came off the low bits and whose next probe sat 17 away
872/// from the last: successive probes correlated, and two processes reading the
873/// same coarse clock walked the same short arithmetic sequence. This crate
874/// already depends on xxh3 for the export digest, so the better start costs no
875/// new dependency.
876///
877/// The walk is what the hash cannot replace. Hashing each probe independently
878/// draws with replacement, so it can miss a free suffix that exists: with 1295
879/// of 1296 taken, 2592 independent draws find the survivor about six times in
880/// seven, and a test that had to find it failed one run in seven. Stepping by one
881/// covers every suffix once, so a free one is found whenever a free one is there.
882///
883/// # Errors
884///
885/// Returns an error when every suffix of that length is taken, naming the config
886/// key that widens it.
887pub fn generate_id(
888    project: &str,
889    subject: &str,
890    existing: &[String],
891    length: usize,
892) -> Result<String> {
893    let len = length.max(2);
894    let taken: std::collections::HashSet<&str> = existing.iter().map(String::as_str).collect();
895    // Project and subject both, separated by a byte neither can contain, so the
896    // two fields cannot run together into the same material. The project is in
897    // there so one pinned seed does not hand every project the same start; the
898    // subject is what makes distinct issues start apart.
899    let mut material = Vec::with_capacity(project.len() + subject.len() + 1);
900    material.extend_from_slice(project.as_bytes());
901    material.push(0);
902    material.extend_from_slice(subject.as_bytes());
903    let start = xxh3_64_with_seed(&material, id_seed());
904    // Bounded by the size of the space, so a full space is reported instead
905    // of spun on. 36^len saturates well before it could overflow.
906    let attempts = 36usize
907        .checked_pow(len as u32)
908        .map(|space| space.saturating_mul(2))
909        .unwrap_or(usize::MAX)
910        .min(2_000_000);
911    for counter in 0..attempts as u64 {
912        let mut n = start.wrapping_add(counter);
913        let mut suffix = String::new();
914        for _ in 0..len {
915            suffix.push(ID_ALPHABET[(n % 36) as usize] as char);
916            n /= 36;
917        }
918        let id = format!("{}-{}", project, suffix);
919        if !taken.contains(id.as_str()) {
920            return Ok(id);
921        }
922    }
923    Err(anyhow!(
924        "no free id left for {project:?} at id_length = {len}; \
925         raise `id_length` under [issues] in vissue.toml"
926    )
927    .into())
928}
929
930/// Walk up from `start` for a `.project-ctx.toml` and read `[project].name`.
931pub fn detect_project_from_ctx(start: &Path) -> Option<String> {
932    let mut dir = start.canonicalize().ok()?;
933    loop {
934        let candidate = dir.join(".project-ctx.toml");
935        if candidate.exists()
936            && let Ok(text) = fs::read_to_string(&candidate)
937            && let Ok(value) = text.parse::<toml::Value>()
938            && let Some(name) = value
939                .get("project")
940                .and_then(|p| p.get("name"))
941                .and_then(|n| n.as_str())
942        {
943            return Some(name.to_string());
944        }
945        if !dir.pop() {
946            break;
947        }
948    }
949    None
950}
951
952/// Look for `wanted` among every `:ID:` under the layout prefix, including
953/// design documents and notes, and stop once every requested id has been
954/// seen. Returns the subset that exists.
955///
956/// `check` uses this because a `:PARENT:` may point at a note rather than
957/// at another issue.
958///
959/// # Errors
960///
961/// Returns an error if an org file under the prefix cannot be read.
962pub fn find_org_ids(
963    layout: &Layout,
964    wanted: &std::collections::HashSet<String>,
965) -> Result<std::collections::HashSet<String>> {
966    let mut found = std::collections::HashSet::new();
967    if wanted.is_empty() {
968        return Ok(found);
969    }
970    let dir = layout.projects_dir();
971    if !dir.exists() {
972        return Ok(found);
973    }
974    for entry in walkdir::WalkDir::new(&dir)
975        .into_iter()
976        .filter_entry(|e| !is_skipped_dir(e))
977    {
978        let entry = match entry {
979            Ok(e) => e,
980            Err(_) => continue,
981        };
982        if !entry.file_type().is_file()
983            || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
984        {
985            continue;
986        }
987        let content = fs::read_to_string(entry.path())
988            .with_context(|| format!("read {}", entry.path().display()))?;
989        for id in org_ids(&content) {
990            if crate::org::is_gcal_event_id(id) {
991                continue;
992            }
993            if wanted.contains(id) {
994                found.insert(id.to_string());
995                if found.len() == wanted.len() {
996                    return Ok(found);
997                }
998            }
999        }
1000    }
1001    Ok(found)
1002}
1003
1004/// Every `:ID:` value in any org file under the layout prefix.
1005///
1006/// # Errors
1007///
1008/// Returns an error if an org file under the prefix cannot be read.
1009pub fn collect_org_ids(layout: &Layout) -> Result<std::collections::HashSet<String>> {
1010    let mut ids = std::collections::HashSet::new();
1011    let dir = layout.projects_dir();
1012    if !dir.exists() {
1013        return Ok(ids);
1014    }
1015    for entry in walkdir::WalkDir::new(&dir)
1016        .into_iter()
1017        .filter_entry(|e| !is_skipped_dir(e))
1018    {
1019        let entry = match entry {
1020            Ok(e) => e,
1021            Err(_) => continue,
1022        };
1023        if !entry.file_type().is_file()
1024            || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
1025        {
1026            continue;
1027        }
1028        let content = fs::read_to_string(entry.path())
1029            .with_context(|| format!("read {}", entry.path().display()))?;
1030        for id in org_ids(&content) {
1031            if !crate::org::is_gcal_event_id(id) {
1032                ids.insert(id.to_string());
1033            }
1034        }
1035    }
1036    Ok(ids)
1037}
1038
1039fn is_skipped_dir(entry: &walkdir::DirEntry) -> bool {
1040    if !entry.file_type().is_dir() {
1041        return false;
1042    }
1043    let name = entry.file_name().to_string_lossy();
1044    matches!(
1045        name.as_ref(),
1046        "node_modules" | "target" | ".git" | ".cache" | "build"
1047    )
1048}
1049
1050fn org_id_property_value(line: &str) -> Option<&str> {
1051    let value = line.trim_start().strip_prefix(":ID:")?.trim();
1052    if value.is_empty() { None } else { Some(value) }
1053}
1054
1055/// The ids a file defines, which are the ones org would read.
1056///
1057/// A property drawer counts where org lets one start: under a headline, under
1058/// that headline's planning line, or beside the other drawers clustered
1059/// there. A `:PROPERTIES:` block further down the entry is an ordinary drawer
1060/// and the `:ID:` inside it is prose.
1061///
1062/// The distinction is the difference between a working `check` and a silent
1063/// one. Agents write their reports into issue bodies, those reports quote org,
1064/// and taking every `:ID:` line makes quoted text define an id: a `:PARENT:`
1065/// pointing at nothing resolves against a report that merely mentions it.
1066pub(crate) fn org_ids(content: &str) -> impl Iterator<Item = &str> {
1067    // The top of a file is a drawer site: org reads a file-level drawer there.
1068    let mut at_drawer_site = true;
1069    let mut in_drawer = false;
1070    let mut drawer_is_properties = false;
1071    // Org takes a planning line on the line under the headline and nowhere
1072    // else, so prose opening on `DEADLINE:` further down is prose.
1073    let mut under_headline = false;
1074    let mut nest = OrgScan::new();
1075
1076    content.lines().filter_map(move |line| {
1077        let trimmed = line.trim();
1078
1079        if in_drawer {
1080            if trimmed.eq_ignore_ascii_case(":END:") {
1081                in_drawer = false;
1082                drawer_is_properties = false;
1083                // The drawers under a headline sit together, so the next one
1084                // is still in a place org reads.
1085                at_drawer_site = true;
1086                return None;
1087            }
1088            if drawer_is_properties {
1089                return org_id_property_value(line);
1090            }
1091            return None;
1092        }
1093
1094        // Greater blocks and Babel results are literal. A quoted headline
1095        // or a #+RESULTS: payload does not define an id (manual 2.8, 16).
1096        if nest.observe(line) {
1097            at_drawer_site = false;
1098            under_headline = false;
1099            return None;
1100        }
1101
1102        if is_headline(line) {
1103            at_drawer_site = true;
1104            under_headline = true;
1105            return None;
1106        }
1107        let planning_may_start_here = under_headline;
1108        under_headline = false;
1109
1110        // Neither a blank line nor a keyword is content, so neither one moves
1111        // the entry past the place its drawers live.
1112        if trimmed.is_empty() || trimmed.starts_with("#+") {
1113            return None;
1114        }
1115        if at_drawer_site {
1116            if opens_a_drawer(trimmed) {
1117                in_drawer = true;
1118                drawer_is_properties = trimmed.eq_ignore_ascii_case(":PROPERTIES:");
1119                return None;
1120            }
1121            if planning_may_start_here && is_planning_line(trimmed) {
1122                return None;
1123            }
1124        }
1125        // Body text: everything below it is the body.
1126        at_drawer_site = false;
1127        None
1128    })
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133    use super::*;
1134    use crate::config::DEFAULT_PREFIX;
1135
1136    fn sample_heading() -> IssueHeading {
1137        let mut props = BTreeMap::new();
1138        props.insert("ID".into(), "sample-abc1".into());
1139        props.insert("CREATED".into(), "[2026-04-25 Sat]".into());
1140        props.insert("TYPE".into(), "feature".into());
1141        IssueHeading {
1142            id: "sample-abc1".into(),
1143            title: "Add a thing".into(),
1144            state: "TODO".into(),
1145            priority: 'A',
1146            properties: props,
1147            org_tags: Vec::new(),
1148            statistics: None,
1149            property_order: vec!["ID".into(), "CREATED".into(), "TYPE".into()],
1150            extra_drawers: Vec::new(),
1151            body: "Some body lines.\nWith multiple lines.".into(),
1152            logbook: Vec::new(),
1153            line_start: 4,
1154            line_end: 12,
1155        }
1156    }
1157
1158    #[test]
1159    fn render_then_parse_preserves_the_heading() {
1160        let mut content = String::from("#+TITLE: sample issues\n");
1161        content.push_str(TODO_HEADER);
1162        content.push_str("\n\n");
1163        content.push_str(&sample_heading().render());
1164        let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
1165        let h = &parsed.headings[0];
1166        let original = sample_heading();
1167        assert_eq!(h.id, original.id);
1168        assert_eq!(h.title, original.title);
1169        assert_eq!(h.state, original.state);
1170        assert_eq!(h.priority, original.priority);
1171        assert_eq!(h.body, original.body);
1172        assert_eq!(
1173            crate::props::get(&h.properties, crate::props::TYPE),
1174            crate::props::get(&original.properties, crate::props::TYPE)
1175        );
1176    }
1177
1178    #[test]
1179    fn heading_without_a_priority_cookie_defaults_to_c() {
1180        let content =
1181            "#+TITLE: x issues\n\n* TODO Just a title\n:PROPERTIES:\n:ID:         x-aaaa\n:END:\n";
1182        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1183        assert_eq!(doc.headings[0].priority, 'C');
1184        assert_eq!(doc.headings[0].title, "Just a title");
1185    }
1186
1187    #[test]
1188    fn a_multibyte_priority_cookie_parses_instead_of_panicking() {
1189        let content = "#+TITLE: x issues\n\n* TODO [#\u{2192}] Hand edited\n:PROPERTIES:\n:ID:         x-aaaa\n:END:\n";
1190        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1191        assert_eq!(doc.headings[0].priority, '\u{2192}');
1192        assert_eq!(doc.headings[0].title, "Hand edited");
1193    }
1194
1195    #[test]
1196    fn a_title_opening_with_a_bracket_keeps_its_text() {
1197        let content = "#+TITLE: x issues\n\n* TODO [#not a cookie] stays\n:PROPERTIES:\n:ID:         x-bbbb\n:END:\n";
1198        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1199        assert_eq!(doc.headings[0].priority, 'C');
1200        assert_eq!(doc.headings[0].title, "[#not a cookie] stays");
1201    }
1202
1203    #[test]
1204    fn an_org_planning_line_parses_instead_of_hiding_the_drawer() {
1205        // What `C-c C-d`, `C-c C-s`, and `org-log-done` write in Emacs. Before
1206        // the planning line was read, the drawer below it went unseen and the
1207        // whole file failed with ":ID: property missing".
1208        let content = "#+TITLE: x issues\n\n* DONE [#A] Ship it\nCLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-05 Sat> DEADLINE: <2026-09-01 Tue>\n:PROPERTIES:\n:ID:         x-aaaa\n:END:\n\nBody stays body.\n";
1209        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1210        let h = &doc.headings[0];
1211        assert_eq!(h.id, "x-aaaa");
1212        assert_eq!(h.deadline(), Some("<2026-09-01 Tue>"));
1213        assert_eq!(h.scheduled(), Some("<2026-09-05 Sat>"));
1214        assert_eq!(
1215            h.properties.get("CLOSED").map(String::as_str),
1216            Some("[2026-08-14 Fri 03:33]")
1217        );
1218        assert_eq!(h.body, "Body stays body.");
1219    }
1220
1221    #[test]
1222    fn a_planning_line_round_trips_in_orgs_own_order() {
1223        let content = "#+TITLE: x issues\n\n* DONE [#A] Ship it\nCLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-05 Sat> DEADLINE: <2026-09-01 Tue>\n:PROPERTIES:\n:ID:         x-aaaa\n:END:\n";
1224        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1225        let rendered = doc.headings[0].render();
1226        assert!(
1227            rendered.contains(
1228                "\nCLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-05 Sat> DEADLINE: <2026-09-01 Tue>\n"
1229            ),
1230            "{rendered}"
1231        );
1232        assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
1233    }
1234
1235    #[test]
1236    fn a_legacy_date_property_is_promoted_to_a_planning_line() {
1237        // Trackers written before dates moved out of the drawer still parse,
1238        // and the next rewrite puts them where Org's agenda reads them.
1239        let content = "#+TITLE: x issues\n\n* TODO [#A] Ship it\n:PROPERTIES:\n:ID:         x-aaaa\n:DEADLINE:   <2026-09-01 Tue>\n:END:\n";
1240        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1241        assert_eq!(doc.headings[0].deadline(), Some("<2026-09-01 Tue>"));
1242        let rendered = doc.headings[0].render();
1243        assert!(
1244            rendered.contains("\nDEADLINE: <2026-09-01 Tue>\n"),
1245            "{rendered}"
1246        );
1247        assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
1248    }
1249
1250    #[test]
1251    fn a_line_that_only_looks_like_planning_is_left_as_body() {
1252        let content = "#+TITLE: x issues\n\n* TODO [#A] Ship it\n:PROPERTIES:\n:ID:         x-aaaa\n:END:\n\nDEADLINE: is discussed in the design note.\n";
1253        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1254        assert_eq!(doc.headings[0].deadline(), None);
1255        assert_eq!(
1256            doc.headings[0].body,
1257            "DEADLINE: is discussed in the design note."
1258        );
1259    }
1260
1261    #[test]
1262    fn a_legacy_tags_property_moves_to_the_name_org_leaves_alone() {
1263        // `TAGS` is one of Org's own special property names, so a drawer that
1264        // claims it is what `org-lint` reports. Existing trackers still read.
1265        let content = "#+TITLE: x issues\n\n* TODO [#A] Ship it\n:PROPERTIES:\n:ID:         x-aaaa\n:TAGS:       needs-review,perf\n:END:\n";
1266        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1267        let h = &doc.headings[0];
1268        assert_eq!(h.tags(), vec!["needs-review", "perf"]);
1269        let rendered = h.render();
1270        assert!(
1271            rendered.contains(":VISSUE_TAGS: needs-review"),
1272            "{rendered}"
1273        );
1274        assert!(rendered.contains(":perf:"), "{rendered}");
1275        assert!(!rendered.contains(":TAGS:"), "{rendered}");
1276    }
1277
1278    #[test]
1279    fn bodies_end_at_the_next_heading() {
1280        let content = "#+TITLE: x issues\n\n* TODO [#A] First\n:PROPERTIES:\n:ID:         x-1111\n:END:\n\nFirst body.\n\n* DONE [#C] Second\n:PROPERTIES:\n:ID:         x-2222\n:END:\n\nSecond body.\nMulti.\n";
1281        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1282        assert_eq!(doc.headings.len(), 2);
1283        assert_eq!(doc.headings[0].body, "First body.");
1284        assert_eq!(doc.headings[1].body, "Second body.\nMulti.");
1285    }
1286
1287    #[test]
1288    fn logbook_survives_a_document_round_trip() {
1289        let mut h = sample_heading();
1290        h.logbook = vec![LogEntry {
1291            timestamp: "[2026-04-26 Sun 09:15]".into(),
1292            from_state: Some("TODO".into()),
1293            to_state: Some("STARTED".into()),
1294            note: None,
1295            raw: None,
1296        }];
1297        let mut content = String::from("#+TITLE: sample issues\n\n");
1298        content.push_str(&h.render());
1299        let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
1300        assert_eq!(parsed.headings[0].logbook.len(), 1);
1301        assert_eq!(
1302            parsed.headings[0].logbook[0].from_state.as_deref(),
1303            Some("TODO")
1304        );
1305    }
1306
1307    #[test]
1308    fn write_then_reread_finds_the_heading() {
1309        let dir = tempfile::tempdir().unwrap();
1310        let path = dir.path().join("Software/sample/issues.org");
1311        IssueDoc {
1312            project: "sample".into(),
1313            path: path.clone(),
1314            preamble: default_preamble("sample"),
1315            tag_settings: tag_settings_from_preamble(&default_preamble("sample")),
1316            headings: vec![sample_heading()],
1317            after: vec![String::new()],
1318        }
1319        .write()
1320        .unwrap();
1321        let parsed = IssueDoc::parse_file("sample", &path).unwrap();
1322        assert_eq!(parsed.headings[0].id, "sample-abc1");
1323    }
1324
1325    #[test]
1326    fn write_preserves_the_existing_preamble_and_property_order() {
1327        let dir = tempfile::tempdir().unwrap();
1328        let path = dir.path().join("Software/sample/issues.org");
1329        fs::create_dir_all(path.parent().unwrap()).unwrap();
1330        fs::write(
1331            &path,
1332            "#+TITLE: sample issues\n#+FILETAGS: :issues:sample:\n#+STATUS: Active\n#+TODO: TODO STARTED BLOCKED | DONE CANCELLED\n\n* TODO [#A] Existing issue\n:PROPERTIES:\n:ID:         sample-abc1\n:CREATED:    [2026-04-26 Sun]\n:TYPE:       spec\n:PARENT:     sample-root\n:END:\n",
1333        )
1334        .unwrap();
1335
1336        let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
1337        doc.headings[0].priority = 'B';
1338        doc.write().unwrap();
1339        let written = fs::read_to_string(&path).unwrap();
1340
1341        assert!(written.contains("#+FILETAGS: :issues:sample:"), "{written}");
1342        assert!(written.contains("#+CATEGORY: sample"), "{written}");
1343        assert!(written.contains("#+STATUS: Active"), "{written}");
1344        assert!(
1345            written.find(":TYPE:").unwrap() < written.find(":PARENT:").unwrap(),
1346            "{written}"
1347        );
1348    }
1349
1350    #[test]
1351    fn a_gcal_event_heading_is_not_an_issue() {
1352        let dir = tempfile::tempdir().unwrap();
1353        let path = dir.path().join("Software/sample/issues.org");
1354        fs::create_dir_all(path.parent().unwrap()).unwrap();
1355        fs::write(
1356            &path,
1357            "#+TITLE: sample issues\n#+TODO: TODO STARTED BLOCKED | DONE CANCELLED\n\n* TODO [#A] Calendar dump\n:PROPERTIES:\n:ID:         abc123/primary@group.calendar.google.com\n:END:\n\n* TODO [#B] Real work\n:PROPERTIES:\n:ID:         sample-aaaa\n:END:\n",
1358        )
1359        .unwrap();
1360        let doc = IssueDoc::parse_file("sample", &path).unwrap();
1361        assert_eq!(
1362            doc.headings.len(),
1363            1,
1364            "{:?}",
1365            doc.headings.iter().map(|h| &h.id).collect::<Vec<_>>()
1366        );
1367        assert_eq!(doc.headings[0].id, "sample-aaaa");
1368        let written = doc.render_string();
1369        assert!(
1370            written.contains("abc123/primary@group.calendar.google.com"),
1371            "{written}"
1372        );
1373    }
1374
1375    #[test]
1376    fn setupfile_todo_keywords_make_an_issue() {
1377        let dir = tempfile::tempdir().unwrap();
1378        let setup = dir.path().join("house.org");
1379        fs::write(&setup, "#+TODO: TODO HOLD | DONE\n").unwrap();
1380        let path = dir.path().join("Software/sample/issues.org");
1381        fs::create_dir_all(path.parent().unwrap()).unwrap();
1382        fs::write(
1383            &path,
1384            format!(
1385                "#+TITLE: sample issues\n#+SETUPFILE: {}\n\n* HOLD [#C] Parked\n:PROPERTIES:\n:ID:         sample-hold\n:END:\n",
1386                setup.display()
1387            ),
1388        )
1389        .unwrap();
1390        let doc = IssueDoc::parse_file("sample", &path).unwrap();
1391        assert_eq!(doc.headings.len(), 1);
1392        assert_eq!(doc.headings[0].state, "HOLD");
1393        assert_eq!(doc.priority_spec().default, 'C');
1394    }
1395
1396    #[test]
1397    fn file_priorities_set_the_missing_cookie() {
1398        let dir = tempfile::tempdir().unwrap();
1399        let path = dir.path().join("Software/sample/issues.org");
1400        fs::create_dir_all(path.parent().unwrap()).unwrap();
1401        fs::write(
1402            &path,
1403            "#+TITLE: sample issues\n#+PRIORITIES: A D B\n#+TODO: TODO | DONE\n\n* TODO No cookie\n:PROPERTIES:\n:ID:         sample-none\n:END:\n",
1404        )
1405        .unwrap();
1406        let doc = IssueDoc::parse_file("sample", &path).unwrap();
1407        assert_eq!(doc.headings[0].priority, 'B');
1408        assert_eq!(doc.priority_spec().lowest, 'D');
1409    }
1410
1411    #[test]
1412    fn an_empty_document_writes_the_house_preamble() {
1413        let dir = tempfile::tempdir().unwrap();
1414        let path = dir.path().join("Software/sample/issues.org");
1415        IssueDoc::empty("sample", path.clone()).write().unwrap();
1416        let written = fs::read_to_string(&path).unwrap();
1417        for expected in [
1418            "#+TITLE: sample issues",
1419            "#+VISSUE: 1",
1420            // Org takes the category from the file name otherwise, and every
1421            // project's file is issues.org.
1422            "#+CATEGORY: sample",
1423            "#+FILETAGS: :issues:sample:noexport:",
1424            "#+TAGS: { bug(b) feature(f) task(t) chore(c) plan(p) }",
1425            "#+PRIORITIES: A C C",
1426            "#+EXCLUDE_TAGS: noexport",
1427            "#+SELECT_TAGS: export",
1428            "#+DATE:",
1429            "#+STATUS: Active",
1430            TODO_HEADER,
1431        ] {
1432            assert!(written.contains(expected), "missing {expected}: {written}");
1433        }
1434    }
1435
1436    #[test]
1437    fn generated_ids_are_unique_and_sized() {
1438        let existing = vec!["p-aaaa".to_string()];
1439        let id = generate_id("p", "a subject", &existing, 4).unwrap();
1440        assert!(id.starts_with("p-"));
1441        assert!(!existing.contains(&id));
1442        assert_eq!(id.len(), 1 + 1 + 4);
1443        assert_eq!(generate_id("q", "t", &[], 6).unwrap().len(), 1 + 1 + 6);
1444    }
1445
1446    #[test]
1447    fn a_full_suffix_space_is_an_error_and_not_a_panic() {
1448        // id_length 2 is 36^2 suffixes. Hand it every one of them and it has
1449        // to say so rather than spin or abort inside a write.
1450        let mut existing = Vec::new();
1451        for a in ID_ALPHABET {
1452            for b in ID_ALPHABET {
1453                existing.push(format!("p-{}{}", *a as char, *b as char));
1454            }
1455        }
1456        let err = generate_id("p", "t", &existing, 2).unwrap_err();
1457        assert!(err.to_string().contains("id_length"), "{err}");
1458        // One free suffix is still found.
1459        existing.pop();
1460        assert!(generate_id("p", "t", &existing, 2).is_ok());
1461    }
1462
1463    #[test]
1464    fn projects_are_discovered_under_the_configured_prefix() {
1465        let dir = tempfile::tempdir().unwrap();
1466        let layout = Layout::new(dir.path(), "tracker");
1467        for project in ["beta", "alpha"] {
1468            IssueDoc::empty(project, layout.project_issues_path(project))
1469                .write()
1470                .unwrap();
1471        }
1472        assert_eq!(list_projects(&layout).unwrap(), vec!["alpha", "beta"]);
1473        assert!(
1474            list_projects(&Layout::new(dir.path(), DEFAULT_PREFIX))
1475                .unwrap()
1476                .is_empty()
1477        );
1478    }
1479
1480    #[test]
1481    fn project_case_resolves_to_the_directory_on_disk() {
1482        let dir = tempfile::tempdir().unwrap();
1483        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
1484        IssueDoc::empty("MixedCase", layout.project_issues_path("MixedCase"))
1485            .write()
1486            .unwrap();
1487        assert_eq!(
1488            resolve_existing_project_case(&layout, "mixedcase").unwrap(),
1489            "MixedCase"
1490        );
1491        assert_eq!(
1492            resolve_existing_project_case(&layout, "brand-new").unwrap(),
1493            "brand-new"
1494        );
1495    }
1496
1497    #[test]
1498    fn project_context_file_is_found_by_walking_up() {
1499        let dir = tempfile::tempdir().unwrap();
1500        let nested = dir.path().join("a/b/c");
1501        fs::create_dir_all(&nested).unwrap();
1502        fs::write(
1503            dir.path().join(".project-ctx.toml"),
1504            "[project]\nname = \"demoproj\"\n",
1505        )
1506        .unwrap();
1507        assert_eq!(
1508            detect_project_from_ctx(&nested).as_deref(),
1509            Some("demoproj")
1510        );
1511        let empty = tempfile::tempdir().unwrap();
1512        assert!(detect_project_from_ctx(empty.path()).is_none());
1513    }
1514
1515    fn ids(content: &str) -> Vec<&str> {
1516        org_ids(content).collect()
1517    }
1518
1519    #[test]
1520    fn a_drawer_under_a_headline_defines_an_id() {
1521        assert_eq!(
1522            ids("* TODO [#B] a title\n:PROPERTIES:\n:ID:         atlas-1a2b\n:END:\n"),
1523            ["atlas-1a2b"]
1524        );
1525    }
1526
1527    #[test]
1528    fn a_drawer_under_the_planning_line_defines_an_id() {
1529        let text = concat!(
1530            "* TODO a title\n",
1531            "DEADLINE: <2026-05-15 Fri>\n",
1532            ":PROPERTIES:\n",
1533            ":ID:         atlas-1a2b\n",
1534            ":END:\n",
1535        );
1536        assert_eq!(ids(text), ["atlas-1a2b"]);
1537    }
1538
1539    #[test]
1540    fn a_logbook_beside_the_properties_does_not_hide_it() {
1541        let text = concat!(
1542            "* TODO a title\n",
1543            ":LOGBOOK:\n",
1544            "- claimed by worker-1\n",
1545            ":END:\n",
1546            ":PROPERTIES:\n",
1547            ":ID:         atlas-1a2b\n",
1548            ":END:\n",
1549        );
1550        assert_eq!(ids(text), ["atlas-1a2b"]);
1551    }
1552
1553    #[test]
1554    fn an_id_quoted_in_a_body_defines_nothing() {
1555        // What an agent writes back when its report quotes the tracker.
1556        let text = concat!(
1557            "* TODO a title\n",
1558            ":PROPERTIES:\n",
1559            ":ID:         atlas-1a2b\n",
1560            ":END:\n",
1561            "\n",
1562            "The heading I was handed reads:\n",
1563            ":PROPERTIES:\n",
1564            ":ID: ghost-9999\n",
1565            ":END:\n",
1566        );
1567        assert_eq!(
1568            ids(text),
1569            ["atlas-1a2b"],
1570            "a report that quotes an id defined it"
1571        );
1572    }
1573
1574    #[test]
1575    fn a_bare_id_line_in_a_body_defines_nothing() {
1576        let text = concat!(
1577            "* TODO a title\n",
1578            ":PROPERTIES:\n",
1579            ":ID:         atlas-1a2b\n",
1580            ":END:\n",
1581            "\n",
1582            "Compare with :ID: ghost-9999 in the other file.\n",
1583            ":ID: ghost-8888\n",
1584        );
1585        assert_eq!(ids(text), ["atlas-1a2b"]);
1586    }
1587
1588    #[test]
1589    fn a_file_level_drawer_defines_an_id() {
1590        // Org reads one at the top, above every headline.
1591        let text = concat!(
1592            "#+TITLE: atlas issues\n",
1593            "\n",
1594            ":PROPERTIES:\n",
1595            ":ID: the-file-itself\n",
1596            ":END:\n",
1597            "\n",
1598            "* TODO a title\n",
1599            ":PROPERTIES:\n",
1600            ":ID:         atlas-1a2b\n",
1601            ":END:\n",
1602        );
1603        assert_eq!(ids(text), ["the-file-itself", "atlas-1a2b"]);
1604    }
1605
1606    #[test]
1607    fn every_headline_depth_opens_a_drawer_site() {
1608        let text = concat!(
1609            "* TODO a title\n",
1610            ":PROPERTIES:\n",
1611            ":ID:         atlas-1a2b\n",
1612            ":END:\n",
1613            "** A sub-heading someone wrote by hand\n",
1614            ":PROPERTIES:\n",
1615            ":ID:         atlas-3c4d\n",
1616            ":END:\n",
1617        );
1618        assert_eq!(ids(text), ["atlas-1a2b", "atlas-3c4d"]);
1619    }
1620
1621    #[test]
1622    fn a_planning_keyword_needs_its_colon() {
1623        assert!(is_planning_line("DEADLINE: <2026-05-15 Fri>"));
1624        assert!(is_planning_line("CLOSED: [2026-05-15 Fri]"));
1625        // Prose that opens on the same word is prose.
1626        assert!(!is_planning_line("DEADLINES slipped again"));
1627        assert!(!is_planning_line("SCHEDULED work for the week"));
1628    }
1629
1630    #[test]
1631    fn prose_that_opens_like_a_planning_line_still_ends_the_drawer_site() {
1632        // Org reads a planning line directly under the headline and nowhere
1633        // else, so this one is body text and the quoted drawer below it is
1634        // body text too.
1635        let text = concat!(
1636            "* TODO a title\n",
1637            ":PROPERTIES:\n",
1638            ":ID:         atlas-1a2b\n",
1639            ":END:\n",
1640            "\n",
1641            "DEADLINE: is discussed in the design note.\n",
1642            ":PROPERTIES:\n",
1643            ":ID: ghost-9999\n",
1644            ":END:\n",
1645        );
1646        assert_eq!(ids(text), ["atlas-1a2b"]);
1647    }
1648
1649    #[test]
1650    fn a_headline_needs_a_space_after_its_stars() {
1651        assert!(is_headline("* TODO a title"));
1652        assert!(is_headline("*** deeper"));
1653        assert!(!is_headline("**bold** at the start of a line"));
1654        assert!(!is_headline("not a headline"));
1655    }
1656
1657    #[test]
1658    fn a_source_block_does_not_split_an_issue() {
1659        let content = concat!(
1660            "#+TITLE: x issues\n\n",
1661            "* TODO [#A] Real issue\n",
1662            ":PROPERTIES:\n",
1663            ":ID:         x-aaaa\n",
1664            ":END:\n\n",
1665            "Quoted tracker:\n",
1666            "#+BEGIN_SRC org\n",
1667            "* TODO quoted\n",
1668            ":PROPERTIES:\n",
1669            ":ID:         ghost-9999\n",
1670            ":END:\n",
1671            "#+END_SRC\n\n",
1672            "Still the same issue.\n",
1673        );
1674        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1675        assert_eq!(
1676            doc.headings.len(),
1677            1,
1678            "{:?}",
1679            doc.headings.iter().map(|h| &h.id).collect::<Vec<_>>()
1680        );
1681        assert_eq!(doc.headings[0].id, "x-aaaa");
1682        assert!(
1683            doc.headings[0].body.contains("* TODO quoted"),
1684            "{}",
1685            doc.headings[0].body
1686        );
1687        assert!(doc.headings[0].body.contains("Still the same issue."));
1688    }
1689
1690    #[test]
1691    fn org_ids_ignore_a_drawer_inside_a_block() {
1692        let text = concat!(
1693            "#+BEGIN_SRC org\n",
1694            "* TODO quoted\n",
1695            ":PROPERTIES:\n",
1696            ":ID:         ghost-9999\n",
1697            ":END:\n",
1698            "#+END_SRC\n",
1699            "* TODO a title\n",
1700            ":PROPERTIES:\n",
1701            ":ID:         atlas-1a2b\n",
1702            ":END:\n",
1703        );
1704        assert_eq!(ids(text), ["atlas-1a2b"]);
1705    }
1706
1707    #[test]
1708    fn a_timestamp_range_on_the_planning_line_parses() {
1709        let content = concat!(
1710            "#+TITLE: x issues\n\n",
1711            "* TODO [#A] Sprint\n",
1712            "SCHEDULED: <2026-09-01 Tue>--<2026-09-08 Tue> DEADLINE: <2026-09-15 Mon +1w>\n",
1713            ":PROPERTIES:\n",
1714            ":ID:         x-aaaa\n",
1715            ":END:\n",
1716        );
1717        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1718        assert_eq!(doc.headings[0].id, "x-aaaa");
1719        assert_eq!(
1720            doc.headings[0].scheduled(),
1721            Some("<2026-09-01 Tue>--<2026-09-08 Tue>")
1722        );
1723        assert_eq!(doc.headings[0].deadline(), Some("<2026-09-15 Mon +1w>"));
1724    }
1725
1726    #[test]
1727    fn a_repeater_and_warning_on_the_planning_line_parse() {
1728        let content = concat!(
1729            "#+TITLE: x issues\n\n",
1730            "* TODO [#A] Weekly\n",
1731            "DEADLINE: <2026-09-01 Tue +1w -2d>\n",
1732            ":PROPERTIES:\n",
1733            ":ID:         x-aaaa\n",
1734            ":END:\n",
1735        );
1736        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1737        assert_eq!(doc.headings[0].deadline(), Some("<2026-09-01 Tue +1w -2d>"));
1738        let rendered = doc.headings[0].render();
1739        assert!(
1740            rendered.contains("DEADLINE: <2026-09-01 Tue +1w -2d>"),
1741            "{rendered}"
1742        );
1743    }
1744
1745    #[test]
1746    fn a_logbook_before_properties_still_parses() {
1747        let content = concat!(
1748            "#+TITLE: x issues\n\n",
1749            "* TODO [#A] Clocked\n",
1750            ":LOGBOOK:\n",
1751            "CLOCK: [2026-08-18 Tue 10:00]--[2026-08-18 Tue 11:00] =>  1:00\n",
1752            ":END:\n",
1753            ":PROPERTIES:\n",
1754            ":ID:         x-aaaa\n",
1755            ":END:\n",
1756        );
1757        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1758        assert_eq!(doc.headings[0].id, "x-aaaa");
1759        assert_eq!(doc.headings[0].logbook.len(), 1);
1760        assert!(doc.headings[0].logbook[0].raw.is_some());
1761    }
1762
1763    #[test]
1764    fn other_drawers_at_the_drawer_site_do_not_hide_the_id() {
1765        let content = concat!(
1766            "#+TITLE: x issues\n\n",
1767            "* TODO [#A] Notes drawer\n",
1768            ":NOTES:\n",
1769            "hand written\n",
1770            ":END:\n",
1771            ":PROPERTIES:\n",
1772            ":ID:         x-aaaa\n",
1773            ":END:\n\n",
1774            "Body stays.\n",
1775        );
1776        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1777        assert_eq!(doc.headings[0].id, "x-aaaa");
1778        assert_eq!(doc.headings[0].body, "Body stays.");
1779        let rendered = doc.headings[0].render();
1780        assert!(rendered.contains(":NOTES:"), "{rendered}");
1781        assert!(rendered.contains("hand written"), "{rendered}");
1782    }
1783
1784    #[test]
1785    fn a_comment_heading_is_not_an_issue() {
1786        let content = concat!(
1787            "#+TITLE: x issues\n\n",
1788            "* COMMENT Archived discussion\n",
1789            ":PROPERTIES:\n",
1790            ":ID:         ghost-old\n",
1791            ":END:\n\n",
1792            "* TODO [#A] Live\n",
1793            ":PROPERTIES:\n",
1794            ":ID:         x-aaaa\n",
1795            ":END:\n",
1796        );
1797        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1798        assert_eq!(doc.headings.len(), 1);
1799        assert_eq!(doc.headings[0].id, "x-aaaa");
1800        assert!(doc.preamble.contains("COMMENT Archived"));
1801    }
1802
1803    #[test]
1804    fn a_section_heading_round_trips() {
1805        let content = concat!(
1806            "#+TITLE: x issues\n\n",
1807            "* TODO [#A] First\n",
1808            ":PROPERTIES:\n",
1809            ":ID:         x-aaaa\n",
1810            ":END:\n\n",
1811            "First body.\n\n",
1812            "* Notes\n",
1813            "Hand-written section.\n\n",
1814            "* TODO [#B] Second\n",
1815            ":PROPERTIES:\n",
1816            ":ID:         x-bbbb\n",
1817            ":END:\n",
1818        );
1819        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1820        assert_eq!(doc.headings.len(), 2);
1821        assert_eq!(doc.headings[0].body, "First body.");
1822        assert!(doc.after[0].contains("* Notes"));
1823        assert!(doc.after[0].contains("Hand-written section."));
1824        let rendered = doc.headings[0].render();
1825        let mut file = String::from("#+TITLE: x issues\n\n");
1826        file.push_str(&rendered);
1827        file.push('\n');
1828        file.push_str(&doc.after[0]);
1829        file.push_str(&doc.headings[1].render());
1830        let again = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), &file).unwrap();
1831        assert_eq!(again.headings.len(), 2);
1832        assert!(again.after[0].contains("* Notes"));
1833    }
1834
1835    #[test]
1836    fn a_file_local_todo_keyword_is_an_issue() {
1837        let content = concat!(
1838            "#+TITLE: x issues\n",
1839            "#+TODO: TODO WAIT | DONE\n\n",
1840            "* WAIT [#B] Parked\n",
1841            ":PROPERTIES:\n",
1842            ":ID:         x-aaaa\n",
1843            ":END:\n",
1844        );
1845        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1846        assert_eq!(doc.headings.len(), 1);
1847        assert_eq!(doc.headings[0].state, "WAIT");
1848        assert_eq!(doc.headings[0].id, "x-aaaa");
1849    }
1850
1851    #[test]
1852    fn a_statistics_cookie_is_not_the_title() {
1853        let content = concat!(
1854            "#+TITLE: x issues\n\n",
1855            "* TODO [#A] Break it down [2/5]           :plan:\n",
1856            ":PROPERTIES:\n",
1857            ":ID:         x-aaaa\n",
1858            ":END:\n",
1859        );
1860        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1861        assert_eq!(doc.headings[0].title, "Break it down");
1862        assert_eq!(doc.headings[0].statistics.as_deref(), Some("[2/5]"));
1863        assert_eq!(doc.headings[0].org_tags, vec!["plan"]);
1864        let line = doc.headings[0].render().lines().next().unwrap().to_string();
1865        assert!(line.contains("[2/5]"), "{line:?}");
1866        assert!(line.contains(":plan:"), "{line:?}");
1867    }
1868
1869    #[test]
1870    fn a_property_plus_appends() {
1871        let content = concat!(
1872            "#+TITLE: x issues\n\n",
1873            "* TODO [#A] Blocked\n",
1874            ":PROPERTIES:\n",
1875            ":ID:         x-aaaa\n",
1876            ":BLOCKED_BY: x-bbbb\n",
1877            ":BLOCKED_BY+: x-cccc\n",
1878            ":END:\n",
1879        );
1880        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1881        assert_eq!(doc.headings[0].blocked_by(), vec!["x-bbbb", "x-cccc"]);
1882    }
1883
1884    #[test]
1885    fn babel_results_do_not_split_an_issue_or_define_an_id() {
1886        let content = concat!(
1887            "#+TITLE: x issues\n\n",
1888            "* TODO [#A] Real issue\n",
1889            ":PROPERTIES:\n",
1890            ":ID:         x-aaaa\n",
1891            ":END:\n\n",
1892            "#+NAME: dump\n",
1893            "#+HEADER: :results raw\n",
1894            "#+BEGIN_SRC python :results raw\n",
1895            "print('* TODO dumped')\n",
1896            "#+END_SRC\n\n",
1897            "#+RESULTS:\n",
1898            "* TODO dumped\n",
1899            ":PROPERTIES:\n",
1900            ":ID:         ghost-9999\n",
1901            ":END:\n\n",
1902            "Still the same issue.\n\n",
1903            "* TODO [#B] Next\n",
1904            ":PROPERTIES:\n",
1905            ":ID:         x-bbbb\n",
1906            ":END:\n",
1907        );
1908        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1909        assert_eq!(
1910            doc.headings
1911                .iter()
1912                .map(|h| h.id.as_str())
1913                .collect::<Vec<_>>(),
1914            ["x-aaaa", "x-bbbb"]
1915        );
1916        assert!(
1917            doc.headings[0].body.contains("#+RESULTS:"),
1918            "{}",
1919            doc.headings[0].body
1920        );
1921        assert!(
1922            doc.headings[0].body.contains("* TODO dumped"),
1923            "{}",
1924            doc.headings[0].body
1925        );
1926        assert!(doc.headings[0].body.contains("Still the same issue."));
1927        assert_eq!(ids(content), ["x-aaaa", "x-bbbb"]);
1928    }
1929
1930    #[test]
1931    fn a_babel_call_with_results_drawer_stays_in_the_body() {
1932        let content = concat!(
1933            "#+TITLE: x issues\n\n",
1934            "* TODO [#A] Calls a named block\n",
1935            ":PROPERTIES:\n",
1936            ":ID:         x-aaaa\n",
1937            ":END:\n\n",
1938            "#+CALL: plot(x=1) :results drawer\n",
1939            "#+RESULTS:\n",
1940            ":RESULTS:\n",
1941            "* TODO not an issue\n",
1942            ":END:\n",
1943        );
1944        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
1945        assert_eq!(doc.headings.len(), 1);
1946        assert!(doc.headings[0].body.contains("#+CALL: plot"));
1947        assert!(doc.headings[0].body.contains("* TODO not an issue"));
1948        assert_eq!(ids(content), ["x-aaaa"]);
1949    }
1950    /// Minting is a function of its inputs, so it can be replayed.
1951    #[test]
1952    fn the_same_project_subject_and_seed_mint_the_same_id() {
1953        crate::process_env::override_var(ID_SEED_ENV, Some("99"));
1954        let a = generate_id("proj", "write the thing", &[], 4).unwrap();
1955        let b = generate_id("proj", "write the thing", &[], 4).unwrap();
1956        assert_eq!(a, b, "the same inputs minted two different ids");
1957
1958        // Different subjects start apart, which is what keeps two agents from
1959        // contending for one suffix when they are creating different issues.
1960        let c = generate_id("proj", "a different thing", &[], 4).unwrap();
1961        assert_ne!(a, c);
1962
1963        // And a different project does not inherit the same start from one seed.
1964        let d = generate_id("other", "write the thing", &[], 4).unwrap();
1965        assert_ne!(a, d);
1966        crate::process_env::clear_override(ID_SEED_ENV);
1967    }
1968
1969    /// The subject deciding the start does not let a duplicate id out: two issues
1970    /// with one subject want the same suffix and the second walks past it.
1971    #[test]
1972    fn two_issues_with_one_subject_do_not_share_an_id() {
1973        crate::process_env::override_var(ID_SEED_ENV, Some("7"));
1974        let first = generate_id("proj", "same title", &[], 4).unwrap();
1975        let second = generate_id("proj", "same title", std::slice::from_ref(&first), 4).unwrap();
1976        assert_ne!(first, second);
1977        crate::process_env::clear_override(ID_SEED_ENV);
1978    }
1979
1980    /// The seed moves the start, so a corpus is not stuck with one assignment.
1981    #[test]
1982    fn a_different_seed_mints_a_different_id() {
1983        crate::process_env::override_var(ID_SEED_ENV, Some("1"));
1984        let one = generate_id("proj", "subject", &[], 4).unwrap();
1985        crate::process_env::override_var(ID_SEED_ENV, Some("2"));
1986        let two = generate_id("proj", "subject", &[], 4).unwrap();
1987        crate::process_env::clear_override(ID_SEED_ENV);
1988        assert_ne!(one, two);
1989    }
1990}