use chrono::{DateTime, Utc};
const ACTIVITY_LOG_TIME_FORMAT: &str = "%Y-%m-%dT%H:%MZ";
pub use cliban_core::sections::find_section;
fn errf(msg: String) -> String {
format!("descmd: {msg}")
}
pub fn find_task(plan_body: &str, n: i32) -> (usize, usize, bool) {
let prefix = format!("### Task {n}:");
let mut offset = 0usize;
let mut task_body_start: Option<usize> = None;
for line in plan_body.split_inclusive('\n') {
let line_len = line.len();
let trimmed = line.trim_end_matches(['\r', '\n']);
match task_body_start {
None => {
if trimmed.starts_with(&prefix) {
task_body_start = Some(offset + line_len);
}
}
Some(start) => {
if trimmed.starts_with("### ") {
return (start, offset, true);
}
}
}
offset += line_len;
}
match task_body_start {
None => (0, 0, false),
Some(start) => (start, plan_body.len(), true),
}
}
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 offset = 0usize;
let mut count = 0i32;
for line in task_body.split_inclusive('\n') {
let line_len = line.len();
if line.starts_with("- [ ] ") || line.starts_with("- [x] ") {
count += 1;
if count == m {
return Some(Step {
index: m,
checked: line.starts_with("- [x] "),
line_start: offset,
line_end: offset + line_len,
raw: line.to_string(),
});
}
}
offset += line_len;
}
None
}
pub fn tick_step(desc: &str, task_n: i32, step_m: i32) -> Result<String, 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 Err(errf(format!(
"Step {step_m} of Task {task_n} already checked"
)));
}
let abs = plan_start + task_start + step.line_start;
let new_line = format!("- [x] {}", &step.raw["- [ ] ".len()..]);
Ok(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 fn parse_activity_log(desc: &str) -> Vec<(DateTime<Utc>, String)> {
let (start, end, ok) = find_section(desc, "Activity Log");
if !ok {
return Vec::new();
}
desc[start..end]
.lines()
.filter_map(|line| {
let rest = line.trim().strip_prefix("- ")?;
let (stamp, msg) = rest.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()))
})
.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()..]
))
}
#[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 = tick_step(d, 1, 1).unwrap();
assert!(out.contains("- [x] Step 1"));
assert!(out.contains("- [ ] Step 2"));
}
#[test]
fn tick_already_checked_errors() {
let d = "## Plan\n\n### Task 1: x\n\n- [x] Step 1\n";
assert!(tick_step(d, 1, 1).is_err());
}
#[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}"
);
}
}