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 check_section_structure(
before: &str,
after: &str,
writing: &str,
may_create: bool,
) -> Result<(), String> {
let mut expected = cliban_core::sections::h2_anchors(before);
if may_create && !expected.iter().any(|a| a == writing) {
expected.push(writing.to_string());
}
let got = cliban_core::sections::h2_anchors(after);
let count = |v: &[String], s: &str| v.iter().filter(|x| x.as_str() == s).count();
if let Some(new_anchor) = got.iter().find(|a| count(&got, a) > count(&expected, a)) {
return Err(format!(
"the write would create a top-level \"## {new_anchor}\" section boundary, \
splitting ## {writing}; demote the heading to ### or deeper, or quote \
it in a fenced code block"
));
}
if let Some(lost) = expected.iter().find(|e| count(&expected, e) > count(&got, e)) {
return Err(format!(
"the write would leave the \"## {lost}\" section unreachable behind an \
unclosed code fence; close the fence — if the description already \
carries the open fence, repair it first with a full-description \
edit (--description-file)"
));
}
for anchor in expected.iter().filter(|a| a.as_str() != writing) {
let (s1, e1, ok1) = find_section(before, anchor);
let (s2, e2, ok2) = find_section(after, anchor);
if ok1
&& (!ok2
|| before[s1..e1].trim_end_matches('\n') != after[s2..e2].trim_end_matches('\n'))
{
return Err(format!(
"the write would displace the \"## {anchor}\" section — afterwards it \
would no longer resolve to its current content; demote any heading \
in the payload and close any code fence"
));
}
}
Ok(())
}
pub fn append_activity_log(desc: &str, msg: &str, ts: DateTime<Utc>) -> Result<String, String> {
let stamp = ts.format(ACTIVITY_LOG_TIME_FORMAT).to_string();
let msg = msg.trim_end_matches('\n');
let entry = format!("- {stamp} — {}\n", msg.replace('\n', "\n "));
let (start, end, ok) = find_section(desc, "Activity Log");
let new_desc = if !ok {
let sep = if desc.is_empty() {
""
} else if !desc.ends_with('\n') {
"\n\n"
} else {
"\n"
};
format!("{desc}{sep}## Activity Log\n\n{entry}")
} else {
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..])
};
check_section_structure(desc, &new_desc, "Activity Log", true)?;
let first_line = msg.lines().next().unwrap_or("");
let head = format!("{stamp} — {first_line}");
let head = head.trim_end();
let visible = |d: &str| {
activity_entries(d)
.iter()
.filter(|e| e.head.trim_end() == head)
.count()
};
if visible(&new_desc) <= visible(desc) {
return Err(
"log entry would never render — an unclosed code fence or unterminated \
block already inside ## Activity Log would absorb it; repair the \
description first with issue edit --description-file"
.to_string(),
);
}
Ok(new_desc)
}
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()
}
pub(crate) 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).unwrap();
assert!(
out.contains("## Activity Log\n\n- 2026-06-19T14:46Z — hello\n"),
"got {out}"
);
}
#[test]
fn append_activity_log_sanitizes_a_top_level_h2_into_the_entry() {
let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
let msg = "intro\n\n## A heading\n\nbody";
let out = append_activity_log("## Spec\n\nbody\n", msg, ts).unwrap();
assert_eq!(
cliban_core::sections::h2_anchors(&out),
vec!["Spec".to_string(), "Activity Log".to_string()],
"the inner heading must not become a section: {out}"
);
let entries = activity_entries(&out);
assert_eq!(entries.len(), 1, "one entry: {out}");
let (_, body) = entries[0].parsed.as_ref().unwrap();
assert!(body.contains("## A heading"), "content preserved: {body}");
}
#[test]
fn append_activity_log_allows_fenced_and_deeper_headings() {
let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
let fenced = "quoting the grammar:\n\n```\n## Spec\n```";
assert!(append_activity_log("", fenced, ts).is_ok());
let h3 = "handoff\n\n### converted call sites\n\n- a\n- b";
assert!(append_activity_log("", h3, ts).is_ok());
}
#[test]
fn append_activity_log_contains_fences_inside_the_entry() {
let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
let d = append_activity_log("## Spec\n\ns\n", "opening a fence\n\n```\nquoted", ts)
.unwrap();
let msg = "more quote\n```\n\n## Phantom\n\nsplit content";
let out = append_activity_log(&d, msg, ts).unwrap();
assert_eq!(
cliban_core::sections::h2_anchors(&out),
vec!["Spec".to_string(), "Activity Log".to_string()],
"{out}"
);
assert_eq!(activity_entries(&out).len(), 2, "both entries render: {out}");
}
#[test]
fn append_activity_log_keeps_a_same_name_heading_inside_the_entry() {
let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
let d = "## Activity Log\n\n- 2026-01-01T00:00Z — x\n\n## Notes\n\nreal notes\n";
let out = append_activity_log(d, "x\n\n## Notes\n\n```\nunclosed", ts).unwrap();
let (s, e, ok) = find_section(&out, "Notes");
assert!(ok);
assert_eq!(out[s..e].trim(), "real notes", "{out}");
}
#[test]
fn append_activity_log_refuses_to_write_an_invisible_entry() {
let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
let d = "## Activity Log\n\n- 2026-01-01T00:00Z — x\n\n```\nstill open\n";
let err = append_activity_log(d, "hello", ts).unwrap_err();
assert!(err.contains("never render"), "got {err}");
assert!(err.contains("--description-file"), "repair guidance: {err}");
}
#[test]
fn append_activity_log_accepts_a_trailing_whitespace_message() {
let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
assert!(append_activity_log("", "oops trailing space ", ts).is_ok());
}
#[test]
fn append_activity_log_fence_cannot_swallow_a_later_section() {
let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
let d = "## Activity Log\n\n- 2026-01-01T00:00Z — x\n\n## Notes\n\nn\n";
let out = append_activity_log(d, "opens\n\n```\nunclosed", ts).unwrap();
let (s, e, ok) = find_section(&out, "Notes");
assert!(ok, "{out}");
assert_eq!(out[s..e].trim(), "n", "{out}");
}
}