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