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