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
87impl LogEntry {
88 pub fn render(&self) -> String {
90 if let Some(raw) = &self.raw {
91 return raw.clone();
92 }
93 if let (Some(to), Some(from)) = (&self.to_state, &self.from_state) {
94 format!("- State \"{}\" from \"{}\" {}", to, from, self.timestamp)
95 } else if let Some(to) = &self.to_state {
96 format!("- State \"{}\" {}", to, self.timestamp)
97 } else if let Some(note) = &self.note {
98 format!("- Note: \"{}\" {}", note, self.timestamp)
99 } else {
100 format!("- {}", self.timestamp)
101 }
102 }
103
104 pub fn now() -> String {
106 Local::now().format("[%Y-%m-%d %a %H:%M]").to_string()
107 }
108
109 fn raw_line(line: &str) -> Self {
110 Self {
111 timestamp: String::new(),
112 from_state: None,
113 to_state: None,
114 note: None,
115 raw: Some(line.trim_end_matches(['\r', '\n']).to_string()),
116 }
117 }
118}
119
120pub(crate) fn parse_log_line(s: &str) -> LogEntry {
121 let trimmed = s.trim();
123 if trimmed.to_ascii_uppercase().starts_with("CLOCK:")
124 || (!trimmed.starts_with('-') && !trimmed.is_empty())
125 {
126 return LogEntry::raw_line(s);
127 }
128 let Some(body) = trimmed.strip_prefix('-').map(str::trim_start) else {
129 return LogEntry::raw_line(s);
130 };
131 let Some(bracket_idx) = body.rfind('[') else {
132 return LogEntry::raw_line(s);
133 };
134 let Some(rel_end) = body[bracket_idx..].find(']') else {
135 return LogEntry::raw_line(s);
136 };
137 let end = rel_end + bracket_idx;
138 let timestamp = body[bracket_idx..=end].to_string();
139 let prefix = body[..bracket_idx].trim().trim_end_matches(',');
140 if let Some(rest) = prefix.strip_prefix("State ") {
141 let mut chunks = rest.splitn(2, " from ");
142 let to_quoted = chunks.next().unwrap_or("").trim();
143 let from_quoted = chunks.next().map(str::trim);
144 LogEntry {
145 timestamp,
146 from_state: from_quoted.map(|s| s.trim_matches('"').to_string()),
147 to_state: Some(to_quoted.trim_matches('"').to_string()),
148 note: None,
149 raw: None,
150 }
151 } else if let Some(rest) = prefix.strip_prefix("Note:") {
152 let note = rest.trim().trim_matches('"').to_string();
153 LogEntry {
154 timestamp,
155 from_state: None,
156 to_state: None,
157 note: if note.is_empty() { None } else { Some(note) },
158 raw: None,
159 }
160 } else {
161 LogEntry {
162 timestamp,
163 from_state: None,
164 to_state: None,
165 note: if prefix.is_empty() {
166 None
167 } else {
168 Some(prefix.to_string())
169 },
170 raw: None,
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
178pub struct IssueHeading {
179 pub id: String,
181 pub title: String,
183 pub state: String,
185 pub priority: char,
187 pub properties: BTreeMap<String, String>,
189 #[serde(default)]
193 pub org_tags: Vec<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub statistics: Option<String>,
198 #[serde(skip_serializing)]
201 pub property_order: Vec<String>,
202 #[serde(skip_serializing)]
206 pub extra_drawers: Vec<String>,
207 #[serde(skip_serializing)]
209 pub body: String,
210 pub logbook: Vec<LogEntry>,
212 pub line_start: usize,
214 pub line_end: usize,
216}
217
218impl IssueHeading {
219 pub fn blocked_by(&self) -> Vec<String> {
226 crate::org::blocker_ids_from_properties(&self.properties)
227 }
228
229 pub fn effort(&self) -> Option<&str> {
231 crate::org::effort_from_properties(&self.properties)
232 }
233
234 pub fn all_tags(&self, filetags: &[String]) -> Vec<String> {
236 let mut tags = self.tags();
237 for tag in filetags {
238 if !tags.iter().any(|seen| seen == tag) {
239 tags.push(tag.clone());
240 }
241 }
242 tags
243 }
244
245 pub fn tags(&self) -> Vec<String> {
248 let mut tags: Vec<String> = self
249 .properties
250 .get(TAGS_PROPERTY)
251 .map(|s| {
252 s.split([',', ':'])
253 .map(|x| x.trim().to_string())
254 .filter(|x| !x.is_empty())
255 .collect()
256 })
257 .unwrap_or_default();
258 for tag in &self.org_tags {
259 if !tags.iter().any(|seen| seen == tag) {
260 tags.push(tag.clone());
261 }
262 }
263 tags
264 }
265
266 pub fn deadline(&self) -> Option<&str> {
268 self.properties.get("DEADLINE").map(|s| s.as_str())
269 }
270
271 pub fn scheduled(&self) -> Option<&str> {
273 self.properties.get("SCHEDULED").map(|s| s.as_str())
274 }
275
276 pub fn parent(&self) -> Option<&str> {
278 crate::props::get(&self.properties, crate::props::PARENT)
279 }
280
281 pub fn claimed_by(&self) -> Option<&str> {
283 crate::props::get(&self.properties, CLAIMED_BY)
284 }
285
286 pub fn claimed_at(&self) -> Option<&str> {
288 crate::props::get(&self.properties, CLAIMED_AT)
289 }
290
291 pub fn claim_age_days(&self, today: chrono::NaiveDate) -> Option<i64> {
293 let taken = parse_stamp_date(self.claimed_at()?)?;
294 Some((today - taken).num_days())
295 }
296
297 pub fn set_claim(&mut self, identity: &str) {
299 self.properties
300 .insert(CLAIMED_BY.to_string(), identity.to_string());
301 self.properties
302 .insert(CLAIMED_AT.to_string(), LogEntry::now());
303 }
304
305 pub fn release_claim(&mut self) -> Option<(String, String)> {
308 let who = self.properties.remove(CLAIMED_BY)?;
309 let when = self.properties.remove(CLAIMED_AT).unwrap_or_default();
310 self.logbook.insert(
311 0,
312 LogEntry {
313 timestamp: LogEntry::now(),
314 from_state: None,
315 to_state: None,
316 note: Some(format!("claim released: {who} held since {when}")),
317 raw: None,
318 },
319 );
320 Some((who, when))
321 }
322
323 pub fn render(&self) -> String {
325 let mut org_tags = self.org_tags.clone();
326 let mut properties = self.properties.clone();
327 crate::props::settle(&mut org_tags, &mut properties);
328 let mut out = render_heading_line(
329 &self.state,
330 self.priority,
331 &self.title,
332 self.statistics.as_deref(),
333 &org_tags,
334 );
335 if let Some(planning) = self.render_planning_line() {
336 out.push_str(&planning);
337 }
338 out.push_str(":PROPERTIES:\n");
339 out.push_str(&render_property("ID", &self.id));
340 for key in self.ordered_property_keys() {
341 if key == "ID" || PLANNING_KEYS.contains(&key.as_str()) {
342 continue;
343 }
344 if let Some(val) = properties.get(&key) {
345 out.push_str(&render_property(&key, val));
346 }
347 }
348 out.push_str(":END:\n");
349 if !self.logbook.is_empty() {
350 out.push_str(":LOGBOOK:\n");
351 for entry in &self.logbook {
352 out.push_str(&entry.render());
353 out.push('\n');
354 }
355 out.push_str(":END:\n");
356 }
357 for drawer in &self.extra_drawers {
358 out.push_str(drawer);
359 if !drawer.ends_with('\n') {
360 out.push('\n');
361 }
362 }
363 if !self.body.is_empty() {
364 let body = escape_body_headlines(&self.body);
365 out.push('\n');
366 out.push_str(&body);
367 if !body.ends_with('\n') {
368 out.push('\n');
369 }
370 }
371 out
372 }
373
374 fn render_planning_line(&self) -> Option<String> {
380 let parts: Vec<String> = PLANNING_KEYS
381 .iter()
382 .filter_map(|key| {
383 let value = self.properties.get(*key)?.trim();
384 (!value.is_empty()).then(|| format!("{key}: {value}"))
385 })
386 .collect();
387 (!parts.is_empty()).then(|| format!("{}\n", parts.join(" ")))
388 }
389
390 fn ordered_property_keys(&self) -> Vec<String> {
393 let mut keys = Vec::new();
394 for key in &self.property_order {
395 if self.properties.contains_key(key) && !keys.contains(key) {
396 keys.push(key.clone());
397 }
398 }
399 for key in DEFAULT_PROPERTY_ORDER {
400 let key = key.to_string();
401 if self.properties.contains_key(&key) && !keys.contains(&key) {
402 keys.push(key);
403 }
404 }
405 for key in self.properties.keys() {
406 if !keys.contains(key) {
407 keys.push(key.clone());
408 }
409 }
410 keys
411 }
412
413 pub fn record_state_change(&mut self, new_state: &str) {
416 if self.state == new_state {
417 return;
418 }
419 let from = Some(self.state.clone());
420 self.logbook.insert(
421 0,
422 LogEntry {
423 timestamp: LogEntry::now(),
424 from_state: from,
425 to_state: Some(new_state.to_string()),
426 note: None,
427 raw: None,
428 },
429 );
430 self.state = new_state.to_string();
431 }
432}
433
434pub fn align_tags(stem: &str, org_tags: &[String]) -> String {
437 if org_tags.is_empty() {
438 return stem.to_string();
439 }
440 let run = format!(":{}:", org_tags.join(":"));
441 let width = stem.chars().count() + run.chars().count();
442 let pad = if width < TAG_COLUMN {
443 TAG_COLUMN - width
444 } else {
445 1
446 };
447 format!("{stem}{}{run}", " ".repeat(pad))
448}
449
450fn escape_body_headlines(body: &str) -> String {
466 if !body.lines().any(ends_the_issue) {
467 return body.to_string();
468 }
469 let mut out = String::with_capacity(body.len() + 8);
470 for (i, line) in body.split('\n').enumerate() {
471 if i > 0 {
472 out.push('\n');
473 }
474 if ends_the_issue(line) {
475 out.push(' ');
476 }
477 out.push_str(line);
478 }
479 out
480}
481
482fn ends_the_issue(line: &str) -> bool {
484 line.starts_with("* ")
485}
486
487fn render_heading_line(
488 state: &str,
489 priority: char,
490 title: &str,
491 statistics: Option<&str>,
492 org_tags: &[String],
493) -> String {
494 let mut stem = format!("* {} [#{}] {}", state, priority, title);
495 if let Some(cookie) = statistics {
496 stem.push(' ');
497 stem.push_str(cookie);
498 }
499 format!("{}\n", align_tags(&stem, org_tags))
500}
501
502fn render_property(key: &str, val: &str) -> String {
503 let key_part = format!(":{}:", key);
504 let pad = if key_part.len() < PROPERTY_COLUMN {
505 " ".repeat(PROPERTY_COLUMN - key_part.len())
506 } else {
507 " ".to_string()
508 };
509 format!("{}{}{}\n", key_part, pad, val)
510}
511
512pub fn today_inactive_bracket() -> String {
514 Local::now().format("[%Y-%m-%d %a]").to_string()
515}
516
517pub fn parse_stamp_date(s: &str) -> Option<chrono::NaiveDate> {
520 let inner = s
521 .trim()
522 .trim_start_matches(['<', '['])
523 .trim_end_matches(['>', ']']);
524 let token = inner.split_whitespace().next()?;
525 chrono::NaiveDate::parse_from_str(token, "%Y-%m-%d").ok()
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 fn sample_heading() -> IssueHeading {
533 let mut props = BTreeMap::new();
534 props.insert("ID".into(), "sample-abc1".into());
535 props.insert("CREATED".into(), "[2026-04-25 Sat]".into());
536 IssueHeading {
537 id: "sample-abc1".into(),
538 title: "Add a thing".into(),
539 state: "TODO".into(),
540 priority: 'A',
541 properties: props,
542 org_tags: Vec::new(),
543 statistics: None,
544 property_order: vec!["ID".into(), "CREATED".into()],
545 extra_drawers: Vec::new(),
546 body: "Some body lines.\nWith multiple lines.".into(),
547 logbook: Vec::new(),
548 line_start: 4,
549 line_end: 12,
550 }
551 }
552
553 #[test]
554 fn blocked_by_accepts_commas_spaces_and_both() {
555 let mut h = sample_heading();
556 for raw in [" A-1, B-2 ,, C-3 ", "A-1 B-2 C-3", "A-1, B-2 C-3"] {
557 h.properties.insert("BLOCKED_BY".into(), raw.into());
558 assert_eq!(h.blocked_by(), vec!["A-1", "B-2", "C-3"], "raw = {raw:?}");
559 }
560 }
561
562 #[test]
563 fn blocked_by_reads_a_blocker_id_list_and_edna_ids() {
564 let mut h = sample_heading();
565 h.properties.insert("BLOCKER".into(), "A-1 B-2".into());
566 assert_eq!(h.blocked_by(), vec!["A-1", "B-2"]);
567 h.properties
568 .insert("BLOCKER".into(), "ids(A-1) prev-sibling".into());
569 assert_eq!(h.blocked_by(), vec!["A-1"]);
570 h.properties.insert("BLOCKER".into(), "prev-sibling".into());
571 assert!(h.blocked_by().is_empty());
572 }
573
574 #[test]
575 fn tags_split_on_commas_and_colons() {
576 let mut h = sample_heading();
577 h.properties
578 .insert(TAGS_PROPERTY.into(), "rust:perf, scaling".into());
579 assert_eq!(h.tags(), vec!["rust", "perf", "scaling"]);
580 }
581
582 #[test]
583 fn accessors_read_optional_properties() {
584 let mut h = sample_heading();
585 assert!(h.parent().is_none());
586 h.properties.insert("PARENT".into(), "sample-q3xa".into());
587 h.properties
588 .insert("DEADLINE".into(), "<2026-05-15 Fri>".into());
589 h.properties
590 .insert("SCHEDULED".into(), "<2026-04-28 Mon>".into());
591 assert_eq!(h.parent(), Some("sample-q3xa"));
592 assert_eq!(h.deadline(), Some("<2026-05-15 Fri>"));
593 assert_eq!(h.scheduled(), Some("<2026-04-28 Mon>"));
594 }
595
596 #[test]
597 fn state_change_prepends_and_skips_no_ops() {
598 let mut h = sample_heading();
599 h.record_state_change("STARTED");
600 assert_eq!(h.state, "STARTED");
601 assert_eq!(h.logbook[0].from_state.as_deref(), Some("TODO"));
602 h.record_state_change("DONE");
603 assert_eq!(h.logbook.len(), 2);
604 assert_eq!(h.logbook[0].to_state.as_deref(), Some("DONE"));
605 h.record_state_change("DONE");
606 assert_eq!(h.logbook.len(), 2, "no-op transition is not logged");
607 }
608
609 #[test]
610 fn logbook_renders_state_transitions() {
611 let entry = LogEntry {
612 timestamp: "[2026-04-26 Sun 14:22]".into(),
613 from_state: Some("STARTED".into()),
614 to_state: Some("DONE".into()),
615 note: None,
616 raw: None,
617 };
618 assert_eq!(
619 entry.render(),
620 "- State \"DONE\" from \"STARTED\" [2026-04-26 Sun 14:22]"
621 );
622 }
623
624 #[test]
625 fn clock_lines_survive_a_state_rewrite() {
626 let clock = " CLOCK: [2026-07-26 Sun 17:40]";
627 let closed = " CLOCK: [2026-07-26 Sun 10:00]--[2026-07-26 Sun 11:00] => 1:00";
628 let state = "- State \"STARTED\" from \"TODO\" [2026-07-26 Sun 17:39]";
629 assert_eq!(parse_log_line(clock).render(), clock);
630 assert_eq!(parse_log_line(closed).render(), closed);
631 let parsed_state = parse_log_line(state);
632 assert_eq!(parsed_state.to_state.as_deref(), Some("STARTED"));
633 assert!(parsed_state.raw.is_none());
634
635 let mut h = sample_heading();
636 h.logbook = vec![parsed_state, parse_log_line(clock)];
637 h.record_state_change("DONE");
638 let rendered = h.render();
639 assert!(
640 rendered.contains("CLOCK: [2026-07-26 Sun 17:40]"),
641 "{rendered}"
642 );
643 assert!(rendered.contains("State \"DONE\""), "{rendered}");
644 }
645
646 #[test]
647 fn headline_tags_split_off_the_title() {
648 assert_eq!(
649 split_headline_tags("Document the retry policy :docs:retry:"),
650 (
651 "Document the retry policy".to_string(),
652 vec!["docs".to_string(), "retry".to_string()]
653 )
654 );
655 }
656
657 #[test]
658 fn a_title_is_not_mistaken_for_a_tag_run() {
659 for title in [
662 "Scope: the header block",
663 "Rename the key :needs-review:",
664 "A ratio of 3:1",
665 "Trailing colon:",
666 ] {
667 assert_eq!(
668 split_headline_tags(title),
669 (title.to_string(), Vec::new()),
670 "{title:?}"
671 );
672 }
673 }
674
675 #[test]
676 fn tags_read_the_property_and_the_heading_together() {
677 let mut h = sample_heading();
678 h.properties
679 .insert(TAGS_PROPERTY.into(), "needs-review, perf".into());
680 h.org_tags = vec!["docs".into(), "perf".into()];
681 assert_eq!(h.tags(), vec!["needs-review", "perf", "docs"]);
682 }
683
684 #[test]
685 fn a_heading_renders_its_tags_where_org_aligns_them() {
686 let mut h = sample_heading();
687 h.state = "TODO".into();
688 h.priority = 'B';
689 h.title = "Document the retry policy".into();
690 h.org_tags = vec!["docs".into(), "retry".into()];
691 let line = h.render().lines().next().unwrap().to_string();
692 assert_eq!(line.chars().count(), TAG_COLUMN, "{line:?}");
693 assert!(line.ends_with(":docs:retry:"), "{line:?}");
694 }
695
696 #[test]
697 fn a_long_title_keeps_one_space_before_its_tags() {
698 let mut h = sample_heading();
699 h.title = "t".repeat(TAG_COLUMN);
700 h.org_tags = vec!["docs".into()];
701 let line = h.render().lines().next().unwrap().to_string();
702 assert!(line.ends_with(" :docs:"), "{line:?}");
703 }
704
705 #[test]
706 fn note_lines_round_trip() {
707 let parsed = parse_log_line("- Note: \"picked up after review\" [2026-04-26 Sun 09:15]");
708 assert_eq!(parsed.note.as_deref(), Some("picked up after review"));
709 assert_eq!(
710 parsed.render(),
711 "- Note: \"picked up after review\" [2026-04-26 Sun 09:15]"
712 );
713 }
714}