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