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::{anyhow, Context, Result};
4use fs2::FileExt;
5use std::collections::{BTreeMap, HashMap};
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex, OnceLock};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use crate::config::Layout;
13use crate::model::TODO_KEYWORDS;
14use crate::model::{parse_log_line, today_inactive_bracket, IssueHeading, LogEntry, TODO_HEADER};
15
16/// Process-local mutex per path, so concurrent async handlers in one process
17/// serialize even where an advisory file lock would not (same process, many
18/// descriptors).
19static PROCESS_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
20static WRITE_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
21
22const ID_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
23
24struct CrossProcessLock {
25    file: fs::File,
26}
27
28impl CrossProcessLock {
29    fn acquire(path: &Path) -> Result<Self> {
30        let lock_path = issues_lock_path(path);
31        if let Some(parent) = lock_path.parent() {
32            fs::create_dir_all(parent)
33                .with_context(|| format!("create lock parent {}", parent.display()))?;
34        }
35        let file = fs::OpenOptions::new()
36            .create(true)
37            .read(true)
38            .append(true)
39            .open(&lock_path)
40            .with_context(|| format!("open lock {}", lock_path.display()))?;
41        file.lock_exclusive()
42            .with_context(|| format!("lock {}", lock_path.display()))?;
43        Ok(Self { file })
44    }
45}
46
47impl Drop for CrossProcessLock {
48    fn drop(&mut self) {
49        let _ = fs2::FileExt::unlock(&self.file);
50    }
51}
52
53/// Write `bytes` and flush them to the device, so the caller may rename the
54/// file knowing the contents are durable.
55fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
56    use std::io::Write as _;
57    let mut file = fs::File::create(path)?;
58    file.write_all(bytes)?;
59    file.sync_all()
60}
61
62/// Replace `path` with `body` through a flushed temporary and a rename.
63///
64/// For generated output a reader shares, which is a mirror: a plain write
65/// truncates first, so a reader mid-pull, or a crash, sees a half file where
66/// a whole one was.
67pub fn replace_file_atomically(path: &Path, body: &str) -> Result<()> {
68    let parent = path
69        .parent()
70        .filter(|p| !p.as_os_str().is_empty())
71        .map(Path::to_path_buf)
72        .unwrap_or_else(|| PathBuf::from("."));
73    fs::create_dir_all(&parent).with_context(|| format!("create {}", parent.display()))?;
74    let seq = WRITE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
75    let base = path
76        .file_name()
77        .and_then(|s| s.to_str())
78        .unwrap_or("output");
79    let tmp = parent.join(format!(".{}.tmp.{}-{}", base, std::process::id(), seq));
80    if let Err(e) = write_synced(&tmp, body.as_bytes()) {
81        let _ = fs::remove_file(&tmp);
82        return Err(e).with_context(|| format!("write temp {}", tmp.display()));
83    }
84    if let Err(e) = fs::rename(&tmp, path) {
85        let _ = fs::remove_file(&tmp);
86        return Err(e).with_context(|| format!("rename {} -> {}", tmp.display(), path.display()));
87    }
88    Ok(())
89}
90
91fn issues_lock_path(path: &Path) -> PathBuf {
92    let mut s = path.as_os_str().to_owned();
93    s.push(".lock");
94    PathBuf::from(s)
95}
96
97fn process_mutex_for(path: &Path) -> Arc<Mutex<()>> {
98    let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
99    let mut map = PROCESS_LOCKS
100        .get_or_init(|| Mutex::new(HashMap::new()))
101        .lock()
102        .unwrap_or_else(|p| p.into_inner());
103    map.entry(key)
104        .or_insert_with(|| Arc::new(Mutex::new(())))
105        .clone()
106}
107
108/// Serialize every read-modify-write cycle on one `issues.org`.
109///
110/// Without this, parallel creates race on the temporary file rename and lose
111/// updates: the last writer wins and peers see a vanished temporary.
112pub fn with_issues_lock<R, F>(path: &Path, f: F) -> Result<R>
113where
114    F: FnOnce() -> Result<R>,
115{
116    let mutex = process_mutex_for(path);
117    let _proc = mutex.lock().unwrap_or_else(|p| p.into_inner());
118    let _cross = CrossProcessLock::acquire(path)?;
119    f()
120}
121
122/// Lock several files in sorted order, which makes a cross-project move
123/// deadlock-free.
124pub fn with_issues_locks<R, F>(paths: &[&Path], f: F) -> Result<R>
125where
126    F: FnOnce() -> Result<R>,
127{
128    let mut keys: Vec<PathBuf> = paths.iter().map(|p| (*p).to_path_buf()).collect();
129    keys.sort();
130    keys.dedup();
131    let mutexes: Vec<Arc<Mutex<()>>> = keys.iter().map(|k| process_mutex_for(k)).collect();
132    let mut proc_guards = Vec::with_capacity(mutexes.len());
133    let mut cross_guards = Vec::with_capacity(keys.len());
134    for (key, mutex) in keys.iter().zip(mutexes.iter()) {
135        proc_guards.push(mutex.lock().unwrap_or_else(|p| p.into_inner()));
136        cross_guards.push(CrossProcessLock::acquire(key)?);
137    }
138    f()
139}
140
141/// One project's `issues.org`: a preamble followed by top-level headings.
142#[derive(Debug, Clone)]
143pub struct IssueDoc {
144    pub project: String,
145    pub path: PathBuf,
146    pub preamble: String,
147    pub headings: Vec<IssueHeading>,
148}
149
150impl IssueDoc {
151    pub fn empty(project: &str, path: PathBuf) -> Self {
152        IssueDoc {
153            project: project.to_string(),
154            path,
155            preamble: default_preamble(project),
156            headings: Vec::new(),
157        }
158    }
159
160    /// Parse `path`, or produce an empty document when the file is absent.
161    pub fn parse_file(project: &str, path: &Path) -> Result<Self> {
162        if !path.exists() {
163            return Ok(Self::empty(project, path.to_path_buf()));
164        }
165        let content =
166            fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
167        Self::parse(project, path.to_path_buf(), &content)
168    }
169
170    pub fn parse(project: &str, path: PathBuf, content: &str) -> Result<Self> {
171        let lines: Vec<&str> = content.lines().collect();
172        let first_heading = lines
173            .iter()
174            .position(|line| line.starts_with("* "))
175            .unwrap_or(lines.len());
176        let preamble = if first_heading == 0 {
177            default_preamble(project)
178        } else {
179            lines[..first_heading].join("\n").trim_end().to_string()
180        };
181        let mut headings = Vec::new();
182        let mut i = first_heading;
183        while i < lines.len() {
184            if lines[i].starts_with("* ") {
185                let (heading, end_idx) = parse_heading(&lines, i)
186                    .with_context(|| format!("at {}:{}", path.display(), i + 1))?;
187                headings.push(heading);
188                i = end_idx;
189            } else {
190                i += 1;
191            }
192        }
193        Ok(IssueDoc {
194            project: project.to_string(),
195            path,
196            preamble,
197            headings,
198        })
199    }
200
201    /// Render and replace the file through a uniquely named temporary. Callers
202    /// hold [`with_issues_lock`] around the parse and this write.
203    pub fn write(&self) -> Result<()> {
204        if let Some(parent) = self.path.parent() {
205            fs::create_dir_all(parent)?;
206        }
207        let mut out = String::new();
208        let preamble = if self.preamble.trim().is_empty() {
209            default_preamble(&self.project)
210        } else {
211            self.preamble.clone()
212        };
213        out.push_str(preamble.trim_end());
214        out.push_str("\n\n");
215        for h in &self.headings {
216            out.push_str(&h.render());
217            out.push('\n');
218        }
219        // A shared temporary name races: a peer renames it out from under this
220        // writer and the rename fails with ENOENT.
221        let seq = WRITE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
222        let nanos = SystemTime::now()
223            .duration_since(UNIX_EPOCH)
224            .map(|d| d.as_nanos())
225            .unwrap_or(0);
226        let base = self
227            .path
228            .file_name()
229            .and_then(|s| s.to_str())
230            .unwrap_or("issues.org");
231        let tmp = self
232            .path
233            .parent()
234            .unwrap_or_else(|| Path::new("."))
235            .join(format!(
236                ".{}.tmp.{}-{}-{}",
237                base,
238                std::process::id(),
239                nanos,
240                seq
241            ));
242        // Flush the bytes to the device before the rename publishes them.
243        // Rename is atomic against a concurrent reader, not against a crash:
244        // an unsynced temporary can land as a truncated issues.org.
245        if let Err(e) = write_synced(&tmp, out.as_bytes()) {
246            let _ = fs::remove_file(&tmp);
247            return Err(e).with_context(|| format!("write temp {}", tmp.display()));
248        }
249        if let Err(e) = fs::rename(&tmp, &self.path) {
250            let _ = fs::remove_file(&tmp);
251            return Err(e)
252                .with_context(|| format!("rename {} -> {}", tmp.display(), self.path.display()));
253        }
254        self.announce_write();
255        Ok(())
256    }
257
258    /// Tell pollers the file moved. The event files live beside the project
259    /// directories, which is this file's grandparent. Failure here is not a
260    /// failed write: the issue is already on disk.
261    fn announce_write(&self) {
262        if !crate::events::enabled() {
263            return;
264        }
265        let Some(dir) = self.path.parent().and_then(|p| p.parent()) else {
266            return;
267        };
268        let _ = crate::events::emit_issues_write(dir, &self.project, &self.path);
269        let _ = crate::events::ensure_gitignore_hint(dir);
270    }
271
272    pub fn known_ids(&self) -> Vec<String> {
273        self.headings.iter().map(|h| h.id.clone()).collect()
274    }
275
276    pub fn upsert(&mut self, heading: IssueHeading) {
277        if let Some(slot) = self.headings.iter_mut().find(|h| h.id == heading.id) {
278            *slot = heading;
279        } else {
280            self.headings.push(heading);
281        }
282    }
283
284    pub fn remove(&mut self, id: &str) -> Option<IssueHeading> {
285        let idx = self.headings.iter().position(|h| h.id == id)?;
286        Some(self.headings.remove(idx))
287    }
288}
289
290fn parse_heading(lines: &[&str], start: usize) -> Result<(IssueHeading, usize)> {
291    let header = lines[start];
292    let stripped = header
293        .strip_prefix("* ")
294        .ok_or_else(|| anyhow!("not a heading"))?;
295
296    let trimmed = stripped.trim();
297    let (state, after) = trimmed
298        .split_once(' ')
299        .map(|(s, a)| (s.to_string(), a.trim_start()))
300        .unwrap_or((trimmed.to_string(), ""));
301    if !TODO_KEYWORDS.contains(&state.as_str()) {
302        return Err(anyhow!("unknown TODO keyword {:?}", state));
303    }
304
305    let (priority, heading_text) = match parse_priority_cookie(after) {
306        Some((p, rest)) => (p, rest.trim()),
307        None => ('C', after),
308    };
309    let (title, org_tags) = crate::model::split_headline_tags(heading_text);
310
311    let mut properties = BTreeMap::new();
312    let mut property_order = Vec::new();
313    let mut logbook: Vec<LogEntry> = Vec::new();
314    let mut i = start + 1;
315
316    // Org writes DEADLINE, SCHEDULED, and CLOSED on a planning line between
317    // the heading and the drawer. Reading it is what keeps `C-c C-d` in Emacs
318    // from making the file unparseable here.
319    while i < lines.len() {
320        let found = parse_planning_line(lines[i]);
321        if found.is_empty() {
322            break;
323        }
324        for (key, value) in found {
325            if !property_order.contains(&key) {
326                property_order.push(key.clone());
327            }
328            properties.insert(key, value);
329        }
330        i += 1;
331    }
332
333    let mut body_start = i;
334    if i < lines.len() && lines[i].trim() == ":PROPERTIES:" {
335        i += 1;
336        while i < lines.len() && lines[i].trim() != ":END:" {
337            let line = lines[i].trim();
338            if let Some(rest) = line.strip_prefix(':') {
339                if let Some(idx) = rest.find(':') {
340                    let key = rest[..idx].to_string();
341                    let val = rest[idx + 1..].trim().to_string();
342                    if !property_order.contains(&key) {
343                        property_order.push(key.clone());
344                    }
345                    properties.insert(key, val);
346                }
347            }
348            i += 1;
349        }
350        if i < lines.len() {
351            i += 1;
352        }
353        body_start = i;
354    }
355
356    if i < lines.len() && lines[i].trim() == ":LOGBOOK:" {
357        i += 1;
358        while i < lines.len() && lines[i].trim() != ":END:" {
359            if !lines[i].trim().is_empty() {
360                logbook.push(parse_log_line(lines[i]));
361            }
362            i += 1;
363        }
364        if i < lines.len() {
365            i += 1;
366        }
367        body_start = i;
368    }
369
370    let mut body_end = body_start;
371    while body_end < lines.len() && !lines[body_end].starts_with("* ") {
372        body_end += 1;
373    }
374    let body = lines[body_start..body_end]
375        .join("\n")
376        .trim_matches('\n')
377        .trim_end()
378        .to_string();
379
380    // Carry a drawer written under the old name forward, so an existing
381    // tracker reads the same and the next rewrite settles on the name Org
382    // does not reserve.
383    if let Some(legacy) = properties.remove(crate::model::LEGACY_TAGS_PROPERTY) {
384        property_order.retain(|key| key != crate::model::LEGACY_TAGS_PROPERTY);
385        properties
386            .entry(crate::model::TAGS_PROPERTY.to_string())
387            .or_insert(legacy);
388    }
389
390    let id = properties
391        .get("ID")
392        .cloned()
393        .ok_or_else(|| anyhow!(":ID: property missing"))?;
394
395    Ok((
396        IssueHeading {
397            id,
398            title,
399            state,
400            priority,
401            properties,
402            org_tags,
403            property_order,
404            body,
405            logbook,
406            line_start: start + 1,
407            line_end: if body_end == 0 { 1 } else { body_end },
408        },
409        body_end,
410    ))
411}
412
413/// Read an Org planning line into its `KEY -> timestamp` pairs.
414///
415/// Org packs several onto one line, as `CLOSED: [...] SCHEDULED: <...>`, and
416/// a line holding anything else is not a planning line at all.
417fn parse_planning_line(line: &str) -> Vec<(String, String)> {
418    let mut rest = line.trim();
419    let mut found = Vec::new();
420    while !rest.is_empty() {
421        let Some(key) = crate::model::PLANNING_KEYS
422            .iter()
423            .find(|key| rest.starts_with(&format!("{key}:")))
424        else {
425            return Vec::new();
426        };
427        let after = rest[key.len() + 1..].trim_start();
428        let close = match after.chars().next() {
429            Some('<') => '>',
430            Some('[') => ']',
431            _ => return Vec::new(),
432        };
433        let Some(end) = after.find(close) else {
434            return Vec::new();
435        };
436        found.push((key.to_string(), after[..=end].to_string()));
437        rest = after[end + 1..].trim_start();
438    }
439    found
440}
441
442/// Split a leading `[#A]` cookie off a heading, returning the cookie character
443/// and the rest of the line. Any other shape yields `None`, so a title that
444/// merely opens with a bracket keeps its text.
445///
446/// The cookie character is taken as a character, not a byte: an issues.org is
447/// hand-editable, and `[#<multibyte>]` is text a person can type.
448fn parse_priority_cookie(after: &str) -> Option<(char, &str)> {
449    let rest = after.strip_prefix("[#")?;
450    let mut chars = rest.char_indices();
451    let (_, priority) = chars.next()?;
452    let (close, bracket) = chars.next()?;
453    if bracket != ']' {
454        return None;
455    }
456    Some((priority, &rest[close + 1..]))
457}
458
459/// The header a fresh project file gets.
460///
461/// `#+CATEGORY:` names the project, because Org otherwise takes the category
462/// from the file name and every project's file is `issues.org`: an agenda
463/// spanning several projects would label every row `issues`.
464pub fn default_preamble(project: &str) -> String {
465    format!(
466        "#+TITLE: {project} issues\n#+CATEGORY: {project}\n#+FILETAGS: :issues:{project}:\n#+DATE: {}\n#+DESCRIPTION: Issue tracking file for {project} specs, plans, and implementation tasks.\n#+STATUS: Active\n{}",
467        today_inactive_bracket(),
468        TODO_HEADER
469    )
470}
471
472/// Every project directory under the layout prefix that holds an `issues.org`.
473pub fn list_projects(layout: &Layout) -> Result<Vec<String>> {
474    let dir = layout.projects_dir();
475    if !dir.exists() {
476        return Ok(Vec::new());
477    }
478    let mut projects = Vec::new();
479    for entry in fs::read_dir(&dir).with_context(|| format!("read dir {}", dir.display()))? {
480        let entry = entry?;
481        let path = entry.path();
482        if path.is_dir() && path.join("issues.org").exists() {
483            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
484                projects.push(name.to_string());
485            }
486        }
487    }
488    projects.sort();
489    Ok(projects)
490}
491
492/// Map a project name onto the directory that already exists, ignoring case.
493/// An unmatched name is returned unchanged so `create` can make it.
494pub fn resolve_existing_project_case(layout: &Layout, project: &str) -> Result<String> {
495    if project.is_empty() {
496        return Ok(project.to_string());
497    }
498    if layout.project_issues_path(project).exists() {
499        return Ok(project.to_string());
500    }
501    let project_lower = project.to_lowercase();
502    let matches: Vec<String> = list_projects(layout)?
503        .into_iter()
504        .filter(|candidate| candidate.to_lowercase() == project_lower)
505        .collect();
506    match matches.as_slice() {
507        [] => Ok(project.to_string()),
508        [canonical] => Ok(canonical.clone()),
509        _ => Err(anyhow!(
510            "project {project:?} is ambiguous; case-insensitive matches: {}",
511            matches.join(", ")
512        )),
513    }
514}
515
516/// Whether a project belongs to a `--project` selection.
517///
518/// Case folds, because the directory on disk is what names a project and
519/// [`resolve_existing_project_case`] already folds case for every verb that
520/// writes. A query that dropped `-p Atlas` on a tracker holding `atlas` would
521/// answer "no issues" to a question that has issues.
522pub fn project_selected(project: &str, filter: Option<&str>) -> bool {
523    match filter {
524        None => true,
525        Some(p) => project.eq_ignore_ascii_case(p),
526    }
527}
528
529/// Locate one issue by id across every project.
530pub fn find_by_id(layout: &Layout, id: &str) -> Result<Option<(IssueHeading, PathBuf, String)>> {
531    for project in list_projects(layout)? {
532        let path = layout.project_issues_path(&project);
533        let doc = IssueDoc::parse_file(&project, &path)?;
534        for h in doc.headings {
535            if h.id == id {
536                return Ok(Some((h, path, project)));
537            }
538        }
539    }
540    Ok(None)
541}
542
543/// Snapshot every heading across every project, tagged with its project.
544pub fn load_all(layout: &Layout) -> Result<Vec<(String, IssueHeading)>> {
545    let mut all = Vec::new();
546    for project in list_projects(layout)? {
547        let path = layout.project_issues_path(&project);
548        let doc = IssueDoc::parse_file(&project, &path)?;
549        for h in doc.headings {
550            all.push((project.clone(), h));
551        }
552    }
553    Ok(all)
554}
555
556/// `<project>-<base36 suffix>`, retried until it does not collide.
557///
558/// Fails rather than looping forever when the suffix space is full. That is
559/// reachable, not hypothetical: `id_length = 2` is 1296 suffixes, so a
560/// project can outgrow it, and the answer is a longer id rather than a
561/// crash inside a write.
562pub fn generate_id(project: &str, existing: &[String], length: usize) -> Result<String> {
563    let len = length.max(2);
564    let taken: std::collections::HashSet<&str> = existing.iter().map(String::as_str).collect();
565    let base = SystemTime::now()
566        .duration_since(UNIX_EPOCH)
567        .map(|d| d.as_nanos())
568        .unwrap_or(1);
569    // Bounded by the size of the space, so a full space is reported instead
570    // of spun on. 36^len saturates well before it could overflow.
571    let attempts = 36usize
572        .checked_pow(len as u32)
573        .map(|space| space.saturating_mul(2))
574        .unwrap_or(usize::MAX)
575        .min(2_000_000);
576    for counter in 0..attempts as u128 {
577        let mut n = base
578            .wrapping_add(counter.wrapping_mul(17))
579            .wrapping_mul(2654435761);
580        let mut suffix = String::new();
581        for _ in 0..len {
582            suffix.push(ID_ALPHABET[(n as usize) % 36] as char);
583            n /= 36;
584        }
585        let id = format!("{}-{}", project, suffix);
586        if !taken.contains(id.as_str()) {
587            return Ok(id);
588        }
589    }
590    Err(anyhow!(
591        "no free id left for {project:?} at id_length = {len}; \
592         raise `id_length` under [issues] in vissue.toml"
593    ))
594}
595
596/// Walk up from `start` for a `.project-ctx.toml` and read `[project].name`.
597pub fn detect_project_from_ctx(start: &Path) -> Option<String> {
598    let mut dir = start.canonicalize().ok()?;
599    loop {
600        let candidate = dir.join(".project-ctx.toml");
601        if candidate.exists() {
602            if let Ok(text) = fs::read_to_string(&candidate) {
603                if let Ok(value) = text.parse::<toml::Value>() {
604                    if let Some(name) = value
605                        .get("project")
606                        .and_then(|p| p.get("name"))
607                        .and_then(|n| n.as_str())
608                    {
609                        return Some(name.to_string());
610                    }
611                }
612            }
613        }
614        if !dir.pop() {
615            break;
616        }
617    }
618    None
619}
620
621/// Every `:ID:` value in any org file under the layout prefix. A `:PARENT:`
622/// may point at a design document rather than at another issue.
623/// Look for `wanted` among the `:ID:` values in the tree, stopping as soon
624/// as every one has been seen.
625///
626/// `check` validates `:PARENT:` against any Org id, which includes design
627/// documents and notes rather than only issues. On a tracker that shares a
628/// root with a notes vault that is most of the bytes on disk: 172MB across
629/// 6335 files, against 4.8MB of `issues.org`. Almost every parent is another
630/// issue, so the whole scan usually answers a question already answered.
631///
632/// Returns the ids among `wanted` that were found.
633pub fn find_org_ids(
634    layout: &Layout,
635    wanted: &std::collections::HashSet<String>,
636) -> Result<std::collections::HashSet<String>> {
637    let mut found = std::collections::HashSet::new();
638    if wanted.is_empty() {
639        return Ok(found);
640    }
641    let dir = layout.projects_dir();
642    if !dir.exists() {
643        return Ok(found);
644    }
645    for entry in walkdir::WalkDir::new(&dir)
646        .into_iter()
647        .filter_entry(|e| !is_skipped_dir(e))
648    {
649        let entry = match entry {
650            Ok(e) => e,
651            Err(_) => continue,
652        };
653        if !entry.file_type().is_file()
654            || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
655        {
656            continue;
657        }
658        let content = fs::read_to_string(entry.path())
659            .with_context(|| format!("read {}", entry.path().display()))?;
660        for id in org_ids(&content) {
661            if wanted.contains(id) {
662                found.insert(id.to_string());
663                if found.len() == wanted.len() {
664                    return Ok(found);
665                }
666            }
667        }
668    }
669    Ok(found)
670}
671
672pub fn collect_org_ids(layout: &Layout) -> Result<std::collections::HashSet<String>> {
673    let mut ids = std::collections::HashSet::new();
674    let dir = layout.projects_dir();
675    if !dir.exists() {
676        return Ok(ids);
677    }
678    for entry in walkdir::WalkDir::new(&dir)
679        .into_iter()
680        .filter_entry(|e| !is_skipped_dir(e))
681    {
682        let entry = match entry {
683            Ok(e) => e,
684            Err(_) => continue,
685        };
686        if !entry.file_type().is_file()
687            || entry.path().extension().and_then(|s| s.to_str()) != Some("org")
688        {
689            continue;
690        }
691        let content = fs::read_to_string(entry.path())
692            .with_context(|| format!("read {}", entry.path().display()))?;
693        for id in org_ids(&content) {
694            ids.insert(id.to_string());
695        }
696    }
697    Ok(ids)
698}
699
700fn is_skipped_dir(entry: &walkdir::DirEntry) -> bool {
701    if !entry.file_type().is_dir() {
702        return false;
703    }
704    let name = entry.file_name().to_string_lossy();
705    matches!(
706        name.as_ref(),
707        "node_modules" | "target" | ".git" | ".cache" | "build"
708    )
709}
710
711fn org_id_property_value(line: &str) -> Option<&str> {
712    let value = line.trim_start().strip_prefix(":ID:")?.trim();
713    if value.is_empty() {
714        None
715    } else {
716        Some(value)
717    }
718}
719
720/// The ids a file defines, which are the ones org would read.
721///
722/// A property drawer counts where org lets one start: under a headline, under
723/// that headline's planning line, or beside the other drawers clustered
724/// there. A `:PROPERTIES:` block further down the entry is an ordinary drawer
725/// and the `:ID:` inside it is prose.
726///
727/// The distinction is the difference between a working `check` and a silent
728/// one. Agents write their reports into issue bodies, those reports quote org,
729/// and taking every `:ID:` line makes quoted text define an id: a `:PARENT:`
730/// pointing at nothing resolves against a report that merely mentions it.
731fn org_ids(content: &str) -> impl Iterator<Item = &str> {
732    // The top of a file is a drawer site: org reads a file-level drawer there.
733    let mut at_drawer_site = true;
734    let mut in_drawer = false;
735    let mut drawer_is_properties = false;
736    // Org takes a planning line on the line under the headline and nowhere
737    // else, so prose opening on `DEADLINE:` further down is prose.
738    let mut under_headline = false;
739
740    content.lines().filter_map(move |line| {
741        let trimmed = line.trim();
742
743        if in_drawer {
744            if trimmed.eq_ignore_ascii_case(":END:") {
745                in_drawer = false;
746                drawer_is_properties = false;
747                // The drawers under a headline sit together, so the next one
748                // is still in a place org reads.
749                at_drawer_site = true;
750                return None;
751            }
752            if drawer_is_properties {
753                return org_id_property_value(line);
754            }
755            return None;
756        }
757
758        if is_headline(line) {
759            at_drawer_site = true;
760            under_headline = true;
761            return None;
762        }
763        let planning_may_start_here = under_headline;
764        under_headline = false;
765
766        // Neither a blank line nor a keyword is content, so neither one moves
767        // the entry past the place its drawers live.
768        if trimmed.is_empty() || trimmed.starts_with("#+") {
769            return None;
770        }
771        if at_drawer_site {
772            if opens_a_drawer(trimmed) {
773                in_drawer = true;
774                drawer_is_properties = trimmed.eq_ignore_ascii_case(":PROPERTIES:");
775                return None;
776            }
777            if planning_may_start_here && is_planning_line(trimmed) {
778                return None;
779            }
780        }
781        // Body text: everything below it is the body.
782        at_drawer_site = false;
783        None
784    })
785}
786
787/// A line org reads as a headline: leading stars, then a space.
788fn is_headline(line: &str) -> bool {
789    let stars = line.len() - line.trim_start_matches('*').len();
790    stars > 0 && line[stars..].starts_with(' ')
791}
792
793/// `:NAME:` alone on a line, which is how every drawer opens.
794fn opens_a_drawer(trimmed: &str) -> bool {
795    trimmed.len() > 2
796        && trimmed.starts_with(':')
797        && trimmed.ends_with(':')
798        && !trimmed.eq_ignore_ascii_case(":END:")
799        && !trimmed[1..trimmed.len() - 1].contains(char::is_whitespace)
800}
801
802fn is_planning_line(trimmed: &str) -> bool {
803    crate::model::PLANNING_KEYS
804        .iter()
805        .any(|key| trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':'))
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811    use crate::config::DEFAULT_PREFIX;
812
813    fn sample_heading() -> IssueHeading {
814        let mut props = BTreeMap::new();
815        props.insert("ID".into(), "sample-abc1".into());
816        props.insert("CREATED".into(), "[2026-04-25 Sat]".into());
817        props.insert("TYPE".into(), "feature".into());
818        IssueHeading {
819            id: "sample-abc1".into(),
820            title: "Add a thing".into(),
821            state: "TODO".into(),
822            priority: 'A',
823            properties: props,
824            org_tags: Vec::new(),
825            property_order: vec!["ID".into(), "CREATED".into(), "TYPE".into()],
826            body: "Some body lines.\nWith multiple lines.".into(),
827            logbook: Vec::new(),
828            line_start: 4,
829            line_end: 12,
830        }
831    }
832
833    #[test]
834    fn render_then_parse_preserves_the_heading() {
835        let mut content = String::from("#+TITLE: sample issues\n");
836        content.push_str(TODO_HEADER);
837        content.push_str("\n\n");
838        content.push_str(&sample_heading().render());
839        let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
840        let h = &parsed.headings[0];
841        let original = sample_heading();
842        assert_eq!(h.id, original.id);
843        assert_eq!(h.title, original.title);
844        assert_eq!(h.state, original.state);
845        assert_eq!(h.priority, original.priority);
846        assert_eq!(h.body, original.body);
847        assert_eq!(h.properties.get("TYPE"), original.properties.get("TYPE"));
848    }
849
850    #[test]
851    fn heading_without_a_priority_cookie_defaults_to_c() {
852        let content =
853            "#+TITLE: x issues\n\n* TODO Just a title\n:PROPERTIES:\n:ID:         x-aaaa\n:END:\n";
854        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
855        assert_eq!(doc.headings[0].priority, 'C');
856        assert_eq!(doc.headings[0].title, "Just a title");
857    }
858
859    #[test]
860    fn a_multibyte_priority_cookie_parses_instead_of_panicking() {
861        let content = "#+TITLE: x issues\n\n* TODO [#\u{2192}] Hand edited\n:PROPERTIES:\n:ID:         x-aaaa\n:END:\n";
862        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
863        assert_eq!(doc.headings[0].priority, '\u{2192}');
864        assert_eq!(doc.headings[0].title, "Hand edited");
865    }
866
867    #[test]
868    fn a_title_opening_with_a_bracket_keeps_its_text() {
869        let content =
870            "#+TITLE: x issues\n\n* TODO [#not a cookie] stays\n:PROPERTIES:\n:ID:         x-bbbb\n:END:\n";
871        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
872        assert_eq!(doc.headings[0].priority, 'C');
873        assert_eq!(doc.headings[0].title, "[#not a cookie] stays");
874    }
875
876    #[test]
877    fn an_org_planning_line_parses_instead_of_hiding_the_drawer() {
878        // What `C-c C-d`, `C-c C-s`, and `org-log-done` write in Emacs. Before
879        // the planning line was read, the drawer below it went unseen and the
880        // whole file failed with ":ID: property missing".
881        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";
882        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
883        let h = &doc.headings[0];
884        assert_eq!(h.id, "x-aaaa");
885        assert_eq!(h.deadline(), Some("<2026-09-01 Tue>"));
886        assert_eq!(h.scheduled(), Some("<2026-09-05 Sat>"));
887        assert_eq!(
888            h.properties.get("CLOSED").map(String::as_str),
889            Some("[2026-08-14 Fri 03:33]")
890        );
891        assert_eq!(h.body, "Body stays body.");
892    }
893
894    #[test]
895    fn a_planning_line_round_trips_in_orgs_own_order() {
896        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";
897        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
898        let rendered = doc.headings[0].render();
899        assert!(
900            rendered.contains(
901                "\nCLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-05 Sat> DEADLINE: <2026-09-01 Tue>\n"
902            ),
903            "{rendered}"
904        );
905        assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
906    }
907
908    #[test]
909    fn a_legacy_date_property_is_promoted_to_a_planning_line() {
910        // Trackers written before dates moved out of the drawer still parse,
911        // and the next rewrite puts them where Org's agenda reads them.
912        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";
913        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
914        assert_eq!(doc.headings[0].deadline(), Some("<2026-09-01 Tue>"));
915        let rendered = doc.headings[0].render();
916        assert!(
917            rendered.contains("\nDEADLINE: <2026-09-01 Tue>\n"),
918            "{rendered}"
919        );
920        assert!(!rendered.contains(":DEADLINE:"), "{rendered}");
921    }
922
923    #[test]
924    fn a_line_that_only_looks_like_planning_is_left_as_body() {
925        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";
926        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
927        assert_eq!(doc.headings[0].deadline(), None);
928        assert_eq!(
929            doc.headings[0].body,
930            "DEADLINE: is discussed in the design note."
931        );
932    }
933
934    #[test]
935    fn a_legacy_tags_property_moves_to_the_name_org_leaves_alone() {
936        // `TAGS` is one of Org's own special property names, so a drawer that
937        // claims it is what `org-lint` reports. Existing trackers still read.
938        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";
939        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
940        let h = &doc.headings[0];
941        assert_eq!(h.tags(), vec!["needs-review", "perf"]);
942        let rendered = h.render();
943        assert!(
944            rendered.contains(":VISSUE_TAGS: needs-review,perf"),
945            "{rendered}"
946        );
947        assert!(!rendered.contains(":TAGS:"), "{rendered}");
948    }
949
950    #[test]
951    fn bodies_end_at_the_next_heading() {
952        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";
953        let doc = IssueDoc::parse("x", PathBuf::from("/tmp/x.org"), content).unwrap();
954        assert_eq!(doc.headings.len(), 2);
955        assert_eq!(doc.headings[0].body, "First body.");
956        assert_eq!(doc.headings[1].body, "Second body.\nMulti.");
957    }
958
959    #[test]
960    fn logbook_survives_a_document_round_trip() {
961        let mut h = sample_heading();
962        h.logbook = vec![LogEntry {
963            timestamp: "[2026-04-26 Sun 09:15]".into(),
964            from_state: Some("TODO".into()),
965            to_state: Some("STARTED".into()),
966            note: None,
967            raw: None,
968        }];
969        let mut content = String::from("#+TITLE: sample issues\n\n");
970        content.push_str(&h.render());
971        let parsed = IssueDoc::parse("sample", PathBuf::from("/tmp/x.org"), &content).unwrap();
972        assert_eq!(parsed.headings[0].logbook.len(), 1);
973        assert_eq!(
974            parsed.headings[0].logbook[0].from_state.as_deref(),
975            Some("TODO")
976        );
977    }
978
979    #[test]
980    fn write_then_reread_finds_the_heading() {
981        let dir = tempfile::tempdir().unwrap();
982        let path = dir.path().join("Software/sample/issues.org");
983        IssueDoc {
984            project: "sample".into(),
985            path: path.clone(),
986            preamble: default_preamble("sample"),
987            headings: vec![sample_heading()],
988        }
989        .write()
990        .unwrap();
991        let parsed = IssueDoc::parse_file("sample", &path).unwrap();
992        assert_eq!(parsed.headings[0].id, "sample-abc1");
993    }
994
995    #[test]
996    fn write_preserves_the_existing_preamble_and_property_order() {
997        let dir = tempfile::tempdir().unwrap();
998        let path = dir.path().join("Software/sample/issues.org");
999        fs::create_dir_all(path.parent().unwrap()).unwrap();
1000        fs::write(
1001            &path,
1002            "#+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",
1003        )
1004        .unwrap();
1005
1006        let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
1007        doc.headings[0].priority = 'B';
1008        doc.write().unwrap();
1009        let written = fs::read_to_string(&path).unwrap();
1010
1011        assert!(written.contains("#+FILETAGS: :issues:sample:"), "{written}");
1012        assert!(written.contains("#+STATUS: Active"), "{written}");
1013        assert!(
1014            written.find(":TYPE:").unwrap() < written.find(":PARENT:").unwrap(),
1015            "{written}"
1016        );
1017    }
1018
1019    #[test]
1020    fn an_empty_document_writes_the_house_preamble() {
1021        let dir = tempfile::tempdir().unwrap();
1022        let path = dir.path().join("Software/sample/issues.org");
1023        IssueDoc::empty("sample", path.clone()).write().unwrap();
1024        let written = fs::read_to_string(&path).unwrap();
1025        for expected in [
1026            "#+TITLE: sample issues",
1027            // Org takes the category from the file name otherwise, and every
1028            // project's file is issues.org.
1029            "#+CATEGORY: sample",
1030            "#+FILETAGS: :issues:sample:",
1031            "#+DATE:",
1032            "#+STATUS: Active",
1033            TODO_HEADER,
1034        ] {
1035            assert!(written.contains(expected), "missing {expected}: {written}");
1036        }
1037    }
1038
1039    #[test]
1040    fn generated_ids_are_unique_and_sized() {
1041        let existing = vec!["p-aaaa".to_string()];
1042        let id = generate_id("p", &existing, 4).unwrap();
1043        assert!(id.starts_with("p-"));
1044        assert!(!existing.contains(&id));
1045        assert_eq!(id.len(), 1 + 1 + 4);
1046        assert_eq!(generate_id("q", &[], 6).unwrap().len(), 1 + 1 + 6);
1047    }
1048
1049    #[test]
1050    fn a_full_suffix_space_is_an_error_and_not_a_panic() {
1051        // id_length 2 is 36^2 suffixes. Hand it every one of them and it has
1052        // to say so rather than spin or abort inside a write.
1053        let mut existing = Vec::new();
1054        for a in ID_ALPHABET {
1055            for b in ID_ALPHABET {
1056                existing.push(format!("p-{}{}", *a as char, *b as char));
1057            }
1058        }
1059        let err = generate_id("p", &existing, 2).unwrap_err();
1060        assert!(err.to_string().contains("id_length"), "{err}");
1061        // One free suffix is still found.
1062        existing.pop();
1063        assert!(generate_id("p", &existing, 2).is_ok());
1064    }
1065
1066    #[test]
1067    fn projects_are_discovered_under_the_configured_prefix() {
1068        let dir = tempfile::tempdir().unwrap();
1069        let layout = Layout::new(dir.path(), "tracker");
1070        for project in ["beta", "alpha"] {
1071            IssueDoc::empty(project, layout.project_issues_path(project))
1072                .write()
1073                .unwrap();
1074        }
1075        assert_eq!(list_projects(&layout).unwrap(), vec!["alpha", "beta"]);
1076        assert!(list_projects(&Layout::new(dir.path(), DEFAULT_PREFIX))
1077            .unwrap()
1078            .is_empty());
1079    }
1080
1081    #[test]
1082    fn project_case_resolves_to_the_directory_on_disk() {
1083        let dir = tempfile::tempdir().unwrap();
1084        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
1085        IssueDoc::empty("MixedCase", layout.project_issues_path("MixedCase"))
1086            .write()
1087            .unwrap();
1088        assert_eq!(
1089            resolve_existing_project_case(&layout, "mixedcase").unwrap(),
1090            "MixedCase"
1091        );
1092        assert_eq!(
1093            resolve_existing_project_case(&layout, "brand-new").unwrap(),
1094            "brand-new"
1095        );
1096    }
1097
1098    #[test]
1099    fn project_context_file_is_found_by_walking_up() {
1100        let dir = tempfile::tempdir().unwrap();
1101        let nested = dir.path().join("a/b/c");
1102        fs::create_dir_all(&nested).unwrap();
1103        fs::write(
1104            dir.path().join(".project-ctx.toml"),
1105            "[project]\nname = \"demoproj\"\n",
1106        )
1107        .unwrap();
1108        assert_eq!(
1109            detect_project_from_ctx(&nested).as_deref(),
1110            Some("demoproj")
1111        );
1112        let empty = tempfile::tempdir().unwrap();
1113        assert!(detect_project_from_ctx(empty.path()).is_none());
1114    }
1115
1116    fn ids(content: &str) -> Vec<&str> {
1117        org_ids(content).collect()
1118    }
1119
1120    #[test]
1121    fn a_drawer_under_a_headline_defines_an_id() {
1122        assert_eq!(
1123            ids("* TODO [#B] a title\n:PROPERTIES:\n:ID:         atlas-1a2b\n:END:\n"),
1124            ["atlas-1a2b"]
1125        );
1126    }
1127
1128    #[test]
1129    fn a_drawer_under_the_planning_line_defines_an_id() {
1130        let text = concat!(
1131            "* TODO a title\n",
1132            "DEADLINE: <2026-05-15 Fri>\n",
1133            ":PROPERTIES:\n",
1134            ":ID:         atlas-1a2b\n",
1135            ":END:\n",
1136        );
1137        assert_eq!(ids(text), ["atlas-1a2b"]);
1138    }
1139
1140    #[test]
1141    fn a_logbook_beside_the_properties_does_not_hide_it() {
1142        let text = concat!(
1143            "* TODO a title\n",
1144            ":LOGBOOK:\n",
1145            "- claimed by worker-1\n",
1146            ":END:\n",
1147            ":PROPERTIES:\n",
1148            ":ID:         atlas-1a2b\n",
1149            ":END:\n",
1150        );
1151        assert_eq!(ids(text), ["atlas-1a2b"]);
1152    }
1153
1154    #[test]
1155    fn an_id_quoted_in_a_body_defines_nothing() {
1156        // What an agent writes back when its report quotes the tracker.
1157        let text = concat!(
1158            "* TODO a title\n",
1159            ":PROPERTIES:\n",
1160            ":ID:         atlas-1a2b\n",
1161            ":END:\n",
1162            "\n",
1163            "The heading I was handed reads:\n",
1164            ":PROPERTIES:\n",
1165            ":ID: ghost-9999\n",
1166            ":END:\n",
1167        );
1168        assert_eq!(
1169            ids(text),
1170            ["atlas-1a2b"],
1171            "a report that quotes an id defined it"
1172        );
1173    }
1174
1175    #[test]
1176    fn a_bare_id_line_in_a_body_defines_nothing() {
1177        let text = concat!(
1178            "* TODO a title\n",
1179            ":PROPERTIES:\n",
1180            ":ID:         atlas-1a2b\n",
1181            ":END:\n",
1182            "\n",
1183            "Compare with :ID: ghost-9999 in the other file.\n",
1184            ":ID: ghost-8888\n",
1185        );
1186        assert_eq!(ids(text), ["atlas-1a2b"]);
1187    }
1188
1189    #[test]
1190    fn a_file_level_drawer_defines_an_id() {
1191        // Org reads one at the top, above every headline.
1192        let text = concat!(
1193            "#+TITLE: atlas issues\n",
1194            "\n",
1195            ":PROPERTIES:\n",
1196            ":ID: the-file-itself\n",
1197            ":END:\n",
1198            "\n",
1199            "* TODO a title\n",
1200            ":PROPERTIES:\n",
1201            ":ID:         atlas-1a2b\n",
1202            ":END:\n",
1203        );
1204        assert_eq!(ids(text), ["the-file-itself", "atlas-1a2b"]);
1205    }
1206
1207    #[test]
1208    fn every_headline_depth_opens_a_drawer_site() {
1209        let text = concat!(
1210            "* TODO a title\n",
1211            ":PROPERTIES:\n",
1212            ":ID:         atlas-1a2b\n",
1213            ":END:\n",
1214            "** A sub-heading someone wrote by hand\n",
1215            ":PROPERTIES:\n",
1216            ":ID:         atlas-3c4d\n",
1217            ":END:\n",
1218        );
1219        assert_eq!(ids(text), ["atlas-1a2b", "atlas-3c4d"]);
1220    }
1221
1222    #[test]
1223    fn a_planning_keyword_needs_its_colon() {
1224        assert!(is_planning_line("DEADLINE: <2026-05-15 Fri>"));
1225        assert!(is_planning_line("CLOSED: [2026-05-15 Fri]"));
1226        // Prose that opens on the same word is prose.
1227        assert!(!is_planning_line("DEADLINES slipped again"));
1228        assert!(!is_planning_line("SCHEDULED work for the week"));
1229    }
1230
1231    #[test]
1232    fn prose_that_opens_like_a_planning_line_still_ends_the_drawer_site() {
1233        // Org reads a planning line directly under the headline and nowhere
1234        // else, so this one is body text and the quoted drawer below it is
1235        // body text too.
1236        let text = concat!(
1237            "* TODO a title\n",
1238            ":PROPERTIES:\n",
1239            ":ID:         atlas-1a2b\n",
1240            ":END:\n",
1241            "\n",
1242            "DEADLINE: is discussed in the design note.\n",
1243            ":PROPERTIES:\n",
1244            ":ID: ghost-9999\n",
1245            ":END:\n",
1246        );
1247        assert_eq!(ids(text), ["atlas-1a2b"]);
1248    }
1249
1250    #[test]
1251    fn a_headline_needs_a_space_after_its_stars() {
1252        assert!(is_headline("* TODO a title"));
1253        assert!(is_headline("*** deeper"));
1254        assert!(!is_headline("**bold** at the start of a line"));
1255        assert!(!is_headline("not a headline"));
1256    }
1257}