Skip to main content

vtcode_core/tools/handlers/
task_tracking.rs

1use std::str::FromStr;
2
3use anyhow::{Result, bail};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
7pub struct TaskStepMetadata {
8    #[serde(default, skip_serializing_if = "Vec::is_empty")]
9    pub files: Vec<String>,
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub outcome: Option<String>,
12    #[serde(default, skip_serializing_if = "Vec::is_empty")]
13    pub verify: Vec<String>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(untagged)]
18pub enum TaskItemInput {
19    Text(String),
20    Structured(TaskItemInputObject),
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
24pub struct TaskItemInputObject {
25    pub description: String,
26    #[serde(default)]
27    pub status: Option<String>,
28    #[serde(default, deserialize_with = "deserialize_optional_string_list")]
29    pub files: Option<Vec<String>>,
30    #[serde(default)]
31    pub outcome: Option<String>,
32    #[serde(default, deserialize_with = "deserialize_optional_string_list")]
33    pub verify: Option<Vec<String>>,
34}
35
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum TaskTrackingStatus {
39    Pending,
40    InProgress,
41    Completed,
42    Blocked,
43}
44
45impl std::fmt::Display for TaskTrackingStatus {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "{}", self.as_str())
48    }
49}
50
51impl FromStr for TaskTrackingStatus {
52    type Err = anyhow::Error;
53
54    fn from_str(value: &str) -> Result<Self> {
55        match value {
56            "pending" => Ok(Self::Pending),
57            "in_progress" => Ok(Self::InProgress),
58            "completed" => Ok(Self::Completed),
59            "blocked" => Ok(Self::Blocked),
60            other => bail!(
61                "Invalid status '{}'. Use: pending, in_progress, completed, blocked",
62                other
63            ),
64        }
65    }
66}
67
68impl TaskTrackingStatus {
69    pub fn as_str(&self) -> &'static str {
70        match self {
71            Self::Pending => "pending",
72            Self::InProgress => "in_progress",
73            Self::Completed => "completed",
74            Self::Blocked => "blocked",
75        }
76    }
77
78    pub fn flat_checkbox(&self) -> &'static str {
79        match self {
80            Self::Pending => "[ ]",
81            Self::InProgress => "[/]",
82            Self::Completed => "[x]",
83            Self::Blocked => "[!]",
84        }
85    }
86
87    pub fn plan_checkbox(&self) -> &'static str {
88        match self {
89            Self::Pending => "[ ]",
90            Self::InProgress => "[~]",
91            Self::Completed => "[x]",
92            Self::Blocked => "[!]",
93        }
94    }
95
96    pub fn view_symbol(&self) -> &'static str {
97        match self {
98            Self::Pending => "•",
99            Self::InProgress => ">",
100            Self::Completed => "✔",
101            Self::Blocked => "!",
102        }
103    }
104}
105
106pub fn parse_marked_status_prefix(value: &str) -> Option<(TaskTrackingStatus, String)> {
107    let trimmed = value.trim_start();
108    let mapping = [
109        ("[x] ", TaskTrackingStatus::Completed),
110        ("[X] ", TaskTrackingStatus::Completed),
111        ("[~] ", TaskTrackingStatus::InProgress),
112        ("[/] ", TaskTrackingStatus::InProgress),
113        ("[!] ", TaskTrackingStatus::Blocked),
114        ("[ ] ", TaskTrackingStatus::Pending),
115    ];
116    for (prefix, status) in mapping {
117        if let Some(rest) = trimmed.strip_prefix(prefix) {
118            return Some((status, rest.to_string()));
119        }
120    }
121    None
122}
123
124pub fn parse_status_prefix(value: &str) -> (TaskTrackingStatus, String) {
125    parse_marked_status_prefix(value)
126        .unwrap_or((TaskTrackingStatus::Pending, value.trim_start().to_string()))
127}
128
129pub fn append_notes(existing: Option<String>, append: Option<&str>) -> Option<String> {
130    match (existing, append) {
131        (None, None) => None,
132        (Some(text), None) => {
133            if text.trim().is_empty() {
134                None
135            } else {
136                Some(text)
137            }
138        }
139        (None, Some(extra)) => {
140            let trimmed = extra.trim();
141            if trimmed.is_empty() {
142                None
143            } else {
144                Some(trimmed.to_string())
145            }
146        }
147        (Some(text), Some(extra)) => {
148            let left = text.trim();
149            let right = extra.trim();
150            if left.is_empty() && right.is_empty() {
151                None
152            } else if left.is_empty() {
153                Some(right.to_string())
154            } else if right.is_empty() {
155                Some(left.to_string())
156            } else {
157                Some(format!("{left}\n{right}"))
158            }
159        }
160    }
161}
162
163pub fn append_notes_section(markdown: &mut String, notes: Option<&str>) {
164    if let Some(text) = notes {
165        let trimmed = text.trim();
166        if !trimmed.is_empty() {
167            markdown.push_str("\n## Notes\n\n");
168            markdown.push_str(trimmed);
169            markdown.push('\n');
170        }
171    }
172}
173
174pub fn is_bulk_sync_update<T>(
175    items: Option<&[T]>,
176    index: Option<usize>,
177    index_path: Option<&str>,
178    status: Option<&str>,
179) -> bool {
180    items.is_some() && ((index.is_none() && index_path.is_none()) || status.is_none())
181}
182
183pub fn deserialize_optional_string_list<'de, D>(
184    deserializer: D,
185) -> std::result::Result<Option<Vec<String>>, D::Error>
186where
187    D: serde::Deserializer<'de>,
188{
189    #[derive(Deserialize)]
190    #[serde(untagged)]
191    enum OneOrMany {
192        One(String),
193        Many(Vec<String>),
194    }
195
196    let parsed = Option::<OneOrMany>::deserialize(deserializer)?;
197    Ok(parsed.map(|value| match value {
198        OneOrMany::One(item) => vec![item],
199        OneOrMany::Many(items) => items,
200    }))
201}
202
203pub fn normalize_string_items(items: Option<&[String]>) -> Vec<String> {
204    items
205        .unwrap_or(&[])
206        .iter()
207        .map(|item| item.trim())
208        .filter(|item| !item.is_empty())
209        .map(ToOwned::to_owned)
210        .collect()
211}
212
213pub fn normalize_optional_text(value: Option<&str>) -> Option<String> {
214    value
215        .map(str::trim)
216        .filter(|value| !value.is_empty())
217        .map(ToOwned::to_owned)
218}
219
220pub fn append_task_step_metadata(markdown: &mut String, indent: &str, metadata: &TaskStepMetadata) {
221    if !metadata.files.is_empty() {
222        markdown.push_str(indent);
223        markdown.push_str("  files: ");
224        markdown.push_str(&metadata.files.join(", "));
225        markdown.push('\n');
226    }
227
228    if let Some(outcome) = metadata.outcome.as_deref() {
229        markdown.push_str(indent);
230        markdown.push_str("  outcome: ");
231        markdown.push_str(outcome);
232        markdown.push('\n');
233    }
234
235    if metadata.verify.len() == 1 {
236        markdown.push_str(indent);
237        markdown.push_str("  verify: ");
238        markdown.push_str(&metadata.verify[0]);
239        markdown.push('\n');
240    } else if !metadata.verify.is_empty() {
241        markdown.push_str(indent);
242        markdown.push_str("  verify:\n");
243        for command in &metadata.verify {
244            markdown.push_str(indent);
245            markdown.push_str("    - ");
246            markdown.push_str(command);
247            markdown.push('\n');
248        }
249    }
250}
251
252pub fn metadata_from_input(
253    files: Option<&[String]>,
254    outcome: Option<&str>,
255    verify: Option<&[String]>,
256) -> TaskStepMetadata {
257    TaskStepMetadata {
258        files: normalize_string_items(files),
259        outcome: normalize_optional_text(outcome),
260        verify: normalize_string_items(verify),
261    }
262}
263
264#[derive(Default)]
265pub struct TaskCounts {
266    pub total: usize,
267    pub completed: usize,
268    pub in_progress: usize,
269    pub pending: usize,
270    pub blocked: usize,
271}
272
273impl TaskCounts {
274    pub fn add(&mut self, status: &TaskTrackingStatus) {
275        self.total += 1;
276        match status {
277            TaskTrackingStatus::Pending => self.pending += 1,
278            TaskTrackingStatus::InProgress => self.in_progress += 1,
279            TaskTrackingStatus::Completed => self.completed += 1,
280            TaskTrackingStatus::Blocked => self.blocked += 1,
281        }
282    }
283
284    pub fn progress_percent(&self) -> usize {
285        if self.total > 0 {
286            (self.completed as f64 / self.total as f64 * 100.0).round() as usize
287        } else {
288            0
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn parse_marked_status_prefix_rejects_unmarked_text() {
299        let parsed = parse_marked_status_prefix("plain text without marker");
300        assert!(parsed.is_none());
301    }
302
303    #[test]
304    fn parse_status_prefix_defaults_to_pending_for_unmarked_text() {
305        let (status, description) = parse_status_prefix("plain text without marker");
306        assert_eq!(status, TaskTrackingStatus::Pending);
307        assert_eq!(description, "plain text without marker");
308    }
309
310    #[test]
311    fn parse_status_prefix_supports_both_in_progress_markers() {
312        let (status_tilde, text_tilde) = parse_status_prefix("[~] do thing");
313        let (status_slash, text_slash) = parse_status_prefix("[/] do thing");
314        assert_eq!(status_tilde, TaskTrackingStatus::InProgress);
315        assert_eq!(status_slash, TaskTrackingStatus::InProgress);
316        assert_eq!(text_tilde, "do thing");
317        assert_eq!(text_slash, "do thing");
318    }
319
320    #[test]
321    fn append_notes_joins_with_single_newline() {
322        let merged = append_notes(Some("left".to_string()), Some("right"));
323        assert_eq!(merged, Some("left\nright".to_string()));
324    }
325
326    #[test]
327    fn append_notes_section_ignores_blank_notes() {
328        let mut markdown = "# Title\n".to_string();
329        append_notes_section(&mut markdown, Some("   "));
330        assert_eq!(markdown, "# Title\n");
331    }
332
333    #[test]
334    fn is_bulk_sync_update_requires_items_and_missing_single_item_fields() {
335        let items = vec!["Step".to_string()];
336        assert!(is_bulk_sync_update(Some(&items), None, None, None));
337        assert!(!is_bulk_sync_update(
338            Some(&items),
339            Some(1),
340            None,
341            Some("completed")
342        ));
343    }
344
345    #[test]
346    fn task_counts_tracks_progress() {
347        let mut counts = TaskCounts::default();
348        counts.add(&TaskTrackingStatus::Completed);
349        counts.add(&TaskTrackingStatus::Pending);
350        counts.add(&TaskTrackingStatus::Blocked);
351        counts.add(&TaskTrackingStatus::InProgress);
352        assert_eq!(counts.total, 4);
353        assert_eq!(counts.completed, 1);
354        assert_eq!(counts.pending, 1);
355        assert_eq!(counts.blocked, 1);
356        assert_eq!(counts.in_progress, 1);
357        assert_eq!(counts.progress_percent(), 25);
358    }
359}