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