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