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