use chrono::Local;
use serde::Serialize;
use std::collections::BTreeMap;
pub use crate::org::{PLANNING_KEYS, is_org_tag_char};
pub const TODO_KEYWORDS: &[&str] = &["TODO", "STARTED", "BLOCKED", "DONE", "CANCELLED"];
pub const READY_STATES: &[&str] = &["TODO", "STARTED"];
pub const TODO_HEADER: &str = "#+TODO: TODO STARTED BLOCKED | DONE CANCELLED";
const PROPERTY_COLUMN: usize = 13;
const DEFAULT_PROPERTY_ORDER: &[&str] = crate::props::CANONICAL_ORDER;
const TAG_COLUMN: usize = 77;
pub fn split_headline_tags(text: &str) -> (String, Vec<String>) {
let trimmed = text.trim_end();
let Some(run_start) = trimmed.rfind(char::is_whitespace).map(|i| i + 1) else {
return (trimmed.to_string(), Vec::new());
};
let run = &trimmed[run_start..];
if run.len() < 3 || !run.starts_with(':') || !run.ends_with(':') {
return (trimmed.to_string(), Vec::new());
}
let tags: Vec<String> = run
.trim_matches(':')
.split(':')
.map(str::to_string)
.collect();
if tags.is_empty()
|| tags
.iter()
.any(|tag| tag.is_empty() || !tag.chars().all(is_org_tag_char))
{
return (trimmed.to_string(), Vec::new());
}
(trimmed[..run_start].trim_end().to_string(), tags)
}
pub const TAGS_PROPERTY: &str = crate::props::TAGS;
pub const LEGACY_TAGS_PROPERTY: &str = "TAGS";
pub const CLAIMED_BY: &str = crate::props::CLAIMED_BY;
pub const CLAIMED_AT: &str = crate::props::CLAIMED_AT;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LogEntry {
pub timestamp: String,
pub from_state: Option<String>,
pub to_state: Option<String>,
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
}
pub const CLAIM_RELEASED_NOTE: &str = "claim released:";
impl LogEntry {
#[must_use]
pub fn is_bookkeeping(&self) -> bool {
self.note
.as_deref()
.is_some_and(|note| note.trim_start().starts_with(CLAIM_RELEASED_NOTE))
}
pub fn render(&self) -> String {
if let Some(raw) = &self.raw {
return raw.clone();
}
if let (Some(to), Some(from)) = (&self.to_state, &self.from_state) {
format!("- State \"{}\" from \"{}\" {}", to, from, self.timestamp)
} else if let Some(to) = &self.to_state {
format!("- State \"{}\" {}", to, self.timestamp)
} else if let Some(note) = &self.note {
format!("- Note: \"{}\" {}", note, self.timestamp)
} else {
format!("- {}", self.timestamp)
}
}
pub fn now() -> String {
Local::now().format("[%Y-%m-%d %a %H:%M]").to_string()
}
fn raw_line(line: &str) -> Self {
Self {
timestamp: String::new(),
from_state: None,
to_state: None,
note: None,
raw: Some(line.trim_end_matches(['\r', '\n']).to_string()),
}
}
}
pub(crate) fn parse_log_line(s: &str) -> LogEntry {
let trimmed = s.trim();
if trimmed.to_ascii_uppercase().starts_with("CLOCK:")
|| (!trimmed.starts_with('-') && !trimmed.is_empty())
{
return LogEntry::raw_line(s);
}
let Some(body) = trimmed.strip_prefix('-').map(str::trim_start) else {
return LogEntry::raw_line(s);
};
let Some(bracket_idx) = body.rfind('[') else {
return LogEntry::raw_line(s);
};
let Some(rel_end) = body[bracket_idx..].find(']') else {
return LogEntry::raw_line(s);
};
let end = rel_end + bracket_idx;
let timestamp = body[bracket_idx..=end].to_string();
let prefix = body[..bracket_idx].trim().trim_end_matches(',');
if let Some(rest) = prefix.strip_prefix("State ") {
let mut chunks = rest.splitn(2, " from ");
let to_quoted = chunks.next().unwrap_or("").trim();
let from_quoted = chunks.next().map(str::trim);
LogEntry {
timestamp,
from_state: from_quoted.map(|s| s.trim_matches('"').to_string()),
to_state: Some(to_quoted.trim_matches('"').to_string()),
note: None,
raw: None,
}
} else if let Some(rest) = prefix.strip_prefix("Note:") {
let note = rest.trim().trim_matches('"').to_string();
LogEntry {
timestamp,
from_state: None,
to_state: None,
note: if note.is_empty() { None } else { Some(note) },
raw: None,
}
} else {
LogEntry {
timestamp,
from_state: None,
to_state: None,
note: if prefix.is_empty() {
None
} else {
Some(prefix.to_string())
},
raw: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IssueHeading {
pub id: String,
pub title: String,
pub state: String,
pub priority: char,
pub properties: BTreeMap<String, String>,
#[serde(default)]
pub org_tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub statistics: Option<String>,
#[serde(skip_serializing)]
pub property_order: Vec<String>,
#[serde(skip_serializing)]
pub extra_drawers: Vec<String>,
#[serde(skip_serializing)]
pub body: String,
pub logbook: Vec<LogEntry>,
pub line_start: usize,
pub line_end: usize,
}
impl IssueHeading {
pub fn blocked_by(&self) -> Vec<String> {
crate::org::blocker_ids_from_properties(&self.properties)
}
pub fn deeds(&self) -> Vec<String> {
crate::props::get(&self.properties, crate::props::DEEDS)
.map(crate::org::split_id_list)
.unwrap_or_default()
}
pub fn effort(&self) -> Option<&str> {
crate::org::effort_from_properties(&self.properties)
}
pub fn all_tags(&self, filetags: &[String]) -> Vec<String> {
let mut tags = self.tags();
for tag in filetags {
if !tags.iter().any(|seen| seen == tag) {
tags.push(tag.clone());
}
}
tags
}
pub fn tags(&self) -> Vec<String> {
let mut tags: Vec<String> = self
.properties
.get(TAGS_PROPERTY)
.map(|s| {
s.split([',', ':'])
.map(|x| x.trim().to_string())
.filter(|x| !x.is_empty())
.collect()
})
.unwrap_or_default();
for tag in &self.org_tags {
if !tags.iter().any(|seen| seen == tag) {
tags.push(tag.clone());
}
}
tags
}
pub fn deadline(&self) -> Option<&str> {
self.properties.get("DEADLINE").map(|s| s.as_str())
}
pub fn scheduled(&self) -> Option<&str> {
self.properties.get("SCHEDULED").map(|s| s.as_str())
}
pub fn parent(&self) -> Option<&str> {
crate::props::get(&self.properties, crate::props::PARENT)
}
pub fn claimed_by(&self) -> Option<&str> {
crate::props::get(&self.properties, CLAIMED_BY)
}
pub fn claimed_at(&self) -> Option<&str> {
crate::props::get(&self.properties, CLAIMED_AT)
}
pub fn claim_age_days(&self, today: chrono::NaiveDate) -> Option<i64> {
let taken = parse_stamp_date(self.claimed_at()?)?;
Some((today - taken).num_days())
}
pub fn set_claim(&mut self, identity: &str) {
self.properties
.insert(CLAIMED_BY.to_string(), identity.to_string());
self.properties
.insert(CLAIMED_AT.to_string(), LogEntry::now());
}
pub fn release_claim(&mut self) -> Option<(String, String)> {
let who = self.properties.remove(CLAIMED_BY)?;
let when = self.properties.remove(CLAIMED_AT).unwrap_or_default();
self.logbook.insert(
0,
LogEntry {
timestamp: LogEntry::now(),
from_state: None,
to_state: None,
note: Some(format!("{CLAIM_RELEASED_NOTE} {who} held since {when}")),
raw: None,
},
);
Some((who, when))
}
pub fn render(&self) -> String {
let mut org_tags = self.org_tags.clone();
let mut properties = self.properties.clone();
crate::props::settle(&mut org_tags, &mut properties);
let mut out = render_heading_line(
&self.state,
self.priority,
&self.title,
self.statistics.as_deref(),
&org_tags,
);
if let Some(planning) = self.render_planning_line() {
out.push_str(&planning);
}
out.push_str(":PROPERTIES:\n");
out.push_str(&render_property("ID", &self.id));
for key in self.ordered_property_keys() {
if key == "ID" || PLANNING_KEYS.contains(&key.as_str()) {
continue;
}
if let Some(val) = properties.get(&key) {
out.push_str(&render_property(&key, val));
}
}
out.push_str(":END:\n");
if !self.logbook.is_empty() {
out.push_str(":LOGBOOK:\n");
for entry in &self.logbook {
out.push_str(&entry.render());
out.push('\n');
}
out.push_str(":END:\n");
}
for drawer in &self.extra_drawers {
out.push_str(drawer);
if !drawer.ends_with('\n') {
out.push('\n');
}
}
if !self.body.is_empty() {
let body = escape_body_headlines(&self.body);
out.push('\n');
out.push_str(&body);
if !body.ends_with('\n') {
out.push('\n');
}
}
out
}
fn render_planning_line(&self) -> Option<String> {
let parts: Vec<String> = PLANNING_KEYS
.iter()
.filter_map(|key| {
let value = self.properties.get(*key)?.trim();
(!value.is_empty()).then(|| format!("{key}: {value}"))
})
.collect();
(!parts.is_empty()).then(|| format!("{}\n", parts.join(" ")))
}
fn ordered_property_keys(&self) -> Vec<String> {
let mut keys = Vec::new();
for key in &self.property_order {
if self.properties.contains_key(key) && !keys.contains(key) {
keys.push(key.clone());
}
}
for key in DEFAULT_PROPERTY_ORDER {
let key = key.to_string();
if self.properties.contains_key(&key) && !keys.contains(&key) {
keys.push(key);
}
}
for key in self.properties.keys() {
if !keys.contains(key) {
keys.push(key.clone());
}
}
keys
}
pub fn record_state_change(&mut self, new_state: &str) {
if self.state == new_state {
return;
}
let from = Some(self.state.clone());
self.logbook.insert(
0,
LogEntry {
timestamp: LogEntry::now(),
from_state: from,
to_state: Some(new_state.to_string()),
note: None,
raw: None,
},
);
self.state = new_state.to_string();
}
}
pub fn align_tags(stem: &str, org_tags: &[String]) -> String {
if org_tags.is_empty() {
return stem.to_string();
}
let run = format!(":{}:", org_tags.join(":"));
let width = stem.chars().count() + run.chars().count();
let pad = if width < TAG_COLUMN {
TAG_COLUMN - width
} else {
1
};
format!("{stem}{}{run}", " ".repeat(pad))
}
fn escape_body_headlines(body: &str) -> String {
if !body.lines().any(ends_the_issue) {
return body.to_string();
}
let mut out = String::with_capacity(body.len() + 8);
for (i, line) in body.split('\n').enumerate() {
if i > 0 {
out.push('\n');
}
if ends_the_issue(line) {
out.push(' ');
}
out.push_str(line);
}
out
}
fn ends_the_issue(line: &str) -> bool {
line.starts_with("* ")
}
fn render_heading_line(
state: &str,
priority: char,
title: &str,
statistics: Option<&str>,
org_tags: &[String],
) -> String {
let mut stem = format!("* {} [#{}] {}", state, priority, title);
if let Some(cookie) = statistics {
stem.push(' ');
stem.push_str(cookie);
}
format!("{}\n", align_tags(&stem, org_tags))
}
fn render_property(key: &str, val: &str) -> String {
let key_part = format!(":{}:", key);
let pad = if key_part.len() < PROPERTY_COLUMN {
" ".repeat(PROPERTY_COLUMN - key_part.len())
} else {
" ".to_string()
};
format!("{}{}{}\n", key_part, pad, val)
}
pub fn today_inactive_bracket() -> String {
Local::now().format("[%Y-%m-%d %a]").to_string()
}
pub fn parse_stamp_date(s: &str) -> Option<chrono::NaiveDate> {
let inner = s
.trim()
.trim_start_matches(['<', '['])
.trim_end_matches(['>', ']']);
let token = inner.split_whitespace().next()?;
chrono::NaiveDate::parse_from_str(token, "%Y-%m-%d").ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_heading() -> IssueHeading {
let mut props = BTreeMap::new();
props.insert("ID".into(), "sample-abc1".into());
props.insert("CREATED".into(), "[2026-04-25 Sat]".into());
IssueHeading {
id: "sample-abc1".into(),
title: "Add a thing".into(),
state: "TODO".into(),
priority: 'A',
properties: props,
org_tags: Vec::new(),
statistics: None,
property_order: vec!["ID".into(), "CREATED".into()],
extra_drawers: Vec::new(),
body: "Some body lines.\nWith multiple lines.".into(),
logbook: Vec::new(),
line_start: 4,
line_end: 12,
}
}
#[test]
fn blocked_by_accepts_commas_spaces_and_both() {
let mut h = sample_heading();
for raw in [" A-1, B-2 ,, C-3 ", "A-1 B-2 C-3", "A-1, B-2 C-3"] {
h.properties.insert("BLOCKED_BY".into(), raw.into());
assert_eq!(h.blocked_by(), vec!["A-1", "B-2", "C-3"], "raw = {raw:?}");
}
}
#[test]
fn blocked_by_reads_a_blocker_id_list_and_edna_ids() {
let mut h = sample_heading();
h.properties.insert("BLOCKER".into(), "A-1 B-2".into());
assert_eq!(h.blocked_by(), vec!["A-1", "B-2"]);
h.properties
.insert("BLOCKER".into(), "ids(A-1) prev-sibling".into());
assert_eq!(h.blocked_by(), vec!["A-1"]);
h.properties.insert("BLOCKER".into(), "prev-sibling".into());
assert!(h.blocked_by().is_empty());
}
#[test]
fn tags_split_on_commas_and_colons() {
let mut h = sample_heading();
h.properties
.insert(TAGS_PROPERTY.into(), "rust:perf, scaling".into());
assert_eq!(h.tags(), vec!["rust", "perf", "scaling"]);
}
#[test]
fn accessors_read_optional_properties() {
let mut h = sample_heading();
assert!(h.parent().is_none());
h.properties.insert("PARENT".into(), "sample-q3xa".into());
h.properties
.insert("DEADLINE".into(), "<2026-05-15 Fri>".into());
h.properties
.insert("SCHEDULED".into(), "<2026-04-28 Mon>".into());
assert_eq!(h.parent(), Some("sample-q3xa"));
assert_eq!(h.deadline(), Some("<2026-05-15 Fri>"));
assert_eq!(h.scheduled(), Some("<2026-04-28 Mon>"));
}
#[test]
fn state_change_prepends_and_skips_no_ops() {
let mut h = sample_heading();
h.record_state_change("STARTED");
assert_eq!(h.state, "STARTED");
assert_eq!(h.logbook[0].from_state.as_deref(), Some("TODO"));
h.record_state_change("DONE");
assert_eq!(h.logbook.len(), 2);
assert_eq!(h.logbook[0].to_state.as_deref(), Some("DONE"));
h.record_state_change("DONE");
assert_eq!(h.logbook.len(), 2, "no-op transition is not logged");
}
#[test]
fn logbook_renders_state_transitions() {
let entry = LogEntry {
timestamp: "[2026-04-26 Sun 14:22]".into(),
from_state: Some("STARTED".into()),
to_state: Some("DONE".into()),
note: None,
raw: None,
};
assert_eq!(
entry.render(),
"- State \"DONE\" from \"STARTED\" [2026-04-26 Sun 14:22]"
);
}
#[test]
fn clock_lines_survive_a_state_rewrite() {
let clock = " CLOCK: [2026-07-26 Sun 17:40]";
let closed = " CLOCK: [2026-07-26 Sun 10:00]--[2026-07-26 Sun 11:00] => 1:00";
let state = "- State \"STARTED\" from \"TODO\" [2026-07-26 Sun 17:39]";
assert_eq!(parse_log_line(clock).render(), clock);
assert_eq!(parse_log_line(closed).render(), closed);
let parsed_state = parse_log_line(state);
assert_eq!(parsed_state.to_state.as_deref(), Some("STARTED"));
assert!(parsed_state.raw.is_none());
let mut h = sample_heading();
h.logbook = vec![parsed_state, parse_log_line(clock)];
h.record_state_change("DONE");
let rendered = h.render();
assert!(
rendered.contains("CLOCK: [2026-07-26 Sun 17:40]"),
"{rendered}"
);
assert!(rendered.contains("State \"DONE\""), "{rendered}");
}
#[test]
fn headline_tags_split_off_the_title() {
assert_eq!(
split_headline_tags("Document the retry policy :docs:retry:"),
(
"Document the retry policy".to_string(),
vec!["docs".to_string(), "retry".to_string()]
)
);
}
#[test]
fn a_title_is_not_mistaken_for_a_tag_run() {
for title in [
"Scope: the header block",
"Rename the key :needs-review:",
"A ratio of 3:1",
"Trailing colon:",
] {
assert_eq!(
split_headline_tags(title),
(title.to_string(), Vec::new()),
"{title:?}"
);
}
}
#[test]
fn tags_read_the_property_and_the_heading_together() {
let mut h = sample_heading();
h.properties
.insert(TAGS_PROPERTY.into(), "needs-review, perf".into());
h.org_tags = vec!["docs".into(), "perf".into()];
assert_eq!(h.tags(), vec!["needs-review", "perf", "docs"]);
}
#[test]
fn a_heading_renders_its_tags_where_org_aligns_them() {
let mut h = sample_heading();
h.state = "TODO".into();
h.priority = 'B';
h.title = "Document the retry policy".into();
h.org_tags = vec!["docs".into(), "retry".into()];
let line = h.render().lines().next().unwrap().to_string();
assert_eq!(line.chars().count(), TAG_COLUMN, "{line:?}");
assert!(line.ends_with(":docs:retry:"), "{line:?}");
}
#[test]
fn a_long_title_keeps_one_space_before_its_tags() {
let mut h = sample_heading();
h.title = "t".repeat(TAG_COLUMN);
h.org_tags = vec!["docs".into()];
let line = h.render().lines().next().unwrap().to_string();
assert!(line.ends_with(" :docs:"), "{line:?}");
}
#[test]
fn note_lines_round_trip() {
let parsed = parse_log_line("- Note: \"picked up after review\" [2026-04-26 Sun 09:15]");
assert_eq!(parsed.note.as_deref(), Some("picked up after review"));
assert_eq!(
parsed.render(),
"- Note: \"picked up after review\" [2026-04-26 Sun 09:15]"
);
}
}