Skip to main content

vissue_core/
model.rs

1//! The issue heading and its logbook: parsing, accessors, and rendering.
2
3use chrono::Local;
4use serde::Serialize;
5use std::collections::BTreeMap;
6
7/// Planning-line keys (`CLOSED`, `SCHEDULED`, `DEADLINE`) and Org's tag
8/// character class. The property map still holds the dates internally;
9/// only the on-disk shape is Org's (manual 8.1, 6).
10pub use crate::org::{PLANNING_KEYS, is_org_tag_char};
11
12/// TODO keywords recognised on a heading, in org declaration order.
13pub const TODO_KEYWORDS: &[&str] = &["TODO", "STARTED", "BLOCKED", "DONE", "CANCELLED"];
14/// States an issue can be worked from once its blockers clear.
15pub const READY_STATES: &[&str] = &["TODO", "STARTED"];
16/// The `#+TODO:` line written into a fresh issues.org preamble.
17pub const TODO_HEADER: &str = "#+TODO: TODO STARTED BLOCKED | DONE CANCELLED";
18
19const PROPERTY_COLUMN: usize = 13;
20const DEFAULT_PROPERTY_ORDER: &[&str] = crate::props::CANONICAL_ORDER;
21
22/// Column org right-aligns headline tags to, matching the `org-tags-column`
23/// default. Writing them anywhere else makes the next Emacs edit realign the
24/// line and show up as a diff that changed nothing.
25const TAG_COLUMN: usize = 77;
26
27/// Split a trailing `:a:b:` tag run off a heading's text.
28///
29/// Returns the title and the tags. A title that merely ends in a colon, or
30/// whose trailing run holds a character org would not accept in a tag, keeps
31/// its text.
32pub fn split_headline_tags(text: &str) -> (String, Vec<String>) {
33    let trimmed = text.trim_end();
34    let Some(run_start) = trimmed.rfind(char::is_whitespace).map(|i| i + 1) else {
35        return (trimmed.to_string(), Vec::new());
36    };
37    let run = &trimmed[run_start..];
38    if run.len() < 3 || !run.starts_with(':') || !run.ends_with(':') {
39        return (trimmed.to_string(), Vec::new());
40    }
41    let tags: Vec<String> = run
42        .trim_matches(':')
43        .split(':')
44        .map(str::to_string)
45        .collect();
46    if tags.is_empty()
47        || tags
48            .iter()
49            .any(|tag| tag.is_empty() || !tag.chars().all(is_org_tag_char))
50    {
51        return (trimmed.to_string(), Vec::new());
52    }
53    (trimmed[..run_start].trim_end().to_string(), tags)
54}
55
56/// Property holding tags Org itself cannot carry on a heading.
57///
58/// Not `TAGS`: Org reserves that name for the headline tags it exposes as a
59/// special property, and `org-lint` reports a drawer that claims it.
60pub const TAGS_PROPERTY: &str = crate::props::TAGS;
61/// The name this property had before the clash with Org was found. Read on
62/// parse and rewritten under the current name.
63pub const LEGACY_TAGS_PROPERTY: &str = "TAGS";
64
65/// Property naming the identity that holds a STARTED issue.
66pub const CLAIMED_BY: &str = crate::props::CLAIMED_BY;
67/// Property recording when that claim was taken.
68pub const CLAIMED_AT: &str = crate::props::CLAIMED_AT;
69
70/// One line of an issue's `:LOGBOOK:` drawer.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct LogEntry {
73    /// Inactive org timestamp on the line, or empty for a raw CLOCK row.
74    pub timestamp: String,
75    /// Previous TODO keyword on a state flip.
76    pub from_state: Option<String>,
77    /// New TODO keyword on a state flip.
78    pub to_state: Option<String>,
79    /// Folded note text, when the line is a note rather than a state flip.
80    pub note: Option<String>,
81    /// Opaque logbook line (an org `CLOCK:` entry, say) preserved verbatim
82    /// across rewrites.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub raw: Option<String>,
85}
86
87impl LogEntry {
88    /// One logbook line, matching the Org drawer form this crate writes.
89    pub fn render(&self) -> String {
90        if let Some(raw) = &self.raw {
91            return raw.clone();
92        }
93        if let (Some(to), Some(from)) = (&self.to_state, &self.from_state) {
94            format!("- State \"{}\" from \"{}\" {}", to, from, self.timestamp)
95        } else if let Some(to) = &self.to_state {
96            format!("- State \"{}\" {}", to, self.timestamp)
97        } else if let Some(note) = &self.note {
98            format!("- Note: \"{}\" {}", note, self.timestamp)
99        } else {
100            format!("- {}", self.timestamp)
101        }
102    }
103
104    /// An inactive org timestamp for the current local time.
105    pub fn now() -> String {
106        Local::now().format("[%Y-%m-%d %a %H:%M]").to_string()
107    }
108
109    fn raw_line(line: &str) -> Self {
110        Self {
111            timestamp: String::new(),
112            from_state: None,
113            to_state: None,
114            note: None,
115            raw: Some(line.trim_end_matches(['\r', '\n']).to_string()),
116        }
117    }
118}
119
120pub(crate) fn parse_log_line(s: &str) -> LogEntry {
121    // Org CLOCK lines and other non-dash drawer content survive as raw text.
122    let trimmed = s.trim();
123    if trimmed.to_ascii_uppercase().starts_with("CLOCK:")
124        || (!trimmed.starts_with('-') && !trimmed.is_empty())
125    {
126        return LogEntry::raw_line(s);
127    }
128    let Some(body) = trimmed.strip_prefix('-').map(str::trim_start) else {
129        return LogEntry::raw_line(s);
130    };
131    let Some(bracket_idx) = body.rfind('[') else {
132        return LogEntry::raw_line(s);
133    };
134    let Some(rel_end) = body[bracket_idx..].find(']') else {
135        return LogEntry::raw_line(s);
136    };
137    let end = rel_end + bracket_idx;
138    let timestamp = body[bracket_idx..=end].to_string();
139    let prefix = body[..bracket_idx].trim().trim_end_matches(',');
140    if let Some(rest) = prefix.strip_prefix("State ") {
141        let mut chunks = rest.splitn(2, " from ");
142        let to_quoted = chunks.next().unwrap_or("").trim();
143        let from_quoted = chunks.next().map(str::trim);
144        LogEntry {
145            timestamp,
146            from_state: from_quoted.map(|s| s.trim_matches('"').to_string()),
147            to_state: Some(to_quoted.trim_matches('"').to_string()),
148            note: None,
149            raw: None,
150        }
151    } else if let Some(rest) = prefix.strip_prefix("Note:") {
152        let note = rest.trim().trim_matches('"').to_string();
153        LogEntry {
154            timestamp,
155            from_state: None,
156            to_state: None,
157            note: if note.is_empty() { None } else { Some(note) },
158            raw: None,
159        }
160    } else {
161        LogEntry {
162            timestamp,
163            from_state: None,
164            to_state: None,
165            note: if prefix.is_empty() {
166                None
167            } else {
168                Some(prefix.to_string())
169            },
170            raw: None,
171        }
172    }
173}
174
175/// One issue: a top-level org heading with a properties drawer, an optional
176/// logbook, and free-form body prose.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
178pub struct IssueHeading {
179    /// `:ID:` value, `<project>-<suffix>`.
180    pub id: String,
181    /// Heading title, without a trailing tag run.
182    pub title: String,
183    /// TODO keyword on the heading.
184    pub state: String,
185    /// Priority cookie character (`A`, `B`, or `C`).
186    pub priority: char,
187    /// Property drawer, including planning keys held in the map.
188    pub properties: BTreeMap<String, String>,
189    /// Tags written on the heading itself, which is where Org's own tag
190    /// search and agenda look. Kept apart from the `:TAGS:` property so each
191    /// round-trips as it was written; [`IssueHeading::tags`] reads both.
192    #[serde(default)]
193    pub org_tags: Vec<String>,
194    /// Trailing statistics cookies (`[2/5]`, `[40%]`), which Org keeps on
195    /// the headline after the title (manual 5.5).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub statistics: Option<String>,
198    /// Drawer keys in on-disk order, so a rewrite leaves a hand-arranged
199    /// drawer alone.
200    #[serde(skip_serializing)]
201    pub property_order: Vec<String>,
202    /// Drawers other than `:PROPERTIES:` and `:LOGBOOK:` that sat at the
203    /// drawer site. Written back after those two so a rewrite does not drop
204    /// a `:NOTES:` drawer someone put there.
205    #[serde(skip_serializing)]
206    pub extra_drawers: Vec<String>,
207    /// Prose under the heading, without the property drawer or logbook.
208    #[serde(skip_serializing)]
209    pub body: String,
210    /// `:LOGBOOK:` lines, newest first.
211    pub logbook: Vec<LogEntry>,
212    /// 1-based first line of this heading in the file.
213    pub line_start: usize,
214    /// 1-based last line of this heading in the file.
215    pub line_end: usize,
216}
217
218impl IssueHeading {
219    /// Ids this heading waits on.
220    ///
221    /// `:BLOCKED_BY:` is the vissue form. `:BLOCKEDBY:` is a typo for that.
222    /// `:BLOCKER:` is GNU ELPA org-edna (and org-depend in org-contrib):
223    /// an `ids(...)` form or a bare id list both count; `prev-sibling` and
224    /// the rest of the condition language do not.
225    pub fn blocked_by(&self) -> Vec<String> {
226        crate::org::blocker_ids_from_properties(&self.properties)
227    }
228
229    /// Org Effort estimate (`Effort` or `EFFORT`), when set.
230    pub fn effort(&self) -> Option<&str> {
231        crate::org::effort_from_properties(&self.properties)
232    }
233
234    /// Own tags plus inherited FILETAGS (Org ALLTAGS for a top-level heading).
235    pub fn all_tags(&self, filetags: &[String]) -> Vec<String> {
236        let mut tags = self.tags();
237        for tag in filetags {
238            if !tags.iter().any(|seen| seen == tag) {
239                tags.push(tag.clone());
240            }
241        }
242        tags
243    }
244
245    /// Every tag on the issue: the `:VISSUE_TAGS:` property and the heading's
246    /// own Org tags, in that order and without duplicates.
247    pub fn tags(&self) -> Vec<String> {
248        let mut tags: Vec<String> = self
249            .properties
250            .get(TAGS_PROPERTY)
251            .map(|s| {
252                s.split([',', ':'])
253                    .map(|x| x.trim().to_string())
254                    .filter(|x| !x.is_empty())
255                    .collect()
256            })
257            .unwrap_or_default();
258        for tag in &self.org_tags {
259            if !tags.iter().any(|seen| seen == tag) {
260                tags.push(tag.clone());
261            }
262        }
263        tags
264    }
265
266    /// Deadline timestamp, when the heading carries one.
267    pub fn deadline(&self) -> Option<&str> {
268        self.properties.get("DEADLINE").map(|s| s.as_str())
269    }
270
271    /// Scheduled timestamp, when the heading carries one.
272    pub fn scheduled(&self) -> Option<&str> {
273        self.properties.get("SCHEDULED").map(|s| s.as_str())
274    }
275
276    /// `:PARENT:` id, when set.
277    pub fn parent(&self) -> Option<&str> {
278        crate::props::get(&self.properties, crate::props::PARENT)
279    }
280
281    /// The identity holding this issue, when one has claimed it.
282    pub fn claimed_by(&self) -> Option<&str> {
283        crate::props::get(&self.properties, CLAIMED_BY)
284    }
285
286    /// When the claim was taken, as the stored org timestamp.
287    pub fn claimed_at(&self) -> Option<&str> {
288        crate::props::get(&self.properties, CLAIMED_AT)
289    }
290
291    /// Whole days the claim has been held, when it parses as a date.
292    pub fn claim_age_days(&self, today: chrono::NaiveDate) -> Option<i64> {
293        let taken = parse_stamp_date(self.claimed_at()?)?;
294        Some((today - taken).num_days())
295    }
296
297    /// Record the claim. Stores the identity verbatim: it is an opaque tag.
298    pub fn set_claim(&mut self, identity: &str) {
299        self.properties
300            .insert(CLAIMED_BY.to_string(), identity.to_string());
301        self.properties
302            .insert(CLAIMED_AT.to_string(), LogEntry::now());
303    }
304
305    /// Drop the claim, leaving a logbook note so the history survives the
306    /// properties being cleared.
307    pub fn release_claim(&mut self) -> Option<(String, String)> {
308        let who = self.properties.remove(CLAIMED_BY)?;
309        let when = self.properties.remove(CLAIMED_AT).unwrap_or_default();
310        self.logbook.insert(
311            0,
312            LogEntry {
313                timestamp: LogEntry::now(),
314                from_state: None,
315                to_state: None,
316                note: Some(format!("claim released: {who} held since {when}")),
317                raw: None,
318            },
319        );
320        Some((who, when))
321    }
322
323    /// Heading, planning line, drawers, and body as they are written to disk.
324    pub fn render(&self) -> String {
325        let mut org_tags = self.org_tags.clone();
326        let mut properties = self.properties.clone();
327        crate::props::settle(&mut org_tags, &mut properties);
328        let mut out = render_heading_line(
329            &self.state,
330            self.priority,
331            &self.title,
332            self.statistics.as_deref(),
333            &org_tags,
334        );
335        if let Some(planning) = self.render_planning_line() {
336            out.push_str(&planning);
337        }
338        out.push_str(":PROPERTIES:\n");
339        out.push_str(&render_property("ID", &self.id));
340        for key in self.ordered_property_keys() {
341            if key == "ID" || PLANNING_KEYS.contains(&key.as_str()) {
342                continue;
343            }
344            if let Some(val) = properties.get(&key) {
345                out.push_str(&render_property(&key, val));
346            }
347        }
348        out.push_str(":END:\n");
349        if !self.logbook.is_empty() {
350            out.push_str(":LOGBOOK:\n");
351            for entry in &self.logbook {
352                out.push_str(&entry.render());
353                out.push('\n');
354            }
355            out.push_str(":END:\n");
356        }
357        for drawer in &self.extra_drawers {
358            out.push_str(drawer);
359            if !drawer.ends_with('\n') {
360                out.push('\n');
361            }
362        }
363        if !self.body.is_empty() {
364            let body = escape_body_headlines(&self.body);
365            out.push('\n');
366            out.push_str(&body);
367            if !body.ends_with('\n') {
368                out.push('\n');
369            }
370        }
371        out
372    }
373
374    /// The `CLOSED: ... SCHEDULED: ... DEADLINE: ...` line org puts under a
375    /// heading, or `None` when the issue carries no dates.
376    ///
377    /// Org's agenda reads this line and ignores a same-named property, so a
378    /// deadline written into the drawer is a deadline Emacs cannot see.
379    fn render_planning_line(&self) -> Option<String> {
380        let parts: Vec<String> = PLANNING_KEYS
381            .iter()
382            .filter_map(|key| {
383                let value = self.properties.get(*key)?.trim();
384                (!value.is_empty()).then(|| format!("{key}: {value}"))
385            })
386            .collect();
387        (!parts.is_empty()).then(|| format!("{}\n", parts.join(" ")))
388    }
389
390    /// Keys as they appeared on disk first, then the house order, then the rest.
391    /// Rewriting an issue therefore leaves a hand-arranged drawer alone.
392    fn ordered_property_keys(&self) -> Vec<String> {
393        let mut keys = Vec::new();
394        for key in &self.property_order {
395            if self.properties.contains_key(key) && !keys.contains(key) {
396                keys.push(key.clone());
397            }
398        }
399        for key in DEFAULT_PROPERTY_ORDER {
400            let key = key.to_string();
401            if self.properties.contains_key(&key) && !keys.contains(&key) {
402                keys.push(key);
403            }
404        }
405        for key in self.properties.keys() {
406            if !keys.contains(key) {
407                keys.push(key.clone());
408            }
409        }
410        keys
411    }
412
413    /// Move to `new_state` and prepend the transition to the logbook. A
414    /// transition to the current state is not recorded.
415    pub fn record_state_change(&mut self, new_state: &str) {
416        if self.state == new_state {
417            return;
418        }
419        let from = Some(self.state.clone());
420        self.logbook.insert(
421            0,
422            LogEntry {
423                timestamp: LogEntry::now(),
424                from_state: from,
425                to_state: Some(new_state.to_string()),
426                note: None,
427                raw: None,
428            },
429        );
430        self.state = new_state.to_string();
431    }
432}
433
434/// Append a `:tag:tag:` run to a heading, right-aligned the way Org aligns it,
435/// so running `org-align-tags` in Emacs over the result changes nothing.
436pub fn align_tags(stem: &str, org_tags: &[String]) -> String {
437    if org_tags.is_empty() {
438        return stem.to_string();
439    }
440    let run = format!(":{}:", org_tags.join(":"));
441    let width = stem.chars().count() + run.chars().count();
442    let pad = if width < TAG_COLUMN {
443        TAG_COLUMN - width
444    } else {
445        1
446    };
447    format!("{stem}{}{run}", " ".repeat(pad))
448}
449
450/// `* STATE [#P] Title            :tag:tag:`.
451/// Indent body lines that would end the issue.
452///
453/// The parser splits issues on a line starting with `* `, so a body carrying
454/// one, which any markdown bullet list does, cuts the issue in two on the
455/// next read: the tail becomes a heading with no `:ID:`, the file stops
456/// parsing, and every issue in it drops out of `list`.
457///
458/// Deeper headings are left alone. `** Scope` under a level-one issue is a
459/// child of it, which is structure the body is meant to be able to carry and
460/// which [`crate::mirror`] demotes when it nests issues further down.
461///
462/// One leading space is enough, and reads the same to a person. Applying it
463/// again changes nothing, so a file written, read, and written again is
464/// stable.
465fn escape_body_headlines(body: &str) -> String {
466    if !body.lines().any(ends_the_issue) {
467        return body.to_string();
468    }
469    let mut out = String::with_capacity(body.len() + 8);
470    for (i, line) in body.split('\n').enumerate() {
471        if i > 0 {
472            out.push('\n');
473        }
474        if ends_the_issue(line) {
475            out.push(' ');
476        }
477        out.push_str(line);
478    }
479    out
480}
481
482/// Whether the parser would read this body line as the next issue heading.
483fn ends_the_issue(line: &str) -> bool {
484    line.starts_with("* ")
485}
486
487fn render_heading_line(
488    state: &str,
489    priority: char,
490    title: &str,
491    statistics: Option<&str>,
492    org_tags: &[String],
493) -> String {
494    let mut stem = format!("* {} [#{}] {}", state, priority, title);
495    if let Some(cookie) = statistics {
496        stem.push(' ');
497        stem.push_str(cookie);
498    }
499    format!("{}\n", align_tags(&stem, org_tags))
500}
501
502fn render_property(key: &str, val: &str) -> String {
503    let key_part = format!(":{}:", key);
504    let pad = if key_part.len() < PROPERTY_COLUMN {
505        " ".repeat(PROPERTY_COLUMN - key_part.len())
506    } else {
507        " ".to_string()
508    };
509    format!("{}{}{}\n", key_part, pad, val)
510}
511
512/// Today as an inactive org timestamp.
513pub fn today_inactive_bracket() -> String {
514    Local::now().format("[%Y-%m-%d %a]").to_string()
515}
516
517/// The date inside an org timestamp, active or inactive, with or without a
518/// time of day.
519pub fn parse_stamp_date(s: &str) -> Option<chrono::NaiveDate> {
520    let inner = s
521        .trim()
522        .trim_start_matches(['<', '['])
523        .trim_end_matches(['>', ']']);
524    let token = inner.split_whitespace().next()?;
525    chrono::NaiveDate::parse_from_str(token, "%Y-%m-%d").ok()
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    fn sample_heading() -> IssueHeading {
533        let mut props = BTreeMap::new();
534        props.insert("ID".into(), "sample-abc1".into());
535        props.insert("CREATED".into(), "[2026-04-25 Sat]".into());
536        IssueHeading {
537            id: "sample-abc1".into(),
538            title: "Add a thing".into(),
539            state: "TODO".into(),
540            priority: 'A',
541            properties: props,
542            org_tags: Vec::new(),
543            statistics: None,
544            property_order: vec!["ID".into(), "CREATED".into()],
545            extra_drawers: Vec::new(),
546            body: "Some body lines.\nWith multiple lines.".into(),
547            logbook: Vec::new(),
548            line_start: 4,
549            line_end: 12,
550        }
551    }
552
553    #[test]
554    fn blocked_by_accepts_commas_spaces_and_both() {
555        let mut h = sample_heading();
556        for raw in [" A-1, B-2 ,, C-3 ", "A-1 B-2  C-3", "A-1, B-2 C-3"] {
557            h.properties.insert("BLOCKED_BY".into(), raw.into());
558            assert_eq!(h.blocked_by(), vec!["A-1", "B-2", "C-3"], "raw = {raw:?}");
559        }
560    }
561
562    #[test]
563    fn blocked_by_reads_a_blocker_id_list_and_edna_ids() {
564        let mut h = sample_heading();
565        h.properties.insert("BLOCKER".into(), "A-1 B-2".into());
566        assert_eq!(h.blocked_by(), vec!["A-1", "B-2"]);
567        h.properties
568            .insert("BLOCKER".into(), "ids(A-1) prev-sibling".into());
569        assert_eq!(h.blocked_by(), vec!["A-1"]);
570        h.properties.insert("BLOCKER".into(), "prev-sibling".into());
571        assert!(h.blocked_by().is_empty());
572    }
573
574    #[test]
575    fn tags_split_on_commas_and_colons() {
576        let mut h = sample_heading();
577        h.properties
578            .insert(TAGS_PROPERTY.into(), "rust:perf, scaling".into());
579        assert_eq!(h.tags(), vec!["rust", "perf", "scaling"]);
580    }
581
582    #[test]
583    fn accessors_read_optional_properties() {
584        let mut h = sample_heading();
585        assert!(h.parent().is_none());
586        h.properties.insert("PARENT".into(), "sample-q3xa".into());
587        h.properties
588            .insert("DEADLINE".into(), "<2026-05-15 Fri>".into());
589        h.properties
590            .insert("SCHEDULED".into(), "<2026-04-28 Mon>".into());
591        assert_eq!(h.parent(), Some("sample-q3xa"));
592        assert_eq!(h.deadline(), Some("<2026-05-15 Fri>"));
593        assert_eq!(h.scheduled(), Some("<2026-04-28 Mon>"));
594    }
595
596    #[test]
597    fn state_change_prepends_and_skips_no_ops() {
598        let mut h = sample_heading();
599        h.record_state_change("STARTED");
600        assert_eq!(h.state, "STARTED");
601        assert_eq!(h.logbook[0].from_state.as_deref(), Some("TODO"));
602        h.record_state_change("DONE");
603        assert_eq!(h.logbook.len(), 2);
604        assert_eq!(h.logbook[0].to_state.as_deref(), Some("DONE"));
605        h.record_state_change("DONE");
606        assert_eq!(h.logbook.len(), 2, "no-op transition is not logged");
607    }
608
609    #[test]
610    fn logbook_renders_state_transitions() {
611        let entry = LogEntry {
612            timestamp: "[2026-04-26 Sun 14:22]".into(),
613            from_state: Some("STARTED".into()),
614            to_state: Some("DONE".into()),
615            note: None,
616            raw: None,
617        };
618        assert_eq!(
619            entry.render(),
620            "- State \"DONE\" from \"STARTED\" [2026-04-26 Sun 14:22]"
621        );
622    }
623
624    #[test]
625    fn clock_lines_survive_a_state_rewrite() {
626        let clock = "   CLOCK: [2026-07-26 Sun 17:40]";
627        let closed = "   CLOCK: [2026-07-26 Sun 10:00]--[2026-07-26 Sun 11:00] =>  1:00";
628        let state = "- State \"STARTED\" from \"TODO\" [2026-07-26 Sun 17:39]";
629        assert_eq!(parse_log_line(clock).render(), clock);
630        assert_eq!(parse_log_line(closed).render(), closed);
631        let parsed_state = parse_log_line(state);
632        assert_eq!(parsed_state.to_state.as_deref(), Some("STARTED"));
633        assert!(parsed_state.raw.is_none());
634
635        let mut h = sample_heading();
636        h.logbook = vec![parsed_state, parse_log_line(clock)];
637        h.record_state_change("DONE");
638        let rendered = h.render();
639        assert!(
640            rendered.contains("CLOCK: [2026-07-26 Sun 17:40]"),
641            "{rendered}"
642        );
643        assert!(rendered.contains("State \"DONE\""), "{rendered}");
644    }
645
646    #[test]
647    fn headline_tags_split_off_the_title() {
648        assert_eq!(
649            split_headline_tags("Document the retry policy   :docs:retry:"),
650            (
651                "Document the retry policy".to_string(),
652                vec!["docs".to_string(), "retry".to_string()]
653            )
654        );
655    }
656
657    #[test]
658    fn a_title_is_not_mistaken_for_a_tag_run() {
659        // Org tags are `[[:alnum:]_@#%]+`, so neither of these is one and the
660        // text has to survive intact.
661        for title in [
662            "Scope: the header block",
663            "Rename the key :needs-review:",
664            "A ratio of 3:1",
665            "Trailing colon:",
666        ] {
667            assert_eq!(
668                split_headline_tags(title),
669                (title.to_string(), Vec::new()),
670                "{title:?}"
671            );
672        }
673    }
674
675    #[test]
676    fn tags_read_the_property_and_the_heading_together() {
677        let mut h = sample_heading();
678        h.properties
679            .insert(TAGS_PROPERTY.into(), "needs-review, perf".into());
680        h.org_tags = vec!["docs".into(), "perf".into()];
681        assert_eq!(h.tags(), vec!["needs-review", "perf", "docs"]);
682    }
683
684    #[test]
685    fn a_heading_renders_its_tags_where_org_aligns_them() {
686        let mut h = sample_heading();
687        h.state = "TODO".into();
688        h.priority = 'B';
689        h.title = "Document the retry policy".into();
690        h.org_tags = vec!["docs".into(), "retry".into()];
691        let line = h.render().lines().next().unwrap().to_string();
692        assert_eq!(line.chars().count(), TAG_COLUMN, "{line:?}");
693        assert!(line.ends_with(":docs:retry:"), "{line:?}");
694    }
695
696    #[test]
697    fn a_long_title_keeps_one_space_before_its_tags() {
698        let mut h = sample_heading();
699        h.title = "t".repeat(TAG_COLUMN);
700        h.org_tags = vec!["docs".into()];
701        let line = h.render().lines().next().unwrap().to_string();
702        assert!(line.ends_with(" :docs:"), "{line:?}");
703    }
704
705    #[test]
706    fn note_lines_round_trip() {
707        let parsed = parse_log_line("- Note: \"picked up after review\" [2026-04-26 Sun 09:15]");
708        assert_eq!(parsed.note.as_deref(), Some("picked up after review"));
709        assert_eq!(
710            parsed.render(),
711            "- Note: \"picked up after review\" [2026-04-26 Sun 09:15]"
712        );
713    }
714}