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,
94 pub from_state: Option<String>,
95 pub to_state: Option<String>,
96 pub note: Option<String>,
97 #[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 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 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#[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 #[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 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 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 pub fn claimed_by(&self) -> Option<&str> {
263 self.properties.get(CLAIMED_BY).map(|s| s.as_str())
264 }
265
266 pub fn claimed_at(&self) -> Option<&str> {
268 self.properties.get(CLAIMED_AT).map(|s| s.as_str())
269 }
270
271 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 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 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 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 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 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
398pub 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
414fn 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
446fn 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
466pub fn today_inactive_bracket() -> String {
468 Local::now().format("[%Y-%m-%d %a]").to_string()
469}
470
471pub 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 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}