Skip to main content

ito_domain/tasks/
update.rs

1//! Helpers for updating task status in `tasks.md`.
2
3use chrono::{DateTime, Local};
4use regex::Regex;
5
6use super::TaskStatus;
7
8use super::checkbox::split_checkbox_task_label;
9fn split_checkbox_line(t: &str) -> Option<(char, &str)> {
10    let bytes = t.as_bytes();
11    if bytes.len() < 5 {
12        return None;
13    }
14    let bullet = bytes[0] as char;
15    if bullet != '-' && bullet != '*' {
16        return None;
17    }
18    if bytes[1] != b' ' || bytes[2] != b'[' || bytes[4] != b']' {
19        return None;
20    }
21    Some((bullet, &t[5..]))
22}
23
24/// Update the status marker of a checkbox-formatted task in the given file contents.
25///
26/// This prefers an explicit numeric task label at the start of a checkbox item's text (e.g. `1.1 First`)
27/// and falls back to interpreting `task_id` as a 1-based index of checkbox items when no explicit label matches.
28/// Maps `TaskStatus` to checkbox markers: `Pending` -> `[ ]`, `InProgress` -> `[~]`, `Complete` -> `[x]`.
29///
30/// Returns `Ok(String)` with the full updated file content (always ending with a trailing newline),
31/// or `Err(String)` if the requested task cannot be found or if `Shelved` is requested (not supported for checkbox-only tasks).
32///
33/// # Examples
34///
35/// ```
36/// use ito_domain::tasks::{TaskStatus, update_checkbox_task_status};
37/// let contents = "- [ ] 1.1 First task\n- [ ] Second task\n";
38/// let updated = update_checkbox_task_status(contents, "1.1", TaskStatus::Complete).unwrap();
39/// assert!(updated.contains("- [x] 1.1 First task"));
40/// ```
41pub fn update_checkbox_task_status(
42    contents: &str,
43    task_id: &str,
44    new_status: TaskStatus,
45) -> Result<String, String> {
46    let new_marker = match new_status {
47        TaskStatus::Pending => ' ',
48        TaskStatus::InProgress => '~',
49        TaskStatus::Complete => 'x',
50        TaskStatus::Shelved => {
51            return Err("Checkbox-only tasks.md does not support shelving".into());
52        }
53    };
54
55    let mut lines: Vec<String> = Vec::new();
56    for line in contents.lines() {
57        lines.push(line.to_string());
58    }
59
60    // Prefer explicit ids when the task text starts with a numeric token (e.g. `1.1 First`).
61    for line in &mut lines {
62        let indent_len = line.len().saturating_sub(line.trim_start().len());
63        let indent = &line[..indent_len];
64        let t = &line[indent_len..];
65        let Some((bullet, after)) = split_checkbox_line(t) else {
66            continue;
67        };
68
69        let rest = after.trim_start();
70        let Some((id, _name)) = split_checkbox_task_label(rest) else {
71            continue;
72        };
73        if id != task_id {
74            continue;
75        }
76
77        *line = format!("{indent}{bullet} [{new_marker}]{after}");
78
79        let mut out = lines.join("\n");
80        out.push('\n');
81        return Ok(out);
82    }
83
84    let Ok(idx) = task_id.parse::<usize>() else {
85        return Err(format!("Task \"{task_id}\" not found"));
86    };
87    if idx == 0 {
88        return Err(format!("Task \"{task_id}\" not found"));
89    }
90
91    let mut count = 0usize;
92
93    for line in &mut lines {
94        let indent_len = line.len().saturating_sub(line.trim_start().len());
95        let indent = &line[..indent_len];
96        let t = &line[indent_len..];
97        let Some((bullet, after)) = split_checkbox_line(t) else {
98            continue;
99        };
100
101        count += 1;
102        if count != idx {
103            continue;
104        }
105
106        *line = format!("{indent}{bullet} [{new_marker}]{after}");
107        break;
108    }
109
110    if count < idx {
111        return Err(format!("Task \"{task_id}\" not found"));
112    }
113
114    let mut out = lines.join("\n");
115    out.push('\n');
116    Ok(out)
117}
118
119/// Update the status and "Updated At" metadata of an enhanced-format task block.
120///
121/// Locates the task block whose heading starts with `###` and contains the given `task_id`
122/// (e.g., `### Task 123:` or `### 123:`), replaces or inserts the `- **Status**: ...` and
123/// `- **Updated At**: YYYY-MM-DD` lines as needed, and returns the modified file contents
124/// (ensuring a trailing newline).
125///
126/// # Examples
127///
128/// ```
129/// use chrono::{Local, TimeZone};
130/// use ito_domain::tasks::{TaskStatus, update_enhanced_task_status};
131/// let contents = "## Project\n\n### Task 42: Example task\n- **Status**: [ ] pending\n";
132/// let now = Local.with_ymd_and_hms(2025, 2, 1, 0, 0, 0).unwrap();
133/// let out = update_enhanced_task_status(contents, "42", TaskStatus::Complete, now);
134/// assert!(out.contains("- **Status**: [x] complete"));
135/// assert!(out.contains("- **Updated At**: 2025-02-01"));
136/// ```
137pub fn update_enhanced_task_status(
138    contents: &str,
139    task_id: &str,
140    new_status: TaskStatus,
141    now: DateTime<Local>,
142) -> String {
143    // Match TS: `^###\s+(?:Task\s+)?${taskId}\s*:`
144    let heading = Regex::new(&format!(
145        r"(?m)^###\s+(?:Task\s+)?{}\s*:\s*.+$",
146        regex::escape(task_id)
147    ))
148    .unwrap();
149
150    let status_line = match new_status {
151        TaskStatus::Complete => "- **Status**: [x] complete".to_string(),
152        TaskStatus::InProgress => "- **Status**: [>] in-progress".to_string(),
153        TaskStatus::Pending => "- **Status**: [ ] pending".to_string(),
154        TaskStatus::Shelved => "- **Status**: [-] shelved".to_string(),
155    };
156
157    let date = now.format("%Y-%m-%d").to_string();
158    let updated_at_line = format!("- **Updated At**: {date}");
159
160    let mut lines: Vec<String> = Vec::new();
161    for line in contents.lines() {
162        lines.push(line.to_string());
163    }
164    let mut start_idx: Option<usize> = None;
165    for (i, line) in lines.iter().enumerate() {
166        if heading.is_match(line) {
167            start_idx = Some(i);
168            break;
169        }
170    }
171
172    if let Some(start) = start_idx {
173        let mut end = lines.len();
174        for (i, line) in lines.iter().enumerate().skip(start + 1) {
175            if line.starts_with("### ") || line.starts_with("## ") {
176                end = i;
177                break;
178            }
179        }
180
181        let mut status_idx: Option<usize> = None;
182        let mut updated_idx: Option<usize> = None;
183        for (i, line) in lines.iter().enumerate().take(end).skip(start + 1) {
184            let l = line.trim_start();
185            if status_idx.is_none() && l.starts_with("- **Status**:") {
186                status_idx = Some(i);
187            }
188            if updated_idx.is_none() && l.starts_with("- **Updated At**:") {
189                updated_idx = Some(i);
190            }
191        }
192
193        if let Some(i) = status_idx {
194            lines[i] = status_line.clone();
195        }
196        if let Some(i) = updated_idx {
197            lines[i] = updated_at_line.clone();
198        }
199
200        match (status_idx, updated_idx) {
201            (Some(s), None) => {
202                // Insert Updated At immediately before Status.
203                lines.insert(s, updated_at_line);
204            }
205            (None, Some(u)) => {
206                // Insert Status immediately after Updated At.
207                lines.insert(u + 1, status_line);
208            }
209            (None, None) => {
210                // Insert both at the end of the block.
211                lines.insert(end, updated_at_line);
212                lines.insert(end + 1, status_line);
213            }
214            (Some(_status_idx), Some(_updated_idx)) => {}
215        }
216    }
217
218    // Preserve trailing newline behavior similar to TS templates.
219    let mut out = lines.join("\n");
220    out.push('\n');
221    out
222}