1use chrono::Local;
4use serde::Serialize;
5use std::collections::BTreeMap;
6
7pub const TODO_KEYWORDS: &[&str] = &["TODO", "STARTED", "BLOCKED", "DONE", "CANCELLED"];
9pub const READY_STATES: &[&str] = &["TODO", "STARTED"];
11pub 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
27pub const PLANNING_KEYS: &[&str] = &["CLOSED", "SCHEDULED", "DEADLINE"];
34
35const TAG_COLUMN: usize = 77;
39
40pub fn is_org_tag_char(c: char) -> bool {
44 c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%')
45}
46
47pub 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
76pub const TAGS_PROPERTY: &str = "VISSUE_TAGS";
81pub const LEGACY_TAGS_PROPERTY: &str = "TAGS";
84
85pub const CLAIMED_BY: &str = "CLAIMED_BY";
87pub const CLAIMED_AT: &str = "CLAIMED_AT";
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct LogEntry {
93 pub timestamp: String,
95 pub from_state: Option<String>,
97 pub to_state: Option<String>,
99 pub note: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub raw: Option<String>,
105}
106
107impl LogEntry {
108 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
198pub struct IssueHeading {
199 pub id: String,
201 pub title: String,
203 pub state: String,
205 pub priority: char,
207 pub properties: BTreeMap<String, String>,
209 #[serde(default)]
213 pub org_tags: Vec<String>,
214 #[serde(skip_serializing)]
217 pub property_order: Vec<String>,
218 #[serde(skip_serializing)]
220 pub body: String,
221 pub logbook: Vec<LogEntry>,
223 pub line_start: usize,
225 pub line_end: usize,
227}
228
229impl IssueHeading {
230 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 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 pub fn deadline(&self) -> Option<&str> {
267 self.properties.get("DEADLINE").map(|s| s.as_str())
268 }
269
270 pub fn scheduled(&self) -> Option<&str> {
272 self.properties.get("SCHEDULED").map(|s| s.as_str())
273 }
274
275 pub fn parent(&self) -> Option<&str> {
277 self.properties.get("PARENT").map(|s| s.as_str())
278 }
279
280 pub fn claimed_by(&self) -> Option<&str> {
282 self.properties.get(CLAIMED_BY).map(|s| s.as_str())
283 }
284
285 pub fn claimed_at(&self) -> Option<&str> {
287 self.properties.get(CLAIMED_AT).map(|s| s.as_str())
288 }
289
290 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 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 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 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 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 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 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
418pub 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
434fn 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
466fn 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
486pub fn today_inactive_bracket() -> String {
488 Local::now().format("[%Y-%m-%d %a]").to_string()
489}
490
491pub 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 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}