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