Skip to main content

vissue_core/
org.rs

1//! Org 9.8 syntax the tracker has to get right.
2//!
3//! An `issues.org` is an ordinary Org document. The verbs treat a top-level
4//! TODO heading as an issue; everything else in the file is still Org, and a
5//! construct Org would not treat as a headline or a property drawer must not
6//! become one here.
7//!
8//! The rules follow the Org 9.8 manual:
9//! <https://orgmode.org/manual/>.
10
11use crate::model::TODO_KEYWORDS;
12
13/// Keywords Org writes on the planning line, in the order Org writes them.
14pub const PLANNING_KEYS: &[&str] = &["CLOSED", "SCHEDULED", "DEADLINE"];
15
16/// Whether `c` may appear in an Org tag (`[[:alnum:]_@#%]+`).
17pub fn is_org_tag_char(c: char) -> bool {
18    c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%')
19}
20
21/// A line Org reads as a headline: one or more stars at column 0, then a space.
22///
23/// Manual 2.1. A line that merely *looks* starred (`**bold**`) is not one.
24/// Leading whitespace takes the line off the left margin, so it is not one
25/// either.
26pub fn is_headline(line: &str) -> bool {
27    let stars = line.len() - line.trim_start_matches('*').len();
28    stars > 0 && line[stars..].starts_with(' ')
29}
30
31/// A level-one headline: `* ` at column 0. That is an issue site.
32pub fn is_top_level_headline(line: &str) -> bool {
33    line.starts_with("* ")
34}
35
36/// `:NAME:` alone on a line, which is how every drawer opens (manual 2.7).
37pub fn opens_a_drawer(trimmed: &str) -> bool {
38    trimmed.len() > 2
39        && trimmed.starts_with(':')
40        && trimmed.ends_with(':')
41        && !trimmed.eq_ignore_ascii_case(":END:")
42        && !trimmed[1..trimmed.len() - 1].contains(char::is_whitespace)
43}
44
45/// Nesting of greater blocks (`#+BEGIN_SRC` … `#+END_SRC`) and dynamic
46/// blocks (`#+BEGIN: clocktable` … `#+END:`).
47///
48/// Manual 2.8, 12.6, 16.2. Content inside a block is literal: a line that
49/// looks like a headline or a drawer is not one.
50#[derive(Debug, Default, Clone)]
51pub struct BlockNest {
52    depth: usize,
53}
54
55impl BlockNest {
56    /// Empty nest, at file (or heading) scope.
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Whether a previous line opened a block that has not yet closed.
62    pub fn inside(&self) -> bool {
63        self.depth > 0
64    }
65
66    /// Observe `line`. Returns true when the line is part of a block,
67    /// including the `#+BEGIN` / `#+END` lines themselves.
68    pub fn observe(&mut self, line: &str) -> bool {
69        let trimmed = line.trim_start();
70        if is_block_end(trimmed) {
71            self.depth = self.depth.saturating_sub(1);
72            return true;
73        }
74        if is_block_begin(trimmed) {
75            self.depth += 1;
76            return true;
77        }
78        self.depth > 0
79    }
80}
81
82/// Walk that hides both greater/dynamic blocks and Org Babel result
83/// regions (manual 16).
84///
85/// vissue never evaluates a source block. It only has to recognise the
86/// syntax Babel writes, so a `#+RESULTS:` payload is not an issue and
87/// does not define an `:ID:`.
88#[derive(Debug, Default, Clone)]
89pub struct OrgScan {
90    blocks: BlockNest,
91    results: ResultsState,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95enum ResultsState {
96    #[default]
97    Out,
98    /// Just saw `#+RESULTS:`; the next element is the payload.
99    Awaiting,
100    /// The payload is a greater or dynamic block.
101    ViaBlock,
102    Drawer,
103    Table,
104    FixedWidth,
105    List,
106    Headline {
107        stars: usize,
108    },
109}
110
111impl OrgScan {
112    /// Empty scan, at file (or heading) scope.
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Whether the previous line left us inside a block or a results payload.
118    pub fn inside(&self) -> bool {
119        self.blocks.inside() || !matches!(self.results, ResultsState::Out)
120    }
121
122    /// Observe `line`. Returns true when the line is not document
123    /// structure: it belongs to a block or to a Babel results element.
124    pub fn observe(&mut self, line: &str) -> bool {
125        let trimmed = line.trim_start();
126
127        if self.blocks.inside() || is_block_end(trimmed) {
128            let in_block = self.blocks.observe(line);
129            if !self.blocks.inside() && matches!(self.results, ResultsState::ViaBlock) {
130                self.results = ResultsState::Out;
131            }
132            return in_block || matches!(self.results, ResultsState::ViaBlock);
133        }
134
135        if is_results_keyword(line.trim()) {
136            self.results = ResultsState::Awaiting;
137            return true;
138        }
139
140        if is_block_begin(trimmed) {
141            if matches!(self.results, ResultsState::Awaiting) {
142                self.results = ResultsState::ViaBlock;
143            }
144            return self.blocks.observe(line);
145        }
146
147        match self.results {
148            ResultsState::Out => false,
149            ResultsState::ViaBlock => {
150                self.results = ResultsState::Out;
151                false
152            }
153            ResultsState::Awaiting => {
154                if line.trim().is_empty() {
155                    return true;
156                }
157                self.start_result_element(line)
158            }
159            ResultsState::Drawer => {
160                if line.trim().eq_ignore_ascii_case(":END:") {
161                    self.results = ResultsState::Out;
162                }
163                true
164            }
165            ResultsState::Table => {
166                if is_org_table_line(line.trim()) {
167                    true
168                } else {
169                    self.results = ResultsState::Out;
170                    self.observe(line)
171                }
172            }
173            ResultsState::FixedWidth => {
174                if is_fixed_width_line(line) {
175                    true
176                } else {
177                    self.results = ResultsState::Out;
178                    self.observe(line)
179                }
180            }
181            ResultsState::List => {
182                if line.trim().is_empty() {
183                    self.results = ResultsState::Out;
184                    return true;
185                }
186                if is_org_list_line(line) || is_list_continuation(line) {
187                    true
188                } else {
189                    self.results = ResultsState::Out;
190                    self.observe(line)
191                }
192            }
193            ResultsState::Headline { stars } => {
194                if is_headline(line) {
195                    let n = headline_stars(line);
196                    if n > 0 && n <= stars {
197                        self.results = ResultsState::Out;
198                        return self.observe(line);
199                    }
200                }
201                true
202            }
203        }
204    }
205
206    fn start_result_element(&mut self, line: &str) -> bool {
207        let trimmed = line.trim();
208        if opens_a_drawer(trimmed) {
209            self.results = ResultsState::Drawer;
210            return true;
211        }
212        if is_org_table_line(trimmed) {
213            self.results = ResultsState::Table;
214            return true;
215        }
216        if is_fixed_width_line(line) {
217            self.results = ResultsState::FixedWidth;
218            return true;
219        }
220        if is_org_list_line(line) {
221            self.results = ResultsState::List;
222            return true;
223        }
224        if is_headline(line) {
225            self.results = ResultsState::Headline {
226                stars: headline_stars(line),
227            };
228            return true;
229        }
230        // A file link, a scalar paragraph, or anything else Babel dumps as
231        // one element: this line is the payload.
232        self.results = ResultsState::Out;
233        true
234    }
235}
236
237/// `#+RESULTS:` / `#+RESULTS[hash]:` / `#+RESULTS: name` (manual 16.6).
238pub fn is_results_keyword(trimmed: &str) -> bool {
239    let Some(rest) = strip_hash_plus(trimmed.trim()) else {
240        return false;
241    };
242    let Some(after) =
243        strip_keyword_prefix(rest, "RESULTS").or_else(|| strip_keyword_prefix(rest, "RESULT"))
244    else {
245        return false;
246    };
247    let after = after.trim_start();
248    if after.starts_with(':') {
249        return true;
250    }
251    if after.starts_with('[') {
252        return after.contains(':');
253    }
254    false
255}
256
257/// `#+CALL: name(...)` (manual 16.5 / Library of Babel).
258pub fn is_babel_call(trimmed: &str) -> bool {
259    let Some(rest) = strip_hash_plus(trimmed.trim()) else {
260        return false;
261    };
262    strip_keyword_prefix(rest, "CALL").is_some_and(|after| after.starts_with(':'))
263}
264
265/// Affiliated keyword that binds to the next element (manual 16.3, org-element).
266pub fn is_affiliated_keyword(trimmed: &str) -> bool {
267    let Some(rest) = strip_hash_plus(trimmed.trim()) else {
268        return false;
269    };
270    let Some((key, _)) = rest.split_once(':') else {
271        return false;
272    };
273    let key = key.trim();
274    if starts_ignore_ascii(key, "ATTR_") {
275        return true;
276    }
277    matches!(
278        key.to_ascii_uppercase().as_str(),
279        "CAPTION"
280            | "DATA"
281            | "HEADER"
282            | "HEADERS"
283            | "LABEL"
284            | "NAME"
285            | "PLOT"
286            | "RESNAME"
287            | "RESULT"
288            | "RESULTS"
289            | "SOURCE"
290            | "SRCNAME"
291            | "TBLNAME"
292    )
293}
294
295/// The `#+BEGIN_SRC lang switches :headers` line (manual 16.2).
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct SrcBlockHead<'a> {
298    /// Language token, `python` or `org`.
299    pub lang: &'a str,
300    /// Switches such as `-n -r`.
301    pub switches: &'a str,
302    /// Header arguments, starting at the first `:key`.
303    pub headers: &'a str,
304}
305
306/// Parse a source-block opening line. Other greater blocks yield `None`.
307pub fn parse_src_begin(line: &str) -> Option<SrcBlockHead<'_>> {
308    let rest = strip_hash_plus(line.trim_start())?;
309    let after = strip_keyword_prefix(rest, "BEGIN_SRC")?;
310    let after = after.trim_start();
311    if after.is_empty() {
312        return None;
313    }
314    let (lang, rest) = first_word(after).unwrap_or((after, ""));
315    let rest = rest.trim_start();
316    let (switches, headers) = match rest.find(':') {
317        Some(i) => (rest[..i].trim(), rest[i..].trim()),
318        None => (rest.trim(), ""),
319    };
320    Some(SrcBlockHead {
321        lang,
322        switches,
323        headers,
324    })
325}
326
327/// `:key value` pairs from a header-args string (manual 16.3).
328pub fn parse_header_args(s: &str) -> Vec<(String, String)> {
329    let mut out = Vec::new();
330    let mut rest = s.trim();
331    while let Some(idx) = rest.find(':') {
332        rest = rest[idx + 1..].trim_start();
333        if rest.is_empty() {
334            break;
335        }
336        let (key, after) = match rest.find(char::is_whitespace) {
337            Some(i) => (&rest[..i], rest[i..].trim_start()),
338            None => (rest, ""),
339        };
340        if key.is_empty() {
341            break;
342        }
343        let (value, next) = next_header_value(after);
344        out.push((key.to_string(), value.to_string()));
345        rest = next;
346    }
347    out
348}
349
350fn next_header_value(s: &str) -> (&str, &str) {
351    if s.is_empty() || s.starts_with(':') {
352        return ("", s);
353    }
354    let bytes = s.as_bytes();
355    let mut i = 0;
356    while i < bytes.len() {
357        if bytes[i] == b':' && (i == 0 || bytes[i - 1].is_ascii_whitespace()) {
358            break;
359        }
360        i += 1;
361    }
362    // Back up so we split on a char boundary.
363    while i > 0 && !s.is_char_boundary(i) {
364        i -= 1;
365    }
366    (s[..i].trim(), s[i..].trim_start())
367}
368
369/// Noweb references `<<name>>` / `<<name(args)>>` (manual 16.11).
370pub fn noweb_refs(body: &str) -> Vec<&str> {
371    let mut refs = Vec::new();
372    let mut rest = body;
373    while let Some(start) = rest.find("<<") {
374        let after = &rest[start + 2..];
375        let Some(end) = after.find(">>") else {
376            break;
377        };
378        let inner = after[..end].trim();
379        if !inner.is_empty() && !inner.contains('\n') && !refs.contains(&inner) {
380            refs.push(inner);
381        }
382        rest = &after[end + 2..];
383    }
384    refs
385}
386
387/// Inline `src_lang{body}` / `src_lang[headers]{body}` (manual 16.2).
388pub fn inline_src_spans(text: &str) -> Vec<(&str, &str, &str)> {
389    let mut out = Vec::new();
390    let mut rest = text;
391    while let Some(idx) = rest.find("src_") {
392        let after = &rest[idx + 4..];
393        let lang_len = after
394            .find(|c: char| c.is_whitespace() || c == '[' || c == '{')
395            .unwrap_or(after.len());
396        if lang_len == 0 {
397            rest = &after[1.min(after.len())..];
398            continue;
399        }
400        let lang = &after[..lang_len];
401        let mut tail = &after[lang_len..];
402        let mut headers = "";
403        if let Some(inner) = tail.strip_prefix('[') {
404            let Some(end) = inner.find(']') else {
405                rest = tail;
406                continue;
407            };
408            headers = &inner[..end];
409            tail = &inner[end + 1..];
410        }
411        let Some(inner) = tail.strip_prefix('{') else {
412            rest = tail;
413            continue;
414        };
415        let Some(end) = inner.find('}') else {
416            rest = tail;
417            continue;
418        };
419        out.push((lang, headers, &inner[..end]));
420        rest = &inner[end + 1..];
421    }
422    out
423}
424
425/// Inline `call_name(args)` / `call_name[hdr](args)` (manual 16.5).
426pub fn inline_call_names(text: &str) -> Vec<&str> {
427    let mut out = Vec::new();
428    let mut rest = text;
429    while let Some(idx) = rest.find("call_") {
430        let after = &rest[idx + 5..];
431        let name_len = after
432            .find(|c: char| c.is_whitespace() || c == '[' || c == '(')
433            .unwrap_or(after.len());
434        if name_len == 0 {
435            rest = &after[1.min(after.len())..];
436            continue;
437        }
438        let name = &after[..name_len];
439        let tail = &after[name_len..];
440        if tail.starts_with('(') || tail.starts_with('[') {
441            out.push(name);
442        }
443        rest = tail;
444    }
445    out
446}
447
448fn strip_hash_plus(trimmed: &str) -> Option<&str> {
449    trimmed.strip_prefix("#+")
450}
451
452fn strip_keyword_prefix<'a>(s: &'a str, keyword: &str) -> Option<&'a str> {
453    if s.len() >= keyword.len()
454        && s.is_char_boundary(keyword.len())
455        && s[..keyword.len()].eq_ignore_ascii_case(keyword)
456    {
457        Some(&s[keyword.len()..])
458    } else {
459        None
460    }
461}
462
463fn headline_stars(line: &str) -> usize {
464    line.len() - line.trim_start_matches('*').len()
465}
466
467fn is_org_table_line(trimmed: &str) -> bool {
468    trimmed.starts_with('|')
469}
470
471fn is_fixed_width_line(line: &str) -> bool {
472    let trimmed = line.trim_start();
473    matches!(
474        trimmed.as_bytes(),
475        [b':'] | [b':', b' ', ..] | [b':', b'\t', ..]
476    ) && !opens_a_drawer(trimmed)
477        && !trimmed.eq_ignore_ascii_case(":END:")
478}
479
480fn is_org_list_line(line: &str) -> bool {
481    if is_headline(line) {
482        return false;
483    }
484    let trimmed = line.trim_start();
485    if trimmed.starts_with("- ") || trimmed.starts_with("+ ") {
486        return true;
487    }
488    let Some((token, rest)) = first_word(trimmed) else {
489        return false;
490    };
491    let rest = rest.trim_start();
492    if rest.is_empty() && !token.ends_with('.') && !token.ends_with(')') {
493        return false;
494    }
495    let bare = token.trim_end_matches(['.', ')']);
496    if bare.is_empty() || bare == token {
497        return false;
498    }
499    bare.chars().all(|c| c.is_ascii_digit())
500        || (bare.len() == 1 && bare.chars().all(|c| c.is_ascii_alphabetic()))
501}
502
503fn is_list_continuation(line: &str) -> bool {
504    !is_headline(line)
505        && (line.starts_with(' ') || line.starts_with('\t'))
506        && !line.trim().is_empty()
507}
508
509fn is_block_begin(trimmed: &str) -> bool {
510    let Some(rest) = trimmed.strip_prefix("#+") else {
511        return false;
512    };
513    starts_ignore_ascii(rest, "BEGIN_") || starts_ignore_ascii(rest, "BEGIN:")
514}
515
516fn is_block_end(trimmed: &str) -> bool {
517    let Some(rest) = trimmed.strip_prefix("#+") else {
518        return false;
519    };
520    starts_ignore_ascii(rest, "END_") || starts_ignore_ascii(rest, "END:")
521}
522
523fn starts_ignore_ascii(s: &str, prefix: &str) -> bool {
524    s.len() >= prefix.len()
525        && s.is_char_boundary(prefix.len())
526        && s[..prefix.len()].eq_ignore_ascii_case(prefix)
527}
528
529/// File-local TODO keywords from `#+TODO:` lines, plus the house set.
530///
531/// Manual 5.2.5. Fast-access keys (`TODO(t)`, `WAIT(w@)`) are stripped.
532/// Several `#+TODO:` lines accumulate. The house keywords stay recognised
533/// so a preamble that only lists a subset does not drop STARTED headings.
534pub fn todo_keywords_from_preamble(preamble: &str) -> Vec<String> {
535    todo_keywords_from_lines(&preamble.lines().collect::<Vec<_>>())
536}
537
538/// The file's TODO sequence as Org reads it: the keywords before the `|`
539/// are open, the ones after it are done; a line with no `|` makes its last
540/// keyword the done one (Org manual 5.2.1). The house five are always in.
541#[derive(Debug, Clone, PartialEq, Eq)]
542pub struct TodoSequence {
543    /// Keywords an issue can still be worked under.
544    pub open: Vec<String>,
545    /// Keywords that close an issue.
546    pub done: Vec<String>,
547}
548
549impl Default for TodoSequence {
550    fn default() -> Self {
551        Self::house()
552    }
553}
554
555impl TodoSequence {
556    /// The house five alone.
557    #[must_use]
558    pub fn house() -> Self {
559        Self {
560            open: ["TODO", "STARTED", "BLOCKED"].map(str::to_string).to_vec(),
561            done: ["DONE", "CANCELLED"].map(str::to_string).to_vec(),
562        }
563    }
564
565    /// Whether `state` is a keyword this file declares, either side.
566    #[must_use]
567    pub fn knows(&self, state: &str) -> bool {
568        self.open.iter().any(|k| k == state) || self.done.iter().any(|k| k == state)
569    }
570
571    /// Whether `state` closes an issue in this file.
572    #[must_use]
573    pub fn is_done(&self, state: &str) -> bool {
574        self.done.iter().any(|k| k == state)
575    }
576
577    /// Every keyword, open ones first, for a message.
578    #[must_use]
579    pub fn all(&self) -> Vec<String> {
580        self.open.iter().chain(self.done.iter()).cloned().collect()
581    }
582}
583
584/// The TODO sequence of a file from its `#+TODO:`, `#+SEQ_TODO:` and
585/// `#+TYP_TODO:` lines (Org manual 5.2.4), over the house five.
586#[must_use]
587pub fn todo_sequence_from_lines(lines: &[&str]) -> TodoSequence {
588    let mut seq = TodoSequence::house();
589    for line in lines {
590        let trimmed = line.trim();
591        let Some(rest) = ["TODO", "SEQ_TODO", "TYP_TODO"]
592            .iter()
593            .find_map(|name| strip_file_keyword(trimmed, name))
594        else {
595            continue;
596        };
597        let names: Vec<&str> = rest
598            .split_whitespace()
599            .map(|token| token.split('(').next().unwrap_or(token))
600            .filter(|name| !name.is_empty())
601            .collect();
602        let bar = names.iter().position(|n| *n == "|");
603        // No bar: Org takes the last keyword as the done one.
604        let done_from = bar.map_or(names.len().saturating_sub(1), |b| b + 1);
605        for (i, name) in names.iter().enumerate() {
606            if *name == "|" || seq.knows(name) {
607                continue;
608            }
609            if i >= done_from {
610                seq.done.push((*name).to_string());
611            } else {
612                seq.open.push((*name).to_string());
613            }
614        }
615    }
616    seq
617}
618
619/// Whether an Org timestamp carries a repeater: `+1w`, `++1w`, `.+1w`
620/// (Org manual 8.3.3).
621#[must_use]
622pub fn has_repeater(value: &str) -> bool {
623    value
624        .split_whitespace()
625        .any(|tok| parse_repeater(tok.trim_end_matches('>')).is_some())
626}
627
628/// `(kind, count, unit)` of a repeater token, kind being `+`, `++` or `.+`.
629fn parse_repeater(tok: &str) -> Option<(&str, u32, char)> {
630    let (kind, rest) = if let Some(r) = tok.strip_prefix(".+") {
631        (".+", r)
632    } else if let Some(r) = tok.strip_prefix("++") {
633        ("++", r)
634    } else {
635        ("+", tok.strip_prefix('+')?)
636    };
637    let unit = rest.chars().last()?;
638    if !"hdwmy".contains(unit) {
639        return None;
640    }
641    let count: u32 = rest[..rest.len() - 1].parse().ok()?;
642    (count > 0).then_some((kind, count, unit))
643}
644
645fn add_interval(date: chrono::NaiveDate, count: u32, unit: char) -> Option<chrono::NaiveDate> {
646    use chrono::{Days, Months};
647    match unit {
648        'd' => date.checked_add_days(Days::new(u64::from(count))),
649        'w' => date.checked_add_days(Days::new(u64::from(count) * 7)),
650        'm' => date.checked_add_months(Months::new(count)),
651        'y' => date.checked_add_months(Months::new(count * 12)),
652        // An hourly repeater on a date without a time has nothing to move.
653        _ => Some(date),
654    }
655}
656
657/// The timestamp after one repeat, as Org shifts it when a repeating task
658/// is marked done (manual 8.3.3): `+` moves one interval on from the date
659/// it holds, `++` moves by intervals until the date is past `today`, `.+`
660/// moves one interval on from `today`. The day name follows the date;
661/// time, repeater and warning stay as written. A timestamp without a
662/// repeater, or a range, is `None`.
663#[must_use]
664pub fn shift_repeating_timestamp(value: &str, today: chrono::NaiveDate) -> Option<String> {
665    let trimmed = value.trim();
666    if trimmed.contains("--") {
667        return None;
668    }
669    let (open, close) = if trimmed.starts_with('<') && trimmed.ends_with('>') {
670        ('<', '>')
671    } else if trimmed.starts_with('[') && trimmed.ends_with(']') {
672        ('[', ']')
673    } else {
674        return None;
675    };
676    let inner = &trimmed[1..trimmed.len() - 1];
677    let tokens: Vec<&str> = inner.split_whitespace().collect();
678    let date_tok = tokens.first()?;
679    let mut date = chrono::NaiveDate::parse_from_str(date_tok, "%Y-%m-%d").ok()?;
680    let mut rest: Vec<String> = Vec::new();
681    let mut repeater = None;
682    for tok in &tokens[1..] {
683        if let Some(r) = parse_repeater(tok) {
684            repeater = Some(r);
685            rest.push((*tok).to_string());
686        } else if tok.chars().all(|c| c.is_ascii_alphabetic())
687            && tok.len() <= 3
688            && repeater.is_none()
689        {
690            // The day name; recomputed below.
691        } else {
692            rest.push((*tok).to_string());
693        }
694    }
695    let (kind, count, unit) = repeater?;
696    date = match kind {
697        "+" => add_interval(date, count, unit)?,
698        "++" => {
699            let mut next = add_interval(date, count, unit)?;
700            let mut guard = 0;
701            while next <= today && guard < 10_000 {
702                next = add_interval(next, count, unit)?;
703                guard += 1;
704            }
705            next
706        }
707        _ => add_interval(today, count, unit)?,
708    };
709    let mut out = format!("{open}{} {}", date.format("%Y-%m-%d"), date.format("%a"));
710    for tok in rest {
711        out.push(' ');
712        out.push_str(&tok);
713    }
714    out.push(close);
715    Some(out)
716}
717
718/// Same as [`todo_keywords_from_preamble`], from already-split lines.
719pub fn todo_keywords_from_lines(lines: &[&str]) -> Vec<String> {
720    let mut keywords: Vec<String> = TODO_KEYWORDS.iter().map(|s| (*s).to_string()).collect();
721    for line in lines {
722        let trimmed = line.trim();
723        let Some(rest) = strip_file_keyword(trimmed, "TODO") else {
724            continue;
725        };
726        for token in rest.split_whitespace() {
727            if token == "|" {
728                continue;
729            }
730            let name = token.split('(').next().unwrap_or(token);
731            if name.is_empty() {
732                continue;
733            }
734            if !keywords.iter().any(|k| k == name) {
735                keywords.push(name.to_string());
736            }
737        }
738    }
739    keywords
740}
741
742/// Tags from `#+FILETAGS:` (manual 6 / in-buffer settings).
743pub fn filetags_from_preamble(preamble: &str) -> Vec<String> {
744    tag_settings_from_preamble(preamble).filetags
745}
746
747/// A declared tag on `#+TAGS:`, with an optional fast-selection key.
748#[derive(Debug, Clone, PartialEq, Eq)]
749pub struct TagSpec {
750    /// Tag text, Org's `[[:alnum:]_@#%]+` or a `{regex}` group member.
751    pub name: String,
752    /// Fast tag selection key (`TAG(k)`).
753    pub key: Option<char>,
754}
755
756/// File-level tag, export, and publish settings (manual 6, 13.2, 17.8).
757#[derive(Debug, Clone, PartialEq, Eq)]
758pub struct TagSettings {
759    /// Tags every heading inherits, as if from a hypothetical level 0.
760    pub filetags: Vec<String>,
761    /// Tags declared on `#+TAGS:` lines, in file order.
762    pub declared: Vec<TagSpec>,
763    /// Mutually exclusive groups `{ a b }`.
764    pub exclusive: Vec<Vec<String>>,
765    /// Group tag then members: `[ GTD : Control Persp ]`.
766    pub hierarchies: Vec<(String, Vec<String>)>,
767    /// `#+SELECT_TAGS:`; Org's default is `export`.
768    pub select_tags: Vec<String>,
769    /// `#+EXCLUDE_TAGS:`; Org's default is `noexport`.
770    pub exclude_tags: Vec<String>,
771}
772
773impl Default for TagSettings {
774    fn default() -> Self {
775        Self {
776            filetags: Vec::new(),
777            declared: Vec::new(),
778            exclusive: Vec::new(),
779            hierarchies: Vec::new(),
780            select_tags: vec!["export".into()],
781            exclude_tags: vec!["noexport".into()],
782        }
783    }
784}
785
786impl TagSettings {
787    /// Own tags plus inherited FILETAGS, which is Org's ALLTAGS for a
788    /// level-one heading (manual 6.1). FILETAGS are not copied onto the
789    /// heading on write.
790    pub fn all_tags(&self, own: &[String]) -> Vec<String> {
791        let mut tags = own.to_vec();
792        for tag in &self.filetags {
793            if !tags.iter().any(|seen| seen == tag) {
794                tags.push(tag.clone());
795            }
796        }
797        tags
798    }
799
800    /// Whether `needle` matches an own tag, an inherited FILETAGS tag, or
801    /// a group tag whose members the heading carries (manual 6.3).
802    pub fn matches_query(&self, own: &[String], needle: &str) -> bool {
803        let needle_l = needle.to_lowercase();
804        if needle_l.is_empty() {
805            return false;
806        }
807        let all = self.all_tags(own);
808        if all.iter().any(|tag| tag.to_lowercase().contains(&needle_l)) {
809            return true;
810        }
811        for (group, members) in &self.hierarchies {
812            if !group.to_lowercase().contains(&needle_l) {
813                continue;
814            }
815            if members
816                .iter()
817                .any(|m| all.iter().any(|tag| tag.eq_ignore_ascii_case(m)))
818            {
819                return true;
820            }
821        }
822        false
823    }
824
825    /// Whether this heading's own tags exclude it from Org export.
826    ///
827    /// FILETAGS `:noexport:` is a file-level publish signal and does not
828    /// hide the heading from a vissue mirror. A heading tagged `noexport`
829    /// (or another `#+EXCLUDE_TAGS:` token) is dropped. `noexport` wins
830    /// over `export` (manual 13.2).
831    pub fn heading_exportable(&self, own: &[String]) -> bool {
832        !own.iter().any(|tag| {
833            self.exclude_tags
834                .iter()
835                .any(|ex| ex.eq_ignore_ascii_case(tag))
836        })
837    }
838}
839
840/// Parse `#+FILETAGS:`, `#+TAGS:`, `#+SELECT_TAGS:`, `#+EXCLUDE_TAGS:`.
841pub fn tag_settings_from_preamble(preamble: &str) -> TagSettings {
842    let mut settings = TagSettings::default();
843    let mut saw_select = false;
844    let mut saw_exclude = false;
845    for line in preamble.lines() {
846        let trimmed = line.trim();
847        if let Some(rest) = strip_file_keyword(trimmed, "FILETAGS") {
848            for tag in rest.trim().trim_matches(':').split(':') {
849                let tag = tag.trim();
850                if !tag.is_empty()
851                    && tag.chars().all(is_org_tag_char)
852                    && !settings.filetags.iter().any(|t| t == tag)
853                {
854                    settings.filetags.push(tag.to_string());
855                }
856            }
857            continue;
858        }
859        if let Some(rest) = strip_file_keyword(trimmed, "TAGS") {
860            apply_tags_line(&mut settings, rest);
861            continue;
862        }
863        if let Some(rest) = strip_file_keyword(trimmed, "SELECT_TAGS") {
864            settings.select_tags = split_keyword_tags(rest);
865            saw_select = true;
866            continue;
867        }
868        if let Some(rest) = strip_file_keyword(trimmed, "EXCLUDE_TAGS") {
869            settings.exclude_tags = split_keyword_tags(rest);
870            saw_exclude = true;
871        }
872    }
873    if !saw_select {
874        settings.select_tags = vec!["export".into()];
875    }
876    if !saw_exclude {
877        settings.exclude_tags = vec!["noexport".into()];
878    }
879    settings
880}
881
882fn split_keyword_tags(rest: &str) -> Vec<String> {
883    let mut tags = Vec::new();
884    for tag in rest
885        .split(|c: char| c.is_whitespace() || c == ':' || c == ',')
886        .map(str::trim)
887        .filter(|t| !t.is_empty())
888    {
889        if tag.chars().all(is_org_tag_char) && !tags.iter().any(|t| t == tag) {
890            tags.push(tag.to_string());
891        }
892    }
893    tags
894}
895
896fn apply_tags_line(settings: &mut TagSettings, rest: &str) {
897    let tokens = tokenize_tags_line(rest);
898    let mut i = 0;
899    while i < tokens.len() {
900        match tokens[i].as_str() {
901            "{" | "[" => {
902                let exclusive = tokens[i] == "{";
903                let closer = if exclusive { "}" } else { "]" };
904                i += 1;
905                let mut names: Vec<TagSpec> = Vec::new();
906                let mut hierarchy_at = None;
907                while i < tokens.len() && tokens[i] != closer {
908                    if tokens[i] == ":" {
909                        hierarchy_at = Some(names.len());
910                        i += 1;
911                        continue;
912                    }
913                    if let Some(spec) = parse_tag_token(&tokens[i]) {
914                        names.push(spec);
915                    }
916                    i += 1;
917                }
918                if i < tokens.len() && tokens[i] == closer {
919                    i += 1;
920                }
921                let names_only: Vec<String> = names.iter().map(|s| s.name.clone()).collect();
922                if let Some(split) = hierarchy_at {
923                    if split >= 1 {
924                        let group = names[0].name.clone();
925                        let members: Vec<String> = names_only.into_iter().skip(split).collect();
926                        settings.hierarchies.push((group, members));
927                    }
928                } else if exclusive && names_only.len() >= 2 {
929                    settings.exclusive.push(names_only);
930                }
931                for spec in names {
932                    if !settings.declared.iter().any(|d| d.name == spec.name) {
933                        settings.declared.push(spec);
934                    }
935                }
936            }
937            "\\n" => i += 1,
938            other => {
939                if let Some(spec) = parse_tag_token(other)
940                    && !settings.declared.iter().any(|d| d.name == spec.name)
941                {
942                    settings.declared.push(spec);
943                }
944                i += 1;
945            }
946        }
947    }
948}
949
950fn tokenize_tags_line(rest: &str) -> Vec<String> {
951    let mut tokens = Vec::new();
952    let chars: Vec<char> = rest.chars().collect();
953    let mut i = 0;
954    while i < chars.len() {
955        let c = chars[i];
956        if c.is_whitespace() {
957            i += 1;
958            continue;
959        }
960        if matches!(c, '{' | '}' | '[' | ']' | ':') {
961            tokens.push(c.to_string());
962            i += 1;
963            continue;
964        }
965        if c == '\\' && chars.get(i + 1) == Some(&'n') {
966            tokens.push("\\n".into());
967            i += 2;
968            continue;
969        }
970        let start = i;
971        while i < chars.len()
972            && !chars[i].is_whitespace()
973            && !matches!(chars[i], '{' | '}' | '[' | ']' | ':')
974        {
975            i += 1;
976        }
977        tokens.push(chars[start..i].iter().collect());
978    }
979    tokens
980}
981
982fn parse_tag_token(token: &str) -> Option<TagSpec> {
983    let token = token.trim();
984    if token.is_empty() {
985        return None;
986    }
987    if let Some(name) = token.strip_suffix(')')
988        && let Some((name, key)) = name.rsplit_once('(')
989    {
990        let name = name.trim();
991        let key = key.trim();
992        if !name.is_empty() && name.chars().all(is_org_tag_char) && key.chars().count() == 1 {
993            return Some(TagSpec {
994                name: name.to_string(),
995                key: key.chars().next(),
996            });
997        }
998    }
999    if token.chars().all(is_org_tag_char) {
1000        return Some(TagSpec {
1001            name: token.to_string(),
1002            key: None,
1003        });
1004    }
1005    None
1006}
1007
1008/// House `#+TAGS:` lines: types are mutually exclusive; the rest are loose.
1009pub const HOUSE_TAGS_LINES: &[&str] = &[
1010    "#+TAGS: { bug(b) feature(f) task(t) chore(c) plan(p) }",
1011    "#+TAGS: docs(d) perf ignore ARCHIVE",
1012];
1013
1014/// House `#+PRIORITIES:`: highest `A`, lowest `C`, default `C`.
1015///
1016/// Org's own default cookie is `B`. The tracker defaults to `C` so an
1017/// unprioritised heading is the lowest cookie, not the middle one.
1018pub const HOUSE_PRIORITIES_LINE: &str = "#+PRIORITIES: A C C";
1019
1020/// On-disk `issues.org` contract. Independent of the crate version and of
1021/// the control-socket protocol.
1022///
1023/// 1 is the house Org shape: `#+CATEGORY:`, `#+FILETAGS:` with `noexport`,
1024/// the type `#+TAGS:` group, `#+PRIORITIES: A C C`, `#+SELECT_TAGS:` /
1025/// `#+EXCLUDE_TAGS:`, type as a heading tag, `:BLOCKED_BY:` as the graph,
1026/// and `:BLOCKER:` as org-edna (read, never minted).
1027pub const PROTOCOL_VERSION: u32 = 1;
1028
1029/// In-buffer keyword that carries [`PROTOCOL_VERSION`].
1030pub const PROTOCOL_KEYWORD: &str = "VISSUE";
1031
1032/// Protocol integer from `#+VISSUE:`, when the line parses.
1033pub fn protocol_from_preamble(preamble: &str) -> Option<u32> {
1034    for line in preamble.lines() {
1035        if let Some(n) = protocol_from_keyword_line(line) {
1036            return Some(n);
1037        }
1038    }
1039    None
1040}
1041
1042fn protocol_from_keyword_line(line: &str) -> Option<u32> {
1043    let rest = strip_file_keyword(line.trim(), PROTOCOL_KEYWORD)?;
1044    let mut parts = rest.split_whitespace();
1045    let first = parts.next()?;
1046    if first.eq_ignore_ascii_case("protocol") {
1047        parts.next()?.parse().ok()
1048    } else {
1049        first
1050            .strip_prefix("protocol=")
1051            .unwrap_or(first)
1052            .parse()
1053            .ok()
1054    }
1055}
1056
1057fn protocol_stamp_line() -> String {
1058    format!("#+{PROTOCOL_KEYWORD}: {PROTOCOL_VERSION}")
1059}
1060
1061/// File-local priority cookie range (`#+PRIORITIES: highest lowest default`).
1062///
1063/// Org requires the highest cookie to have a lower ASCII value than the
1064/// lowest (`A` before `C`). The third token is the default when a heading
1065/// has no `[#X]`.
1066#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1067pub struct PrioritySpec {
1068    /// Highest priority cookie (`A` in the house file).
1069    pub highest: char,
1070    /// Lowest priority cookie (`C` in the house file).
1071    pub lowest: char,
1072    /// Cookie written when the heading has none.
1073    pub default: char,
1074}
1075
1076impl Default for PrioritySpec {
1077    fn default() -> Self {
1078        Self {
1079            highest: 'A',
1080            lowest: 'C',
1081            default: 'C',
1082        }
1083    }
1084}
1085
1086impl PrioritySpec {
1087    /// Whether `cookie` sits in this file's range, inclusive.
1088    pub fn contains(self, cookie: char) -> bool {
1089        let (lo, hi) = if self.highest <= self.lowest {
1090            (self.highest, self.lowest)
1091        } else {
1092            (self.lowest, self.highest)
1093        };
1094        cookie >= lo && cookie <= hi
1095    }
1096}
1097
1098/// `#+PRIORITIES:` from the preamble, or `A C C`.
1099pub fn priorities_from_preamble(preamble: &str) -> PrioritySpec {
1100    for line in preamble.lines() {
1101        let Some(rest) = strip_file_keyword(line.trim(), "PRIORITIES") else {
1102            continue;
1103        };
1104        let mut toks = rest.split_whitespace();
1105        let (Some(h), Some(l), Some(d)) = (toks.next(), toks.next(), toks.next()) else {
1106            continue;
1107        };
1108        let (Some(highest), Some(lowest), Some(default)) =
1109            (h.chars().next(), l.chars().next(), d.chars().next())
1110        else {
1111            continue;
1112        };
1113        return PrioritySpec {
1114            highest,
1115            lowest,
1116            default,
1117        };
1118    }
1119    PrioritySpec::default()
1120}
1121
1122/// An org-gcal event id (`<event>/<calendar>`), not an org-id / vissue id.
1123pub fn is_gcal_event_id(id: &str) -> bool {
1124    let id = id.trim();
1125    let Some((left, right)) = id.split_once('/') else {
1126        return false;
1127    };
1128    !left.is_empty()
1129        && !right.is_empty()
1130        && !left.contains(char::is_whitespace)
1131        && !right.contains(char::is_whitespace)
1132}
1133
1134/// Org treats a non-empty property other than `nil` / `0` as true.
1135pub fn org_property_is_set(
1136    properties: &std::collections::BTreeMap<String, String>,
1137    key: &str,
1138) -> bool {
1139    properties.get(key).is_some_and(|raw| {
1140        let v = raw.trim();
1141        !v.is_empty() && !v.eq_ignore_ascii_case("nil") && v != "0"
1142    })
1143}
1144
1145/// In-buffer settings from `#+SETUPFILE:` plus the file's own preamble.
1146///
1147/// Local files only. A URL is left unread. Cycles and a missing file are
1148/// skipped so a tracker still parses.
1149pub fn merge_setupfile_settings(preamble: &str, base_dir: Option<&std::path::Path>) -> String {
1150    let mut seen = std::collections::HashSet::new();
1151    let mut out = String::new();
1152    collect_setupfile_settings(preamble, base_dir, &mut seen, 0, &mut out);
1153    if !out.is_empty() && !out.ends_with('\n') {
1154        out.push('\n');
1155    }
1156    out.push_str(preamble);
1157    out
1158}
1159
1160fn collect_setupfile_settings(
1161    text: &str,
1162    base_dir: Option<&std::path::Path>,
1163    seen: &mut std::collections::HashSet<std::path::PathBuf>,
1164    depth: u8,
1165    out: &mut String,
1166) {
1167    if depth > 16 {
1168        return;
1169    }
1170    for line in text.lines() {
1171        let Some(rest) = strip_file_keyword(line.trim(), "SETUPFILE") else {
1172            continue;
1173        };
1174        let spec = rest.trim().trim_matches('"').trim_matches('\'').trim();
1175        if spec.is_empty()
1176            || spec.contains("://")
1177            || spec.starts_with("http:")
1178            || spec.starts_with("https:")
1179        {
1180            continue;
1181        }
1182        let path = match base_dir {
1183            Some(dir) => dir.join(spec),
1184            None => std::path::PathBuf::from(spec),
1185        };
1186        let key = path.canonicalize().unwrap_or(path.clone());
1187        if !seen.insert(key) {
1188            continue;
1189        }
1190        let Ok(body) = std::fs::read_to_string(&path) else {
1191            continue;
1192        };
1193        let keywords: String = body
1194            .lines()
1195            .filter(|l| l.trim_start().starts_with("#+"))
1196            .collect::<Vec<_>>()
1197            .join("\n");
1198        if !keywords.is_empty() {
1199            out.push_str(&keywords);
1200            out.push('\n');
1201        }
1202        collect_setupfile_settings(&keywords, path.parent(), seen, depth + 1, out);
1203    }
1204}
1205
1206/// Whether the preamble already carries `#+NAME:`.
1207pub fn preamble_has_keyword(preamble: &str, name: &str) -> bool {
1208    preamble
1209        .lines()
1210        .any(|line| strip_file_keyword(line.trim(), name).is_some())
1211}
1212
1213/// Insert the house in-buffer settings a hand-started file never grew.
1214///
1215/// Org takes the category from the file name otherwise, and every
1216/// project's file is `issues.org`. A missing `#+TAGS:` means Emacs
1217/// fast-tag selection has no type group. `#+FILETAGS:` includes
1218/// `noexport` so a vault publish project skips the tracker (manual 13.2,
1219/// 14). A FILETAGS line that already exists but has no `noexport` gets
1220/// that tag appended.
1221pub fn ensure_org_preamble(preamble: &str, project: &str) -> String {
1222    if preamble.trim().is_empty() {
1223        return preamble.to_string();
1224    }
1225    let mut lines: Vec<String> = preamble.lines().map(str::to_string).collect();
1226    let insert_at = lines
1227        .iter()
1228        .position(|line| strip_file_keyword(line.trim(), "TITLE").is_some())
1229        .map(|i| i + 1)
1230        .unwrap_or(0);
1231    let mut extra = Vec::new();
1232    if !preamble_has_keyword(preamble, "CATEGORY") {
1233        extra.push(format!("#+CATEGORY: {project}"));
1234    }
1235    if !preamble_has_keyword(preamble, "FILETAGS") {
1236        extra.push(format!("#+FILETAGS: :issues:{project}:noexport:"));
1237    }
1238    if !preamble_has_keyword(preamble, "TAGS") {
1239        extra.extend(HOUSE_TAGS_LINES.iter().map(|s| (*s).to_string()));
1240    }
1241    if !preamble_has_keyword(preamble, "EXCLUDE_TAGS") {
1242        extra.push("#+EXCLUDE_TAGS: noexport".to_string());
1243    }
1244    if !preamble_has_keyword(preamble, "SELECT_TAGS") {
1245        extra.push("#+SELECT_TAGS: export".to_string());
1246    }
1247    if !preamble_has_keyword(preamble, "PRIORITIES") {
1248        extra.push(HOUSE_PRIORITIES_LINE.to_string());
1249    }
1250    for (offset, line) in extra.into_iter().enumerate() {
1251        lines.insert(insert_at + offset, line);
1252    }
1253    ensure_filetags_has_noexport(&mut lines);
1254    ensure_protocol_stamp(&mut lines);
1255    let out = lines.join("\n");
1256    if out == preamble {
1257        preamble.to_string()
1258    } else {
1259        out
1260    }
1261}
1262
1263fn ensure_protocol_stamp(lines: &mut Vec<String>) {
1264    let stamp = protocol_stamp_line();
1265    for line in lines.iter_mut() {
1266        if strip_file_keyword(line.trim(), PROTOCOL_KEYWORD).is_none() {
1267            continue;
1268        }
1269        match protocol_from_keyword_line(line) {
1270            Some(n) if n >= PROTOCOL_VERSION => {}
1271            _ => *line = stamp,
1272        }
1273        return;
1274    }
1275    let insert_at = lines
1276        .iter()
1277        .position(|line| strip_file_keyword(line.trim(), "TITLE").is_some())
1278        .map(|i| i + 1)
1279        .unwrap_or(0);
1280    lines.insert(insert_at, stamp);
1281}
1282
1283fn ensure_filetags_has_noexport(lines: &mut [String]) {
1284    for line in lines.iter_mut() {
1285        let Some(rest) = strip_file_keyword(line.trim(), "FILETAGS") else {
1286            continue;
1287        };
1288        let tags: Vec<&str> = rest
1289            .trim()
1290            .trim_matches(':')
1291            .split(':')
1292            .map(str::trim)
1293            .filter(|t| !t.is_empty())
1294            .collect();
1295        if tags.iter().any(|t| t.eq_ignore_ascii_case("noexport")) {
1296            return;
1297        }
1298        let mut all = tags;
1299        all.push("noexport");
1300        *line = format!("#+FILETAGS: :{}:", all.join(":"));
1301        return;
1302    }
1303}
1304
1305/// Move classifiers Org can hold onto the heading: a legal `:TYPE:` and
1306/// any legal token in `:VISSUE_TAGS:`. Hyphenated leftovers stay in the
1307/// property. `:TYPE:` itself is kept so export and `--type` filters still
1308/// read it.
1309pub fn settle_heading_classifiers(
1310    org_tags: &mut Vec<String>,
1311    properties: &mut std::collections::BTreeMap<String, String>,
1312) {
1313    fn push_tag(org_tags: &mut Vec<String>, tag: &str) {
1314        if !tag.is_empty()
1315            && tag.chars().all(is_org_tag_char)
1316            && !org_tags.iter().any(|seen| seen == tag)
1317        {
1318            org_tags.push(tag.to_string());
1319        }
1320    }
1321    for key in ["VISSUE_TYPE", "TYPE"] {
1322        if let Some(kind) = properties.get(key) {
1323            push_tag(org_tags, kind.trim());
1324        }
1325    }
1326    if let Some(raw) = properties.get(crate::model::TAGS_PROPERTY).cloned() {
1327        let mut kept = Vec::new();
1328        for tag in raw
1329            .split([',', ':'])
1330            .map(str::trim)
1331            .filter(|t| !t.is_empty())
1332        {
1333            if tag.chars().all(is_org_tag_char) {
1334                push_tag(org_tags, tag);
1335            } else {
1336                kept.push(tag.to_string());
1337            }
1338        }
1339        if kept.is_empty() {
1340            properties.remove(crate::model::TAGS_PROPERTY);
1341        } else {
1342            properties.insert(crate::model::TAGS_PROPERTY.to_string(), kept.join(","));
1343        }
1344    }
1345}
1346
1347/// Org specials that are computed. Writing them in a drawer does not
1348/// set them; Org reads the headline, the planning line, or the clock.
1349pub const COMPUTED_SPECIALS: &[&str] = &[
1350    "ALLTAGS",
1351    "BLOCKED",
1352    "CLOCKSUM",
1353    "CLOCKSUM_T",
1354    "FILE",
1355    "ITEM",
1356    "PRIORITY",
1357    "TAGS",
1358    "TIMESTAMP",
1359    "TIMESTAMP_IA",
1360    "TODO",
1361];
1362
1363/// Org specials a heading may set. `CATEGORY` and `ARCHIVE` are the
1364/// ones the agenda actually honours from the drawer.
1365pub const SETTABLE_SPECIALS: &[&str] = &[
1366    "ARCHIVE",
1367    "CATEGORY",
1368    "COLUMNS",
1369    "COOKIE_DATA",
1370    "LOGGING",
1371    "ORDERED",
1372    "STYLE",
1373];
1374
1375/// Words org-edna and org-depend put in `:BLOCKER:` / `:TRIGGER:`.
1376const EDNA_ATOMS: &[&str] = &[
1377    "ancestors",
1378    "chain-siblings",
1379    "children",
1380    "descendants",
1381    "file-progress",
1382    "first-child",
1383    "has-property",
1384    "heading",
1385    "headings",
1386    "id",
1387    "ids",
1388    "last-child",
1389    "match",
1390    "next-sibling",
1391    "olp",
1392    "parent",
1393    "prev-sibling",
1394    "previous-sibling",
1395    "relatives",
1396    "rest-of-siblings",
1397    "siblings",
1398    "todo-state",
1399    "todo-state!",
1400];
1401
1402/// Split a BLOCKED_BY-style id list: commas and whitespace both separate.
1403pub fn split_id_list(raw: &str) -> Vec<String> {
1404    raw.split(|c: char| c == ',' || c.is_whitespace())
1405        .map(str::trim)
1406        .filter(|x| !x.is_empty())
1407        .map(str::to_string)
1408        .collect()
1409}
1410
1411/// Whether a `:BLOCKER:` value is org-edna / org-depend syntax, not a
1412/// list of issue ids. GNU ELPA org-edna is the maintained package;
1413/// org-depend in org-contrib is the older one. Both own this name.
1414pub fn is_edna_blocker(raw: &str) -> bool {
1415    let trimmed = raw.trim();
1416    if trimmed.contains('(') {
1417        return true;
1418    }
1419    trimmed.split_whitespace().any(|tok| {
1420        let atom = tok.trim_end_matches('!');
1421        EDNA_ATOMS
1422            .iter()
1423            .any(|known| atom.eq_ignore_ascii_case(known))
1424    })
1425}
1426
1427/// Issue ids mentioned in an org-edna `ids(...)` / `id(...)` form.
1428pub fn edna_blocker_id_refs(raw: &str) -> Vec<&str> {
1429    let mut ids = Vec::new();
1430    let mut rest = raw;
1431    while let Some(start) = rest.find('(') {
1432        let Some(end) = rest[start + 1..].find(')') else {
1433            break;
1434        };
1435        let inner = &rest[start + 1..start + 1 + end];
1436        for id in inner.split(|c: char| c == ',' || c.is_whitespace()) {
1437            let id = id.trim();
1438            if !id.is_empty() && !id.contains('"') && !ids.contains(&id) {
1439                ids.push(id);
1440            }
1441        }
1442        rest = &rest[start + 1 + end + 1..];
1443    }
1444    ids
1445}
1446
1447/// Issue ids mentioned in an org-edna `ids(...)` / `id(...)` form.
1448pub fn edna_blocker_ids(raw: &str) -> Vec<String> {
1449    edna_blocker_id_refs(raw)
1450        .into_iter()
1451        .map(str::to_string)
1452        .collect()
1453}
1454
1455/// Every blocker id a heading declares: `:BLOCKED_BY:`, a typo
1456/// `:BLOCKEDBY:`, a `:BLOCKER:` that is just ids, and `ids(...)` inside
1457/// an org-edna form.
1458pub fn blocker_ids_from_properties(
1459    properties: &std::collections::BTreeMap<String, String>,
1460) -> Vec<String> {
1461    let mut ids = Vec::new();
1462    for key in ["VISSUE_BLOCKED_BY", "BLOCKED_BY", "BLOCKEDBY"] {
1463        if let Some(raw) = properties.get(key) {
1464            for id in split_id_list(raw) {
1465                if !ids.iter().any(|seen| seen == &id) {
1466                    ids.push(id);
1467                }
1468            }
1469        }
1470    }
1471    if let Some(raw) = properties.get("BLOCKER") {
1472        let extra = if is_edna_blocker(raw) {
1473            edna_blocker_ids(raw)
1474        } else {
1475            split_id_list(raw)
1476        };
1477        for id in extra {
1478            if !ids.iter().any(|seen| seen == &id) {
1479                ids.push(id);
1480            }
1481        }
1482    }
1483    ids
1484}
1485
1486/// Effort value Org's column view and agenda effort filter read.
1487/// `org-effort-property` defaults to `Effort`.
1488pub fn effort_from_properties(
1489    properties: &std::collections::BTreeMap<String, String>,
1490) -> Option<&str> {
1491    properties
1492        .get("Effort")
1493        .or_else(|| properties.get("EFFORT"))
1494        .map(|s| s.trim())
1495        .filter(|s| !s.is_empty())
1496}
1497
1498/// A duration Org accepts for Effort: `1:30`, `2h`, `3d`, `0:10`.
1499pub fn is_org_effort(raw: &str) -> bool {
1500    let s = raw.trim();
1501    if s.is_empty() {
1502        return false;
1503    }
1504    if let Some((h, m)) = s.split_once(':') {
1505        return !h.is_empty()
1506            && h.chars().all(|c| c.is_ascii_digit())
1507            && !m.is_empty()
1508            && m.chars().all(|c| c.is_ascii_digit());
1509    }
1510    let (num, unit) = s.split_at(
1511        s.find(|c: char| !c.is_ascii_digit() && c != '.')
1512            .unwrap_or(s.len()),
1513    );
1514    !num.is_empty() && matches!(unit, "h" | "d" | "m" | "w" | "min" | "")
1515}
1516
1517fn strip_file_keyword<'a>(trimmed: &'a str, name: &str) -> Option<&'a str> {
1518    let rest = trimmed.strip_prefix("#+")?;
1519    let (key, value) = rest.split_once(':')?;
1520    if key.eq_ignore_ascii_case(name) {
1521        Some(value)
1522    } else {
1523        None
1524    }
1525}
1526
1527/// The pieces Org puts on a headline after the stars (manual 2.1, 5.1, 5.4).
1528#[derive(Debug, Clone, PartialEq, Eq)]
1529pub struct HeadlineBits<'a> {
1530    /// TODO keyword, when the first word is one of `keywords`.
1531    pub keyword: Option<&'a str>,
1532    /// Priority cookie character, when `[#X]` follows the keyword.
1533    pub priority: Option<char>,
1534    /// Whether the heading carries the `COMMENT` keyword (manual 13.6).
1535    pub commented: bool,
1536    /// Title text, including a trailing tag run and statistics cookies.
1537    pub rest: &'a str,
1538}
1539
1540/// Split the text after `* ` into keyword, priority, COMMENT, and title.
1541pub fn parse_headline_bits<'a>(after_stars: &'a str, keywords: &[String]) -> HeadlineBits<'a> {
1542    let trimmed = after_stars.trim();
1543    let mut rest = trimmed;
1544    let mut keyword = None;
1545    if let Some((word, after)) = first_word(rest)
1546        && is_listed_keyword(word, keywords)
1547    {
1548        keyword = Some(word);
1549        rest = after.trim_start();
1550    }
1551    let mut priority = None;
1552    if let Some((p, after)) = parse_priority_cookie(rest) {
1553        priority = Some(p);
1554        rest = after.trim_start();
1555    }
1556    let mut commented = false;
1557    if let Some((word, after)) = first_word(rest)
1558        && word == "COMMENT"
1559    {
1560        commented = true;
1561        rest = after.trim_start();
1562    }
1563    HeadlineBits {
1564        keyword,
1565        priority,
1566        commented,
1567        rest,
1568    }
1569}
1570
1571fn first_word(s: &str) -> Option<(&str, &str)> {
1572    let s = s.trim_start();
1573    if s.is_empty() {
1574        return None;
1575    }
1576    match s.find(char::is_whitespace) {
1577        Some(i) => Some((&s[..i], &s[i..])),
1578        None => Some((s, "")),
1579    }
1580}
1581
1582fn is_listed_keyword(word: &str, keywords: &[String]) -> bool {
1583    keywords.iter().any(|k| k == word)
1584}
1585
1586/// A top-level heading that is an issue: recognised TODO keyword, not COMMENT.
1587pub fn is_issue_headline(line: &str, keywords: &[String]) -> bool {
1588    let Some(after) = line.strip_prefix("* ") else {
1589        return false;
1590    };
1591    let bits = parse_headline_bits(after, keywords);
1592    bits.keyword.is_some() && !bits.commented
1593}
1594
1595/// Split a leading `[#A]` cookie off a heading. Any other shape yields `None`.
1596pub fn parse_priority_cookie(after: &str) -> Option<(char, &str)> {
1597    let rest = after.strip_prefix("[#")?;
1598    let mut chars = rest.char_indices();
1599    let (_, priority) = chars.next()?;
1600    let (close, bracket) = chars.next()?;
1601    if bracket != ']' {
1602        return None;
1603    }
1604    Some((priority, &rest[close + 1..]))
1605}
1606
1607/// Split trailing statistics cookies (`[2/5]`, `[33%]`) off a title.
1608///
1609/// Manual 5.5. Cookies sit after the title and before the tag run.
1610pub fn split_statistics_cookies(text: &str) -> (String, Option<String>) {
1611    let mut trimmed = text.trim_end().to_string();
1612    let mut cookies = Vec::new();
1613    while let Some(open) = trimmed.rfind('[') {
1614        if !trimmed.ends_with(']') {
1615            break;
1616        }
1617        let cookie = &trimmed[open..];
1618        if !is_statistics_cookie(cookie) {
1619            break;
1620        }
1621        let prefix = trimmed[..open].trim_end();
1622        if open > 0 && !trimmed[..open].ends_with(char::is_whitespace) {
1623            break;
1624        }
1625        cookies.push(cookie.to_string());
1626        trimmed = prefix.to_string();
1627    }
1628    cookies.reverse();
1629    if cookies.is_empty() {
1630        (text.trim_end().to_string(), None)
1631    } else {
1632        (trimmed, Some(cookies.join(" ")))
1633    }
1634}
1635
1636fn is_statistics_cookie(cookie: &str) -> bool {
1637    let Some(inner) = cookie.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else {
1638        return false;
1639    };
1640    // `[/]` and `[%]` are the empty cookies Org fills in (manual 5.5).
1641    if let Some((a, b)) = inner.split_once('/') {
1642        return a.chars().all(|c| c.is_ascii_digit()) && b.chars().all(|c| c.is_ascii_digit());
1643    }
1644    inner
1645        .strip_suffix('%')
1646        .is_some_and(|n| n.chars().all(|c| c.is_ascii_digit()))
1647}
1648
1649/// Consume one Org timestamp or timestamp range at the start of `s`.
1650///
1651/// Manual 8.1: active `<>`, inactive `[]`, diary sexps `<%%(...)>`,
1652/// time ranges on one stamp, and ranges of two stamps joined by `--`.
1653/// Repeaters (`+1w`, `++1w`, `.+1w`) and warnings (`-2d`, `--2d`) live
1654/// inside the brackets, so the first closing delimiter is enough.
1655pub fn take_timestamp(s: &str) -> Option<(&str, &str)> {
1656    let start = s.trim_start();
1657    let leading = s.len() - start.len();
1658    if start.starts_with("<%%") {
1659        let end = start.find('>')?;
1660        let consumed = leading + end + 1;
1661        return Some((s[leading..consumed].trim_end(), s[consumed..].trim_start()));
1662    }
1663    let close = match start.chars().next()? {
1664        '<' => '>',
1665        '[' => ']',
1666        _ => return None,
1667    };
1668    let end = start.find(close)?;
1669    let mut consumed = leading + end + 1;
1670    let rest = &s[consumed..];
1671    if let Some(after) = rest.strip_prefix("--") {
1672        let after = after.trim_start();
1673        if after.starts_with('<') || after.starts_with('[') || after.starts_with("<%%") {
1674            let close2 = if after.starts_with('[') { ']' } else { '>' };
1675            let end2 = after.find(close2)?;
1676            consumed = s.len() - after.len() + end2 + 1;
1677        }
1678    }
1679    Some((s[leading..consumed].trim_end(), s[consumed..].trim_start()))
1680}
1681
1682/// Read an Org planning line into `KEY -> timestamp` pairs.
1683///
1684/// Org packs several onto one line. A line holding anything else is not a
1685/// planning line at all, so a body sentence that opens on `DEADLINE:` stays
1686/// body.
1687pub fn parse_planning_line(line: &str) -> Vec<(String, String)> {
1688    let mut rest = line.trim();
1689    let mut found = Vec::new();
1690    while !rest.is_empty() {
1691        let Some(key) = PLANNING_KEYS
1692            .iter()
1693            .find(|key| rest.starts_with(&format!("{key}:")))
1694        else {
1695            return Vec::new();
1696        };
1697        let after = rest[key.len() + 1..].trim_start();
1698        let Some((ts, next)) = take_timestamp(after) else {
1699            return Vec::new();
1700        };
1701        found.push(((*key).to_string(), ts.to_string()));
1702        rest = next;
1703    }
1704    found
1705}
1706
1707/// Whether `trimmed` opens a planning line (any planning key and a colon).
1708pub fn is_planning_line(trimmed: &str) -> bool {
1709    PLANNING_KEYS
1710        .iter()
1711        .any(|key| trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':'))
1712}
1713
1714/// Ids named by Org links in `body` that also sit in `known_ids`.
1715///
1716/// Manual 4.1 / 4.4: `[[id:foo]]`, `[[id:foo][desc]]`, `<id:foo>`, and a
1717/// bare `id:foo`. `[[foo]]` and `[[file:x.org::foo]]` still resolve when
1718/// the target or the search fragment is a known id.
1719pub fn org_link_targets(body: &str, known_ids: &std::collections::HashSet<&str>) -> Vec<String> {
1720    let mut targets = Vec::new();
1721    let mut rest = body;
1722    while let Some(start) = rest.find("[[") {
1723        let after_start = &rest[start + 2..];
1724        let Some(end) = after_start.find("]]") else {
1725            break;
1726        };
1727        let raw = &after_start[..end];
1728        let target = raw.split_once("][").map_or(raw, |(target, _)| target);
1729        push_link_target(&mut targets, target, known_ids);
1730        rest = &after_start[end + 2..];
1731    }
1732    rest = body;
1733    while let Some(start) = rest.find('<') {
1734        let after = &rest[start + 1..];
1735        let Some(end) = after.find('>') else {
1736            break;
1737        };
1738        push_link_target(&mut targets, &after[..end], known_ids);
1739        rest = &after[end + 1..];
1740    }
1741    rest = body;
1742    while let Some(start) = rest.find("id:") {
1743        let after = &rest[start + 3..];
1744        let len = after
1745            .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
1746            .unwrap_or(after.len());
1747        let id = &after[..len];
1748        if !id.is_empty() && known_ids.contains(id) && !targets.iter().any(|t| t == id) {
1749            targets.push(id.to_string());
1750        }
1751        rest = &after[len.max(1)..];
1752    }
1753    targets
1754}
1755
1756fn push_link_target(
1757    targets: &mut Vec<String>,
1758    raw: &str,
1759    known_ids: &std::collections::HashSet<&str>,
1760) {
1761    let target = raw.trim();
1762    let target = target.strip_prefix("id:").unwrap_or(target);
1763    let target = target.rsplit_once("::").map_or(target, |(_, fragment)| {
1764        fragment.strip_prefix('#').unwrap_or(fragment)
1765    });
1766    let target = target.strip_prefix('#').unwrap_or(target);
1767    if known_ids.contains(target) && !targets.iter().any(|t| t == target) {
1768        targets.push(target.to_string());
1769    }
1770}
1771
1772/// Property key as written, with a trailing `+` (append) stripped.
1773///
1774/// Manual 7.1: `:var+: value` appends to `:var:`.
1775pub fn property_key_and_append(key: &str) -> (&str, bool) {
1776    match key.strip_suffix('+') {
1777        Some(bare) if !bare.is_empty() => (bare, true),
1778        _ => (key, false),
1779    }
1780}
1781
1782#[cfg(test)]
1783mod tests {
1784    use super::*;
1785
1786    #[test]
1787    fn the_todo_sequence_splits_at_the_bar_or_at_the_last_keyword() {
1788        let seq = todo_sequence_from_lines(&["#+TODO: TODO WAITING(w) | DONE WONTFIX"]);
1789        assert_eq!(seq.open, ["TODO", "STARTED", "BLOCKED", "WAITING"]);
1790        assert_eq!(seq.done, ["DONE", "CANCELLED", "WONTFIX"]);
1791        let bare = todo_sequence_from_lines(&["#+SEQ_TODO: NEXT FINISHED"]);
1792        assert!(bare.open.iter().any(|k| k == "NEXT"));
1793        assert!(bare.is_done("FINISHED"));
1794        assert!(TodoSequence::house().knows("CANCELLED"));
1795        assert!(!TodoSequence::house().knows("WAITING"));
1796    }
1797
1798    #[test]
1799    fn a_repeater_shifts_as_org_shifts_it() {
1800        let today = chrono::NaiveDate::from_ymd_opt(2026, 9, 20).unwrap();
1801        assert_eq!(
1802            shift_repeating_timestamp("<2026-09-22 Tue +1w>", today).as_deref(),
1803            Some("<2026-09-29 Tue +1w>")
1804        );
1805        assert_eq!(
1806            shift_repeating_timestamp("<2026-09-01 Tue 10:00 ++1w -2d>", today).as_deref(),
1807            Some("<2026-09-22 Tue 10:00 ++1w -2d>")
1808        );
1809        assert_eq!(
1810            shift_repeating_timestamp("<2026-01-31 Sat .+1m>", today).as_deref(),
1811            Some("<2026-10-20 Tue .+1m>")
1812        );
1813        assert_eq!(
1814            shift_repeating_timestamp("<2026-01-31 Sat +1m>", today).as_deref(),
1815            Some("<2026-02-28 Sat +1m>")
1816        );
1817        assert!(shift_repeating_timestamp("<2026-09-22 Tue>", today).is_none());
1818        assert!(
1819            shift_repeating_timestamp("<2026-09-22 Tue>--<2026-09-23 Wed +1w>", today).is_none()
1820        );
1821        assert!(has_repeater("<2026-09-22 Tue +1w>"));
1822        assert!(!has_repeater("<2026-09-22 Tue -2d>"));
1823    }
1824
1825    #[test]
1826    fn empty_statistics_cookies_are_cookies() {
1827        assert_eq!(
1828            split_statistics_cookies("Parent of two [/]"),
1829            ("Parent of two".to_string(), Some("[/]".to_string()))
1830        );
1831        assert_eq!(
1832            split_statistics_cookies("Half [%]").1.as_deref(),
1833            Some("[%]")
1834        );
1835        assert_eq!(split_statistics_cookies("Not a cookie [a/b]").1, None);
1836    }
1837    use std::collections::HashSet;
1838
1839    fn house() -> Vec<String> {
1840        TODO_KEYWORDS.iter().map(|s| (*s).to_string()).collect()
1841    }
1842
1843    #[test]
1844    fn a_headline_needs_stars_then_a_space_at_column_zero() {
1845        assert!(is_headline("* TODO a title"));
1846        assert!(is_headline("*** deeper"));
1847        assert!(is_top_level_headline("* TODO a title"));
1848        assert!(!is_top_level_headline("** child"));
1849        assert!(!is_headline("**bold** at the start of a line"));
1850        assert!(!is_headline(" * indented is not a headline"));
1851        assert!(!is_headline("not a headline"));
1852    }
1853
1854    #[test]
1855    fn greater_and_dynamic_blocks_hide_their_contents() {
1856        let mut nest = BlockNest::new();
1857        assert!(!nest.inside());
1858        assert!(nest.observe("#+BEGIN_SRC org"));
1859        assert!(nest.inside());
1860        assert!(nest.observe("* TODO quoted"));
1861        assert!(nest.observe("#+begin_example"));
1862        assert!(nest.observe("* still quoted"));
1863        assert!(nest.observe("#+end_example"));
1864        assert!(nest.inside());
1865        assert!(nest.observe("#+END_SRC"));
1866        assert!(!nest.inside());
1867        assert!(nest.observe("  #+BEGIN: clocktable :scope file"));
1868        assert!(nest.observe("* TODO inside clocktable"));
1869        assert!(nest.observe("  #+END:"));
1870        assert!(!nest.inside());
1871    }
1872
1873    #[test]
1874    fn file_local_todo_keywords_accumulate_and_keep_the_house_set() {
1875        let keys = todo_keywords_from_preamble(
1876            "#+TITLE: x\n#+TODO: TODO(t) WAIT(w@) | DONE(d!)\n#+TODO: HOLD | CANCELLED\n",
1877        );
1878        for expected in [
1879            "TODO",
1880            "STARTED",
1881            "BLOCKED",
1882            "DONE",
1883            "CANCELLED",
1884            "WAIT",
1885            "HOLD",
1886        ] {
1887            assert!(
1888                keys.iter().any(|k| k == expected),
1889                "{keys:?} missing {expected}"
1890            );
1891        }
1892    }
1893
1894    #[test]
1895    fn comment_and_section_headlines_are_not_issues() {
1896        let keys = house();
1897        assert!(is_issue_headline("* TODO Ship it", &keys));
1898        assert!(is_issue_headline("* DONE [#A] Ship it", &keys));
1899        assert!(!is_issue_headline("* COMMENT Archive", &keys));
1900        assert!(!is_issue_headline("* TODO COMMENT hidden", &keys));
1901        // Org's keyword is the upper-case word alone; a title that opens
1902        // with the word "comment" is a title.
1903        assert!(is_issue_headline("* TODO [#A] Comment :bug:", &keys));
1904        assert!(is_issue_headline("* TODO comment on the draft", &keys));
1905        assert!(!is_issue_headline("* Notes", &keys));
1906        assert!(!is_issue_headline("** TODO child", &keys));
1907    }
1908
1909    #[test]
1910    fn a_file_local_keyword_is_an_issue() {
1911        let keys = todo_keywords_from_preamble("#+TODO: TODO WAIT | DONE\n");
1912        assert!(is_issue_headline("* WAIT Parked", &keys));
1913        assert!(!is_issue_headline("* HOLD Parked", &keys));
1914    }
1915
1916    #[test]
1917    fn statistics_cookies_split_off_the_title() {
1918        assert_eq!(
1919            split_statistics_cookies("Break it down [2/5]"),
1920            ("Break it down".into(), Some("[2/5]".into()))
1921        );
1922        assert_eq!(
1923            split_statistics_cookies("Break it down [2/5] [40%]"),
1924            ("Break it down".into(), Some("[2/5] [40%]".into()))
1925        );
1926        assert_eq!(
1927            split_statistics_cookies("Array [2/3] leftover"),
1928            ("Array [2/3] leftover".into(), None)
1929        );
1930        assert_eq!(
1931            split_statistics_cookies("Not a cookie [n/a]"),
1932            ("Not a cookie [n/a]".into(), None)
1933        );
1934    }
1935
1936    #[test]
1937    fn timestamps_include_ranges_repeaters_and_diary_sexps() {
1938        let (ts, rest) = take_timestamp("<2026-09-01 Tue +1w -2d> leftover").unwrap();
1939        assert_eq!(ts, "<2026-09-01 Tue +1w -2d>");
1940        assert_eq!(rest, "leftover");
1941        let (ts, rest) = take_timestamp("<2026-09-01 Tue>--<2026-09-08 Tue>").unwrap();
1942        assert_eq!(ts, "<2026-09-01 Tue>--<2026-09-08 Tue>");
1943        assert!(rest.is_empty());
1944        let (ts, _) = take_timestamp("[2026-09-01 Tue 09:00-17:00]").unwrap();
1945        assert_eq!(ts, "[2026-09-01 Tue 09:00-17:00]");
1946        let (ts, rest) = take_timestamp("<%%(diary-float t 4 2)> next").unwrap();
1947        assert_eq!(ts, "<%%(diary-float t 4 2)>");
1948        assert_eq!(rest, "next");
1949    }
1950
1951    #[test]
1952    fn a_planning_line_keeps_a_range_and_rejects_prose() {
1953        let found = parse_planning_line(
1954            "CLOSED: [2026-08-14 Fri 03:33] SCHEDULED: <2026-09-01 Tue>--<2026-09-08 Tue> DEADLINE: <2026-09-15 Mon +1w>",
1955        );
1956        assert_eq!(found.len(), 3, "{found:?}");
1957        assert_eq!(found[1].1, "<2026-09-01 Tue>--<2026-09-08 Tue>");
1958        assert_eq!(found[2].1, "<2026-09-15 Mon +1w>");
1959        assert!(parse_planning_line("DEADLINE: is discussed in the design note.").is_empty());
1960    }
1961
1962    #[test]
1963    fn org_links_include_brackets_angles_and_bare_ids() {
1964        let known: HashSet<&str> = ["atlas-1a2b", "beacon-5j6k"].into_iter().collect();
1965        let body =
1966            "See [[id:atlas-1a2b][the parser]] and <id:beacon-5j6k> plus id:atlas-1a2b again.";
1967        let found = org_link_targets(body, &known);
1968        assert_eq!(found, vec!["atlas-1a2b", "beacon-5j6k"]);
1969    }
1970
1971    #[test]
1972    fn filetags_parse_the_in_buffer_keyword() {
1973        assert_eq!(
1974            filetags_from_preamble("#+FILETAGS: :issues:parser:\n"),
1975            vec!["issues", "parser"]
1976        );
1977    }
1978
1979    #[test]
1980    fn tag_settings_parse_groups_and_keys() {
1981        let settings = tag_settings_from_preamble(
1982            "#+FILETAGS: :issues:demo:noexport:\n\
1983             #+TAGS: { bug(b) feature(f) task(t) }\n\
1984             #+TAGS: [ area : core cli ]\n\
1985             #+TAGS: docs(d) perf\n\
1986             #+EXCLUDE_TAGS: noexport\n\
1987             #+SELECT_TAGS: export\n",
1988        );
1989        assert_eq!(settings.filetags, vec!["issues", "demo", "noexport"]);
1990        assert_eq!(
1991            settings
1992                .declared
1993                .iter()
1994                .map(|s| (s.name.as_str(), s.key))
1995                .collect::<Vec<_>>(),
1996            vec![
1997                ("bug", Some('b')),
1998                ("feature", Some('f')),
1999                ("task", Some('t')),
2000                ("area", None),
2001                ("core", None),
2002                ("cli", None),
2003                ("docs", Some('d')),
2004                ("perf", None),
2005            ]
2006        );
2007        assert_eq!(
2008            settings.exclusive,
2009            vec![vec![
2010                "bug".to_string(),
2011                "feature".to_string(),
2012                "task".to_string()
2013            ]]
2014        );
2015        assert_eq!(
2016            settings.hierarchies,
2017            vec![("area".to_string(), vec!["core".into(), "cli".into()])]
2018        );
2019        let own = vec!["core".to_string(), "bug".to_string()];
2020        assert!(settings.matches_query(&own, "area"));
2021        assert!(settings.matches_query(&own, "issues"));
2022        assert!(settings.heading_exportable(&own));
2023        assert!(!settings.heading_exportable(&["noexport".into()]));
2024        assert_eq!(
2025            settings.all_tags(&own),
2026            vec!["core", "bug", "issues", "demo", "noexport"]
2027        );
2028    }
2029
2030    #[test]
2031    fn ensure_org_preamble_inserts_category_and_filetags() {
2032        let raw = "#+TITLE: demo issues\n#+TODO: TODO | DONE";
2033        let out = ensure_org_preamble(raw, "demo");
2034        assert!(out.contains("#+VISSUE: 1"), "{out}");
2035        assert!(out.contains("#+CATEGORY: demo"), "{out}");
2036        assert!(out.contains("#+FILETAGS: :issues:demo:noexport:"), "{out}");
2037        assert!(
2038            out.contains("#+TAGS: { bug(b) feature(f) task(t) chore(c) plan(p) }"),
2039            "{out}"
2040        );
2041        assert!(out.contains("#+EXCLUDE_TAGS: noexport"), "{out}");
2042        assert!(out.contains("#+SELECT_TAGS: export"), "{out}");
2043        assert!(out.contains("#+PRIORITIES: A C C"), "{out}");
2044        assert!(out.find("#+TITLE:").unwrap() < out.find("#+CATEGORY:").unwrap());
2045        assert_eq!(ensure_org_preamble(&out, "demo"), out);
2046        let kept = "#+TITLE: demo issues\n#+FILETAGS: :issues:demo:\n#+TODO: TODO | DONE";
2047        let healed = ensure_org_preamble(kept, "demo");
2048        assert!(
2049            healed.contains("#+FILETAGS: :issues:demo:noexport:"),
2050            "existing FILETAGS gain noexport: {healed}"
2051        );
2052        let old = "#+TITLE: demo issues\n#+VISSUE: 0\n#+CATEGORY: demo\n";
2053        let bumped = ensure_org_preamble(old, "demo");
2054        assert!(bumped.contains("#+VISSUE: 1"), "{bumped}");
2055        assert!(!bumped.contains("#+VISSUE: 0"), "{bumped}");
2056        let future = "#+TITLE: demo issues\n#+VISSUE: 99\n#+CATEGORY: demo\n";
2057        let left = ensure_org_preamble(future, "demo");
2058        assert!(left.contains("#+VISSUE: 99"), "{left}");
2059    }
2060
2061    #[test]
2062    fn protocol_from_preamble_reads_the_vissue_keyword() {
2063        assert_eq!(protocol_from_preamble("#+VISSUE: 1\n"), Some(1));
2064        assert_eq!(protocol_from_preamble("#+VISSUE: protocol 2\n"), Some(2));
2065        assert_eq!(protocol_from_preamble("#+VISSUE: protocol=3\n"), Some(3));
2066        assert_eq!(protocol_from_preamble("#+TITLE: x\n"), None);
2067    }
2068
2069    #[test]
2070    fn priorities_from_preamble_reads_highest_lowest_default() {
2071        let spec = priorities_from_preamble("#+PRIORITIES: A D B\n");
2072        assert_eq!(spec.highest, 'A');
2073        assert_eq!(spec.lowest, 'D');
2074        assert_eq!(spec.default, 'B');
2075        assert!(spec.contains('C'));
2076        assert!(!spec.contains('E'));
2077        assert_eq!(priorities_from_preamble("").default, 'C');
2078    }
2079
2080    #[test]
2081    fn gcal_event_ids_are_not_org_ids() {
2082        assert!(is_gcal_event_id("abc123/primary@group.calendar.google.com"));
2083        assert!(is_gcal_event_id("evt/cal"));
2084        assert!(is_gcal_event_id("abc/def/ghi"));
2085        assert!(!is_gcal_event_id("atlas-1a2b"));
2086        assert!(!is_gcal_event_id("no-slash"));
2087    }
2088
2089    #[test]
2090    fn setupfile_merges_local_inbuffer_settings() {
2091        let dir = tempfile::tempdir().unwrap();
2092        let setup = dir.path().join("house.org");
2093        std::fs::write(
2094            &setup,
2095            "#+TODO: TODO HOLD | DONE\n#+PRIORITIES: A D C\n* not a keyword\n",
2096        )
2097        .unwrap();
2098        let preamble = format!(
2099            "#+TITLE: x\n#+SETUPFILE: {}\n#+CATEGORY: x\n",
2100            setup.display()
2101        );
2102        let merged = merge_setupfile_settings(&preamble, Some(dir.path()));
2103        assert!(merged.contains("#+TODO: TODO HOLD | DONE"), "{merged}");
2104        assert!(merged.contains("#+PRIORITIES: A D C"), "{merged}");
2105        assert!(merged.contains("#+CATEGORY: x"), "{merged}");
2106        assert!(!merged.contains("* not a keyword"), "{merged}");
2107        assert_eq!(priorities_from_preamble(&merged).lowest, 'D');
2108    }
2109
2110    #[test]
2111    fn edna_blocker_is_not_an_id_list() {
2112        assert!(is_edna_blocker("prev-sibling"));
2113        assert!(is_edna_blocker("ids(atlas-1a2b atlas-3e4f)"));
2114        assert!(is_edna_blocker("headings(\"Ship it\")"));
2115        assert!(!is_edna_blocker("atlas-1a2b"));
2116        assert!(!is_edna_blocker("atlas-1a2b beacon-5j6k"));
2117        assert_eq!(
2118            edna_blocker_ids("ids(atlas-1a2b atlas-3e4f) next-sibling"),
2119            vec!["atlas-1a2b", "atlas-3e4f"]
2120        );
2121        let mut props = std::collections::BTreeMap::new();
2122        props.insert("BLOCKER".into(), "atlas-1a2b atlas-3e4f".into());
2123        assert_eq!(
2124            blocker_ids_from_properties(&props),
2125            vec!["atlas-1a2b", "atlas-3e4f"]
2126        );
2127        let mut edna = std::collections::BTreeMap::new();
2128        edna.insert("BLOCKER".into(), "prev-sibling".into());
2129        assert!(blocker_ids_from_properties(&edna).is_empty());
2130    }
2131
2132    #[test]
2133    fn effort_accepts_org_durations() {
2134        assert!(is_org_effort("1:30"));
2135        assert!(is_org_effort("2h"));
2136        assert!(is_org_effort("20d"));
2137        assert!(!is_org_effort("soon"));
2138        let mut props = std::collections::BTreeMap::new();
2139        props.insert("Effort".into(), "2h".into());
2140        assert_eq!(effort_from_properties(&props), Some("2h"));
2141    }
2142
2143    #[test]
2144    fn settle_moves_legal_type_and_tags_onto_the_heading() {
2145        let mut tags = Vec::new();
2146        let mut props = std::collections::BTreeMap::new();
2147        props.insert("TYPE".into(), "bug".into());
2148        props.insert("VISSUE_TAGS".into(), "perf,needs-review".into());
2149        settle_heading_classifiers(&mut tags, &mut props);
2150        assert_eq!(tags, vec!["bug", "perf"]);
2151        assert_eq!(
2152            props.get("VISSUE_TAGS").map(String::as_str),
2153            Some("needs-review")
2154        );
2155        assert_eq!(props.get("TYPE").map(String::as_str), Some("bug"));
2156    }
2157
2158    #[test]
2159    fn property_plus_appends() {
2160        assert_eq!(property_key_and_append("BLOCKED_BY+"), ("BLOCKED_BY", true));
2161        assert_eq!(property_key_and_append("ID"), ("ID", false));
2162    }
2163
2164    #[test]
2165    fn results_keywords_match_what_babel_writes() {
2166        assert!(is_results_keyword("#+RESULTS:"));
2167        assert!(is_results_keyword("  #+results:"));
2168        assert!(is_results_keyword("#+RESULTS[deadbeef]:"));
2169        assert!(is_results_keyword(
2170            "#+RESULTS[(2026-08-18 17:50) abcdef]: named"
2171        ));
2172        assert!(is_results_keyword("#+RESULTS: named"));
2173        assert!(!is_results_keyword("#+RESULTANT:"));
2174        assert!(!is_results_keyword("#+TODO: TODO"));
2175    }
2176
2177    #[test]
2178    fn babel_call_and_affiliated_keywords() {
2179        assert!(is_babel_call("#+CALL: plot(x=1) :results output"));
2180        assert!(is_babel_call("#+call: fn[:session]()"));
2181        assert!(!is_babel_call("#+CALLING:"));
2182        assert!(is_affiliated_keyword("#+NAME: plot"));
2183        assert!(is_affiliated_keyword("#+HEADER: :var x=1"));
2184        assert!(is_affiliated_keyword("#+ATTR_HTML: :width 40"));
2185        assert!(is_affiliated_keyword("#+TBLNAME: old"));
2186        assert!(!is_affiliated_keyword("#+TODO: TODO"));
2187    }
2188
2189    #[test]
2190    fn src_begin_splits_lang_switches_and_headers() {
2191        let head = parse_src_begin("  #+BEGIN_SRC python -n -r :results output :var x=1").unwrap();
2192        assert_eq!(head.lang, "python");
2193        assert_eq!(head.switches, "-n -r");
2194        assert_eq!(head.headers, ":results output :var x=1");
2195        assert_eq!(
2196            parse_header_args(head.headers),
2197            vec![
2198                ("results".into(), "output".into()),
2199                ("var".into(), "x=1".into())
2200            ]
2201        );
2202    }
2203
2204    #[test]
2205    fn noweb_and_inline_src_and_calls() {
2206        assert_eq!(
2207            noweb_refs("use <<setup>> and <<setup(n=1)>>"),
2208            vec!["setup", "setup(n=1)"]
2209        );
2210        assert_eq!(
2211            inline_src_spans("see src_python[:results raw]{print(1)} and src_elisp{(+ 1 2)}"),
2212            vec![
2213                ("python", ":results raw", "print(1)"),
2214                ("elisp", "", "(+ 1 2)")
2215            ]
2216        );
2217        assert_eq!(
2218            inline_call_names("then call_plot[:session](x=1) here"),
2219            vec!["plot"]
2220        );
2221    }
2222
2223    #[test]
2224    fn babel_results_hide_headlines_and_drawers() {
2225        let mut scan = OrgScan::new();
2226        assert!(!scan.observe("#+NAME: dump"));
2227        assert!(!scan.observe("prologue"));
2228        assert!(scan.observe("#+BEGIN_SRC python :results raw"));
2229        assert!(scan.observe("print('* TODO dumped')"));
2230        assert!(scan.observe("#+END_SRC"));
2231        assert!(!scan.inside());
2232        assert!(scan.observe("#+RESULTS:"));
2233        assert!(scan.observe("* TODO dumped"));
2234        assert!(scan.observe(":PROPERTIES:"));
2235        assert!(scan.observe(":ID:         ghost-9999"));
2236        assert!(scan.observe(":END:"));
2237        assert!(scan.inside());
2238        assert!(!scan.observe("* TODO real"));
2239        assert!(!scan.inside());
2240    }
2241
2242    #[test]
2243    fn babel_results_table_and_fixed_width_and_drawer() {
2244        let mut scan = OrgScan::new();
2245        assert!(scan.observe("#+RESULTS:"));
2246        assert!(scan.observe("| a | b |"));
2247        assert!(scan.observe("|---+---|"));
2248        assert!(scan.observe("| 1 | 2 |"));
2249        assert!(!scan.observe("after the table"));
2250
2251        let mut scan = OrgScan::new();
2252        assert!(scan.observe("#+RESULTS:"));
2253        assert!(scan.observe(": 42"));
2254        assert!(!scan.observe("not fixed width"));
2255
2256        let mut scan = OrgScan::new();
2257        assert!(scan.observe("#+RESULTS:"));
2258        assert!(scan.observe(":RESULTS:"));
2259        assert!(scan.observe("* looks like a headline"));
2260        assert!(scan.observe(":END:"));
2261        assert!(!scan.observe("* TODO real"));
2262    }
2263}