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