1use chrono::Local;
4use serde::Serialize;
5use std::collections::BTreeMap;
6
7pub use crate::org::{PLANNING_KEYS, is_org_tag_char};
11
12pub const TODO_KEYWORDS: &[&str] = &["TODO", "STARTED", "BLOCKED", "DONE", "CANCELLED"];
14pub const READY_STATES: &[&str] = &["TODO", "STARTED"];
16pub 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
22const TAG_COLUMN: usize = 77;
26
27pub 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
56pub const TAGS_PROPERTY: &str = crate::props::TAGS;
61pub const LEGACY_TAGS_PROPERTY: &str = "TAGS";
64
65pub const CLAIMED_BY: &str = crate::props::CLAIMED_BY;
67pub const CLAIMED_AT: &str = crate::props::CLAIMED_AT;
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct LogEntry {
73 pub timestamp: String,
75 pub from_state: Option<String>,
77 pub to_state: Option<String>,
79 pub note: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub raw: Option<String>,
85}
86
87pub const CLAIM_RELEASED_NOTE: &str = "claim released:";
89
90impl LogEntry {
91 #[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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
196pub struct IssueHeading {
197 pub id: String,
199 pub title: String,
201 pub state: String,
203 pub priority: char,
205 pub properties: BTreeMap<String, String>,
207 #[serde(default)]
211 pub org_tags: Vec<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub statistics: Option<String>,
216 #[serde(skip_serializing)]
219 pub property_order: Vec<String>,
220 #[serde(skip_serializing)]
224 pub extra_drawers: Vec<String>,
225 #[serde(skip_serializing)]
227 pub body: String,
228 pub logbook: Vec<LogEntry>,
230 pub line_start: usize,
232 pub line_end: usize,
234}
235
236impl IssueHeading {
237 pub fn blocked_by(&self) -> Vec<String> {
244 crate::org::blocker_ids_from_properties(&self.properties)
245 }
246
247 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 pub fn effort(&self) -> Option<&str> {
261 crate::org::effort_from_properties(&self.properties)
262 }
263
264 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 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 pub fn deadline(&self) -> Option<&str> {
298 self.properties.get("DEADLINE").map(|s| s.as_str())
299 }
300
301 pub fn scheduled(&self) -> Option<&str> {
303 self.properties.get("SCHEDULED").map(|s| s.as_str())
304 }
305
306 pub fn parent(&self) -> Option<&str> {
308 crate::props::get(&self.properties, crate::props::PARENT)
309 }
310
311 pub fn claimed_by(&self) -> Option<&str> {
313 crate::props::get(&self.properties, CLAIMED_BY)
314 }
315
316 pub fn claimed_at(&self) -> Option<&str> {
318 crate::props::get(&self.properties, CLAIMED_AT)
319 }
320
321 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 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 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 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 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 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 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
468pub 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
484fn 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
516fn 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
546pub fn today_inactive_bracket() -> String {
548 Local::now().format("[%Y-%m-%d %a]").to_string()
549}
550
551pub 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 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}