use chrono::{DateTime, Utc};
const ACTIVITY_LOG_TIME_FORMAT: &str = "%Y-%m-%dT%H:%MZ";
pub use cliban_core::sections::{find_section, task_number};
fn errf(msg: String) -> String {
format!("descmd: {msg}")
}
pub fn task_headings(plan_body: &str) -> Vec<(i32, std::ops::Range<usize>)> {
cliban_core::sections::h3_headings(plan_body)
.into_iter()
.filter_map(|h| task_number(&h.text).map(|n| (n, h.range)))
.collect()
}
pub fn find_task(plan_body: &str, n: i32) -> (usize, usize, bool) {
let all_h3 = cliban_core::sections::h3_headings(plan_body);
for (i, h) in all_h3.iter().enumerate() {
if task_number(&h.text) == Some(n) {
let start = h.range.end;
let end = all_h3
.get(i + 1)
.map_or(plan_body.len(), |next| next.range.start);
return (start, end, true);
}
}
(0, 0, false)
}
pub fn count_tasks(desc: &str) -> usize {
let (start, end, ok) = find_section(desc, "Plan");
if !ok {
return 0;
}
task_headings(&desc[start..end]).len()
}
pub struct Step {
#[allow(dead_code)]
pub index: i32,
pub checked: bool,
pub line_start: usize,
#[allow(dead_code)]
pub line_end: usize,
pub raw: String,
}
pub fn find_step(task_body: &str, m: i32) -> Option<Step> {
let mut count = 0i32;
for range in cliban_core::sections::top_level_list_items(task_body) {
let item = &task_body[range.clone()];
let first_line_len = item.find('\n').map_or(item.len(), |i| i + 1);
let raw = &item[..first_line_len];
if raw.starts_with("- [ ] ") || raw.starts_with("- [x] ") {
count += 1;
if count == m {
return Some(Step {
index: m,
checked: raw.starts_with("- [x] "),
line_start: range.start,
line_end: range.start + first_line_len,
raw: raw.to_string(),
});
}
}
}
None
}
pub enum TickOutcome {
Ticked(String),
AlreadyChecked,
}
pub fn tick_step(desc: &str, task_n: i32, step_m: i32) -> Result<TickOutcome, String> {
let (plan_start, plan_end, ok) = find_section(desc, "Plan");
if !ok {
return Err(errf("no ## Plan section".to_string()));
}
let plan_body = &desc[plan_start..plan_end];
let (task_start, task_end, ok) = find_task(plan_body, task_n);
if !ok {
return Err(errf(format!("no Task {task_n} in ## Plan")));
}
let task_body = &plan_body[task_start..task_end];
let step = match find_step(task_body, step_m) {
Some(s) => s,
None => return Err(errf(format!("no Step {step_m} in Task {task_n}"))),
};
if step.checked {
return Ok(TickOutcome::AlreadyChecked);
}
let abs = plan_start + task_start + step.line_start;
let new_line = format!("- [x] {}", &step.raw["- [ ] ".len()..]);
Ok(TickOutcome::Ticked(format!(
"{}{}{}",
&desc[..abs],
new_line,
&desc[abs + step.raw.len()..]
)))
}
pub fn append_activity_log(desc: &str, msg: &str, ts: DateTime<Utc>) -> String {
let stamp = ts.format(ACTIVITY_LOG_TIME_FORMAT).to_string();
let entry = format!("- {stamp} — {msg}\n");
let (start, end, ok) = find_section(desc, "Activity Log");
if !ok {
let sep = if desc.is_empty() {
""
} else if !desc.ends_with('\n') {
"\n\n"
} else {
"\n"
};
return format!("{desc}{sep}## Activity Log\n\n{entry}");
}
let body = &desc[start..end];
let trimmed = body.trim_end_matches('\n');
let mut rebuilt = format!("{trimmed}\n{entry}");
if end < desc.len() {
rebuilt.push('\n');
}
format!("{}{}{}", &desc[..start], rebuilt, &desc[end..])
}
pub struct ActivityEntry {
pub head: String,
pub parsed: Option<(DateTime<Utc>, String)>,
}
pub fn activity_entries(desc: &str) -> Vec<ActivityEntry> {
let (start, end, ok) = find_section(desc, "Activity Log");
if !ok {
return Vec::new();
}
let body = &desc[start..end];
cliban_core::sections::top_level_list_items(body)
.into_iter()
.map(|r| {
let entry = cliban_core::sections::list_item_body(&body[r]);
let (head, rest) = match entry.split_once('\n') {
Some((h, r)) => (h.to_string(), Some(r)),
None => (entry.clone(), None),
};
let parsed = parse_entry_head(&head).map(|(ts, msg)| match rest {
Some(rest) if !rest.trim().is_empty() => {
(ts, format!("{msg}\n{}", rest.trim_end()))
}
_ => (ts, msg),
});
ActivityEntry { head, parsed }
})
.collect()
}
fn parse_entry_head(head: &str) -> Option<(DateTime<Utc>, String)> {
let (stamp, msg) = head.split_once(" — ")?;
let ts = chrono::NaiveDateTime::parse_from_str(
stamp.trim(),
ACTIVITY_LOG_TIME_FORMAT,
)
.map(|t| t.and_utc())
.or_else(|_| DateTime::parse_from_rfc3339(stamp.trim()).map(|t| t.with_timezone(&Utc)))
.ok()?;
Some((ts, msg.trim().to_string()))
}
pub fn parse_activity_log(desc: &str) -> Vec<(DateTime<Utc>, String)> {
activity_entries(desc)
.into_iter()
.filter_map(|e| e.parsed)
.collect()
}
pub fn rewrite_step_line(
desc: &str,
task_n: i32,
step_m: i32,
new_line: &str,
) -> Result<String, String> {
if !new_line.ends_with('\n') {
return Err(errf("newLine must end with newline".to_string()));
}
let (plan_start, plan_end, ok) = find_section(desc, "Plan");
if !ok {
return Err(errf("no ## Plan section".to_string()));
}
let plan_body = &desc[plan_start..plan_end];
let (task_start, task_end, ok) = find_task(plan_body, task_n);
if !ok {
return Err(errf(format!("no Task {task_n} in ## Plan")));
}
let task_body = &plan_body[task_start..task_end];
let step = match find_step(task_body, step_m) {
Some(s) => s,
None => return Err(errf(format!("no Step {step_m} in Task {task_n}"))),
};
let abs = plan_start + task_start + step.line_start;
Ok(format!(
"{}{}{}",
&desc[..abs],
new_line,
&desc[abs + step.raw.len()..]
))
}
pub fn copy_description(desc: &str) -> String {
let mut kept: Vec<(usize, &str, &str)> = Vec::new();
for anchor in ["Spec", "Plan", "Notes"] {
let (start, end, ok) = find_section(desc, anchor);
if ok {
kept.push((start, anchor, desc[start..end].trim_matches('\n')));
}
}
kept.sort_by_key(|(start, _, _)| *start);
let mut out = String::new();
for (_, anchor, body) in kept {
if !out.is_empty() {
out.push('\n');
}
out.push_str("## ");
out.push_str(anchor);
out.push('\n');
let body = if anchor == "Plan" {
reset_plan_body(body)
} else {
body.to_string()
};
if !body.trim().is_empty() {
out.push('\n');
out.push_str(body.trim_matches('\n'));
out.push('\n');
}
}
out
}
pub fn reset_plan_body(plan_body: &str) -> String {
let mut out = String::with_capacity(plan_body.len());
for line in plan_body.split_inclusive('\n') {
if line.starts_with("- [ ] ") || line.starts_with("- [x] ") {
let rest = &line["- [x] ".len()..];
out.push_str("- [ ] ");
out.push_str(&strip_promotion_suffix(rest));
} else {
out.push_str(line);
}
}
out
}
fn strip_promotion_suffix(line: &str) -> String {
let (body, newline) = match line.strip_suffix('\n') {
Some(b) => (b.strip_suffix('\r').unwrap_or(b), &line[b.len()..]),
None => (line, ""),
};
let stripped = match body.rfind(" → ") {
Some(idx) if is_issue_key_shaped(body[idx + " → ".len()..].trim()) => &body[..idx],
_ => body,
};
format!("{}{}", stripped.trim_end(), newline)
}
pub fn is_issue_key_shaped(s: &str) -> bool {
let Some(idx) = s.rfind('-') else {
return false;
};
let (project, seq) = (&s[..idx], &s[idx + 1..]);
!project.is_empty()
&& project
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
&& project.chars().all(|c| c.is_ascii_alphanumeric())
&& !seq.is_empty()
&& seq.chars().all(|c| c.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
#[test]
fn find_section_returns_content_range() {
let d = "## Spec\n\nbody\n\n## Plan\n\nplan body\n";
let (s, e, ok) = find_section(d, "Spec");
assert!(ok);
assert_eq!(&d[s..e], "\nbody\n\n");
}
#[test]
fn tick_step_checks_box() {
let d = "## Plan\n\n### Task 1: x\n\n- [ ] Step 1\n- [ ] Step 2\n";
let out = match tick_step(d, 1, 1).unwrap() {
TickOutcome::Ticked(out) => out,
TickOutcome::AlreadyChecked => panic!("unchecked step must tick"),
};
assert!(out.contains("- [x] Step 1"));
assert!(out.contains("- [ ] Step 2"));
}
#[test]
fn tick_already_checked_is_a_detectable_noop_not_an_error() {
let d = "## Plan\n\n### Task 1: x\n\n- [x] Step 1\n";
assert!(matches!(
tick_step(d, 1, 1),
Ok(TickOutcome::AlreadyChecked)
));
}
#[test]
fn tick_wrong_targets_stay_errors() {
let d = "## Plan\n\n### Task 1: x\n\n- [x] Step 1\n";
assert!(tick_step(d, 1, 2).is_err(), "no such step");
assert!(tick_step(d, 2, 1).is_err(), "no such task");
assert!(tick_step("no plan here\n", 1, 1).is_err(), "no ## Plan");
}
#[test]
fn reset_unticks_column_zero_steps_only() {
let body = "### Task 1: x\n\n- [x] **Step 1: done**\n - [x] child stays\n- [ ] **Step 2: open**\n prose\n";
let out = reset_plan_body(body);
assert!(out.contains("- [ ] **Step 1: done**"), "got {out:?}");
assert!(
out.contains(" - [x] child stays"),
"indented boxes are not steps: {out:?}"
);
assert!(out.contains("- [ ] **Step 2: open**"));
assert!(out.contains("### Task 1: x"));
}
#[test]
fn reset_strips_key_shaped_promotion_suffixes_only() {
let body = "- [x] split me → CLI-53\n- [ ] map a → b\n- [x] pipeline x → y → COOK-7\n";
let out = reset_plan_body(body);
assert_eq!(
out,
"- [ ] split me\n- [ ] map a → b\n- [ ] pipeline x → y\n"
);
}
#[test]
fn reset_handles_last_line_without_newline() {
assert_eq!(reset_plan_body("- [x] tail → ABC-12"), "- [ ] tail");
}
#[test]
fn key_shaped_suffix_rules() {
assert!(is_issue_key_shaped("CLI-53"));
assert!(is_issue_key_shaped("cook2-101"));
assert!(!is_issue_key_shaped("b"));
assert!(!is_issue_key_shaped("a-"));
assert!(!is_issue_key_shaped("-7"));
assert!(!is_issue_key_shaped("7-7"));
assert!(!is_issue_key_shaped("two words-3"));
}
#[test]
fn copy_keeps_contract_sections_in_source_order_and_resets_plan() {
let d = "## Notes\n\nlesson\n\n## Spec\n\nthe spec\n\n## Plan\n\n### Task 1: t\n\n- [x] done → CLI-9\n- [ ] open\n\n## Activity Log\n\n- 2026-01-01T00:00Z — history\n";
let out = copy_description(d);
assert_eq!(
out,
"## Notes\n\nlesson\n\n## Spec\n\nthe spec\n\n## Plan\n\n### Task 1: t\n\n- [x] done → CLI-9\n- [ ] open\n"
.replace("- [x] done → CLI-9", "- [ ] done"),
"got {out:?}"
);
assert!(!out.contains("Activity Log"));
assert!(!out.contains("history"));
}
#[test]
fn copy_drops_non_contract_sections() {
let d = "## Spec\n\ns\n\n## Decisions so far\n\n- deliberation\n\n## Plan\n\n- [ ] step\n";
let out = copy_description(d);
assert!(!out.contains("Decisions so far"));
assert!(!out.contains("deliberation"));
assert!(out.contains("## Spec\n\ns\n"));
assert!(out.contains("## Plan\n\n- [ ] step\n"));
}
#[test]
fn copy_tolerates_missing_and_empty_sections() {
assert_eq!(copy_description("free text, no sections\n"), "");
assert_eq!(copy_description(""), "");
let out = copy_description("## Spec\n\n## Plan\n\n- [ ] s\n");
assert_eq!(out, "## Spec\n\n## Plan\n\n- [ ] s\n");
assert!(!out.contains("\n\n\n"));
}
#[test]
fn an_indented_sublist_is_part_of_its_entry() {
let d = "## Activity Log\n\n- 2026-08-08T10:00Z — did a thing\n - detail one\n \
- detail two\n- 2026-08-08T11:00Z — next thing\n";
let out = parse_activity_log(d);
assert_eq!(out.len(), 2, "two entries, not four: {out:?}");
assert_eq!(
out[0].1, "did a thing\n- detail one\n- detail two",
"the sublist rides along with its entry"
);
assert_eq!(out[1].1, "next thing");
}
#[test]
fn a_wrapped_prose_line_folds_into_its_entry() {
let d = "## Activity Log\n\n- 2026-08-08T11:00Z — wrapped entry that continues\n \
onto a prose line\n";
let out = parse_activity_log(d);
assert_eq!(out.len(), 1);
assert_eq!(
out[0].1, "wrapped entry that continues\nonto a prose line",
"the continuation is not dropped"
);
}
#[test]
fn an_entry_that_is_not_log_shaped_is_still_reported_once() {
let d = "## Activity Log\n\n- yesterday: did stuff\n - with detail\n";
assert!(parse_activity_log(d).is_empty());
let entries = activity_entries(d);
assert_eq!(entries.len(), 1, "one malformed entry, not two");
assert_eq!(entries[0].head, "yesterday: did stuff");
}
#[test]
fn a_fenced_log_line_is_not_an_entry() {
let d = "## Activity Log\n\n- 2026-08-08T10:00Z — real\n\n```\n- 2026-01-01T00:00Z — fake\n```\n";
let out = parse_activity_log(d);
assert_eq!(
out.len(),
1,
"the fenced line is code, not an entry: {out:?}"
);
assert_eq!(out[0].1, "real");
}
#[test]
fn a_fenced_task_heading_does_not_end_a_task() {
let plan = "### Task 1: t\n\n- [ ] **Step 1: a**\n- [ ] **Step 2: b**\n\n```markdown\n\
### Task 2: not real\n```\n\n- [ ] **Step 3: c**\n";
let (s, e, ok) = find_task(plan, 1);
assert!(ok);
let body = &plan[s..e];
assert!(body.contains("Step 3: c"), "task not truncated: {body:?}");
assert!(find_step(body, 3).is_some(), "the third step is reachable");
assert_eq!(count_tasks(&format!("## Plan\n\n{plan}")), 1);
}
#[test]
fn ticking_a_step_that_owns_a_sublist_keeps_its_children() {
let d = "## Plan\n\n### Task 1: t\n\n- [ ] **Step 1: a**\n - note one\n - note two\n\
- [ ] **Step 2: b**\n";
let out = match tick_step(d, 1, 1).unwrap() {
TickOutcome::Ticked(out) => out,
TickOutcome::AlreadyChecked => panic!("unchecked step must tick"),
};
assert!(out.contains("- [x] **Step 1: a**"));
assert!(out.contains(" - note one\n - note two"), "got {out:?}");
assert!(out.contains("- [ ] **Step 2: b**"));
}
#[test]
fn a_fenced_checkbox_is_not_a_step() {
let task = "- [ ] **Step 1: real**\n\n```\n- [ ] fenced, not a step\n```\n\n\
- [ ] **Step 2: also real**\n";
assert!(find_step(task, 1).unwrap().raw.contains("real"));
assert!(find_step(task, 2).unwrap().raw.contains("also real"));
assert!(find_step(task, 3).is_none(), "only two steps exist");
}
#[test]
fn append_activity_log_minute_precision() {
let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
let out = append_activity_log("## Spec\n\nbody\n", "hello", ts);
assert!(
out.contains("## Activity Log\n\n- 2026-06-19T14:46Z — hello\n"),
"got {out}"
);
}
}