vtcode_core/tools/handlers/
task_tracking.rs1use 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}