Skip to main content

vtcode_core/tools/handlers/
task_tracking.rs

1use std::str::FromStr;
2
3use anyhow::{Result, bail};
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
8pub struct TaskStepMetadata {
9    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10    pub files: Vec<String>,
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub outcome: Option<String>,
13    #[serde(default, skip_serializing_if = "Vec::is_empty")]
14    pub verify: Vec<String>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(untagged)]
19pub enum TaskItemInput {
20    Text(String),
21    Structured(TaskItemInputObject),
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
25pub struct TaskItemInputObject {
26    pub description: String,
27    #[serde(default)]
28    pub status: Option<String>,
29    #[serde(default, deserialize_with = "deserialize_optional_string_list")]
30    pub files: Option<Vec<String>>,
31    #[serde(default)]
32    pub outcome: Option<String>,
33    #[serde(default, deserialize_with = "deserialize_optional_string_list")]
34    pub verify: Option<Vec<String>>,
35}
36
37#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub enum TaskTrackingStatus {
40    Pending,
41    InProgress,
42    Completed,
43    Blocked,
44}
45
46impl std::fmt::Display for TaskTrackingStatus {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(f, "{}", self.as_str())
49    }
50}
51
52impl FromStr for TaskTrackingStatus {
53    type Err = anyhow::Error;
54
55    fn from_str(value: &str) -> Result<Self> {
56        match value {
57            "pending" => Ok(Self::Pending),
58            "in_progress" => Ok(Self::InProgress),
59            "completed" => Ok(Self::Completed),
60            "blocked" => Ok(Self::Blocked),
61            other => {
62                bail!("Invalid status '{other}'. Use: pending, in_progress, completed, blocked")
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    pub fn compact_view_symbol(&self) -> &'static str {
106        match self {
107            Self::Pending => "□",
108            Self::InProgress => "[-]",
109            Self::Completed => "[x]",
110            Self::Blocked => "[!]",
111        }
112    }
113}
114
115pub fn parse_marked_status_prefix(value: &str) -> Option<(TaskTrackingStatus, String)> {
116    let trimmed = value.trim_start();
117    let mapping = [
118        ("[x] ", TaskTrackingStatus::Completed),
119        ("[X] ", TaskTrackingStatus::Completed),
120        ("[~] ", TaskTrackingStatus::InProgress),
121        ("[/] ", TaskTrackingStatus::InProgress),
122        ("[!] ", TaskTrackingStatus::Blocked),
123        ("[ ] ", TaskTrackingStatus::Pending),
124    ];
125    for (prefix, status) in mapping {
126        if let Some(rest) = trimmed.strip_prefix(prefix) {
127            return Some((status, rest.to_string()));
128        }
129    }
130    None
131}
132
133pub fn parse_status_prefix(value: &str) -> (TaskTrackingStatus, String) {
134    parse_marked_status_prefix(value).unwrap_or((TaskTrackingStatus::Pending, value.trim_start().to_string()))
135}
136
137pub fn append_notes(existing: Option<String>, append: Option<&str>) -> Option<String> {
138    match (existing, append) {
139        (None, None) => None,
140        (Some(text), None) => {
141            if text.trim().is_empty() {
142                None
143            } else {
144                Some(text)
145            }
146        }
147        (None, Some(extra)) => {
148            let trimmed = extra.trim();
149            if trimmed.is_empty() {
150                None
151            } else {
152                Some(trimmed.to_string())
153            }
154        }
155        (Some(text), Some(extra)) => {
156            let left = text.trim();
157            let right = extra.trim();
158            if left.is_empty() && right.is_empty() {
159                None
160            } else if left.is_empty() {
161                Some(right.to_string())
162            } else if right.is_empty() {
163                Some(left.to_string())
164            } else {
165                Some(format!("{left}\n{right}"))
166            }
167        }
168    }
169}
170
171pub fn append_notes_section(markdown: &mut String, notes: Option<&str>) {
172    if let Some(text) = notes {
173        let trimmed = text.trim();
174        if !trimmed.is_empty() {
175            markdown.push_str("\n## Notes\n\n");
176            markdown.push_str(trimmed);
177            markdown.push('\n');
178        }
179    }
180}
181
182pub(crate) fn validate_update_shape<T>(
183    items: Option<&[T]>,
184    index: Option<usize>,
185    index_path: Option<&str>,
186    status: Option<&str>,
187) -> Result<()> {
188    if index.is_some() && index_path.is_some() {
189        bail!("task-tracker updates cannot combine 'index' and 'index_path'");
190    }
191
192    if items.is_some() && (index.is_some() || index_path.is_some() || status.is_some()) {
193        bail!("bulk task-tracker updates cannot combine 'items' with 'index', 'index_path', or 'status'");
194    }
195
196    Ok(())
197}
198
199pub(crate) fn validate_action_index_fields(action: &str, index: Option<usize>, index_path: Option<&str>) -> Result<()> {
200    if action != "update" && (index.is_some() || index_path.is_some()) {
201        bail!(
202            "task-tracker action '{action}' cannot use 'index' or 'index_path'; indices are only valid with action='update'"
203        );
204    }
205
206    Ok(())
207}
208
209pub(crate) const TASK_ITEMS_DESCRIPTION: &str = "Full checklist replacement for create or bulk update, not indexed updates. Send plain descriptions with [x]/[~]/[!]/[ ] status prefixes or actual objects with description and status; never JSON-encoded strings.";
210
211/// Shared status values for every task-tracker schema (standard + planning,
212/// top-level and nested item objects). Single source so a future status
213/// addition cannot drift between the planning on/off variants.
214pub(crate) const TASK_STATUS_VALUES: [&str; 4] = ["pending", "in_progress", "completed", "blocked"];
215
216/// Base `status` type schema without a description (callers add their own).
217pub(crate) fn task_status_schema() -> Value {
218    json!({
219        "type": "string",
220        "enum": TASK_STATUS_VALUES
221    })
222}
223
224/// Base `files` type schema without a description.
225pub(crate) fn task_files_schema() -> Value {
226    json!({
227        "type": "array",
228        "items": { "type": "string" }
229    })
230}
231
232/// Base `verify` type schema without a description (string or string list).
233pub(crate) fn task_verify_schema() -> Value {
234    json!({
235        "anyOf": [
236            { "type": "string" },
237            {
238                "type": "array",
239                "items": { "type": "string" }
240            }
241        ]
242    })
243}
244
245fn with_description(mut schema: Value, description: &str) -> Value {
246    if let Value::Object(map) = &mut schema {
247        map.insert("description".to_string(), Value::String(description.to_string()));
248    }
249    schema
250}
251
252/// Shared nested item-object schema (description + optional status/files/
253/// outcome/verify). Used inside the `items` array of both tracker schemas.
254pub(crate) fn task_item_object_schema() -> Value {
255    json!({
256        "type": "object",
257        "properties": {
258            "description": { "type": "string" },
259            "status": task_status_schema(),
260            "files": task_files_schema(),
261            "outcome": { "type": "string" },
262            "verify": task_verify_schema()
263        },
264        "required": ["description"]
265    })
266}
267
268/// Shared `items` array schema with a caller-supplied description.
269pub(crate) fn task_items_array_schema(items_description: &str) -> Value {
270    with_description(
271        json!({
272            "type": "array",
273            "items": {
274                "anyOf": [
275                    { "type": "string" },
276                    task_item_object_schema()
277                ]
278            }
279        }),
280        items_description,
281    )
282}
283
284/// Top-level `status` property with its own description.
285pub(crate) fn task_status_property_schema(description: &str) -> Value {
286    with_description(task_status_schema(), description)
287}
288
289/// Top-level `files` property with its own description.
290pub(crate) fn task_files_property_schema(description: &str) -> Value {
291    with_description(task_files_schema(), description)
292}
293
294/// Top-level `verify` property with its own description.
295pub(crate) fn task_verify_property_schema(description: &str) -> Value {
296    with_description(task_verify_schema(), description)
297}
298
299/// Checklist replacement must not interpret encoded update commands as descriptions.
300pub(crate) fn validate_task_item_inputs(items: &[TaskItemInput]) -> Result<()> {
301    for item in items {
302        let TaskItemInput::Text(text) = item else {
303            continue;
304        };
305        let (_, description) = parse_status_prefix(text.trim());
306        if let Ok(Value::Object(object)) = serde_json::from_str::<Value>(&description)
307            && ["index", "index_path", "description", "status"]
308                .iter()
309                .any(|key| object.contains_key(*key))
310        {
311            bail!(
312                "'items' replaces the complete checklist; JSON-encoded task updates are not task descriptions. \
313                 Update an existing step directly: {{\"action\":\"update\",\"index_path\":\"1\",\"status\":\"completed\"}}. \
314                 For full synchronization, send descriptions with statuses, for example \
315                 {{\"action\":\"update\",\"items\":[{{\"description\":\"Original step\",\"status\":\"completed\"}}]}}. \
316                 The checklist was not changed. Retry once with the intended shape."
317            );
318        }
319    }
320    Ok(())
321}
322
323pub fn is_bulk_sync_update<T>(
324    items: Option<&[T]>,
325    index: Option<usize>,
326    index_path: Option<&str>,
327    status: Option<&str>,
328) -> bool {
329    items.is_some() && index.is_none() && index_path.is_none() && status.is_none()
330}
331
332pub fn deserialize_optional_string_list<'de, D>(deserializer: D) -> std::result::Result<Option<Vec<String>>, D::Error>
333where
334    D: serde::Deserializer<'de>,
335{
336    #[derive(Deserialize)]
337    #[serde(untagged)]
338    enum OneOrMany {
339        One(String),
340        Many(Vec<String>),
341    }
342
343    let parsed = Option::<OneOrMany>::deserialize(deserializer)?;
344    Ok(parsed.map(|value| match value {
345        OneOrMany::One(item) => vec![item],
346        OneOrMany::Many(items) => items,
347    }))
348}
349
350pub fn normalize_string_items(items: Option<&[String]>) -> Vec<String> {
351    items
352        .unwrap_or(&[])
353        .iter()
354        .map(|item| item.trim())
355        .filter(|item| !item.is_empty())
356        .map(ToOwned::to_owned)
357        .collect()
358}
359
360pub fn normalize_optional_text(value: Option<&str>) -> Option<String> {
361    value.map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned)
362}
363
364pub fn append_task_step_metadata(markdown: &mut String, indent: &str, metadata: &TaskStepMetadata) {
365    if !metadata.files.is_empty() {
366        markdown.push_str(indent);
367        markdown.push_str("  files: ");
368        markdown.push_str(&metadata.files.join(", "));
369        markdown.push('\n');
370    }
371
372    if let Some(outcome) = metadata.outcome.as_deref() {
373        markdown.push_str(indent);
374        markdown.push_str("  outcome: ");
375        markdown.push_str(outcome);
376        markdown.push('\n');
377    }
378
379    if metadata.verify.len() == 1 {
380        markdown.push_str(indent);
381        markdown.push_str("  verify: ");
382        markdown.push_str(&metadata.verify[0]);
383        markdown.push('\n');
384    } else if !metadata.verify.is_empty() {
385        markdown.push_str(indent);
386        markdown.push_str("  verify:\n");
387        for command in &metadata.verify {
388            markdown.push_str(indent);
389            markdown.push_str("    - ");
390            markdown.push_str(command);
391            markdown.push('\n');
392        }
393    }
394}
395
396pub fn metadata_from_input(
397    files: Option<&[String]>,
398    outcome: Option<&str>,
399    verify: Option<&[String]>,
400) -> TaskStepMetadata {
401    TaskStepMetadata {
402        files: normalize_string_items(files),
403        outcome: normalize_optional_text(outcome),
404        verify: normalize_string_items(verify),
405    }
406}
407
408/// Markers that may trail a task description as inline `Action -> files: [...]
409/// -> verify: [...]` metadata. `outcome` is accepted so a polluted suffix is
410/// still stripped from the visible row even though the compact view never
411/// renders it.
412const INLINE_TASK_METADATA_MARKERS: &[&str] = &[" -> files:", " -> verify:", " -> outcome:"];
413
414fn parse_inline_bracket_list(raw: &str) -> Vec<String> {
415    let trimmed = raw.trim();
416    let inner = trimmed
417        .strip_prefix('[')
418        .and_then(|value| value.strip_suffix(']'))
419        .unwrap_or(trimmed);
420    super::planning_workflow::split_bracket_items(inner)
421        .into_iter()
422        .map(|item| item.trim().to_owned())
423        .filter(|value| !value.is_empty())
424        .collect()
425}
426
427/// Split an inline `Action -> files: [...] -> verify: [...]` suffix out of a
428/// task description.
429///
430/// Returns `(clean_description, files, verify)`. Markers are recognized only
431/// outside inline-code spans so `` `a -> files: b` `` stays intact, and the
432/// Unicode `→` arrow is normalized first so both spellings agree with plan
433/// validation. When no marker exists outside backticks the description is
434/// returned trimmed with empty metadata.
435pub fn split_task_description_metadata(description: &str) -> (String, Vec<String>, Vec<String>) {
436    let normalized;
437    let source = if description.contains('→') {
438        normalized = description.replace('→', "->");
439        normalized.as_str()
440    } else {
441        description
442    };
443    let Some(first) = find_inline_metadata_marker(source, 0) else {
444        return (source.trim().to_string(), Vec::new(), Vec::new());
445    };
446    let head = source[..first.0].trim();
447    if head.is_empty() {
448        return (source.trim().to_string(), Vec::new(), Vec::new());
449    }
450
451    let mut files = Vec::new();
452    let mut verify = Vec::new();
453    let mut cursor = first.0;
454    while let Some((marker_start, marker)) = find_inline_metadata_marker(source, cursor) {
455        let value_start = marker_start + marker.len();
456        let next = find_inline_metadata_marker(source, value_start)
457            .map(|(start, _)| start)
458            .unwrap_or(source.len());
459        let value = source[value_start..next].trim();
460        if marker.contains("files:") {
461            files.extend(parse_inline_bracket_list(value));
462        } else if marker.contains("verify:") {
463            verify.extend(parse_inline_bracket_list(value));
464        }
465        cursor = next;
466        if cursor >= source.len() {
467            break;
468        }
469    }
470    (head.to_string(), files, verify)
471}
472
473/// Strip an inline `-> files:` / `-> verify:` / `-> outcome:` suffix for
474/// display. Structured metadata stays in the payload; the visible compact row
475/// must not reintroduce it as detail text.
476pub fn strip_task_description_metadata(description: &str) -> String {
477    split_task_description_metadata(description).0
478}
479
480/// One short description for a task row: the leading clause before detail
481/// separators (` — `/` – `/` - `/`: `, `; `, `. `, ` (`).
482///
483/// Plan steps often carry a long detail tail
484/// (`"Add X – document Y in Z, sourced from W"`); the TODO transcript and
485/// panel must show only `"Add X"` so each item stays on one scannable row.
486/// Inline `-> files:`/`-> verify:` suffixes are stripped first; the full text
487/// stays in structured tracker metadata.
488pub fn short_task_description(description: &str) -> String {
489    let (clean, _, _) = split_task_description_metadata(description);
490    let base = if clean.trim().is_empty() {
491        description.trim()
492    } else {
493        clean.trim()
494    };
495    let first_line = base.lines().map(str::trim).find(|line| !line.is_empty()).unwrap_or("");
496    let collapsed = vtcode_commons::formatting::collapse_whitespace(first_line);
497    if collapsed.is_empty() {
498        return String::new();
499    }
500    let mut cut = collapsed.len();
501    for separator in [" — ", " – ", " - ", ": ", "; ", ". ", " ("] {
502        if let Some(index) = collapsed.find(separator) {
503            cut = cut.min(index);
504        }
505    }
506    let short = collapsed[..cut].trim().trim_end_matches([',', ';', ':', '.']).trim();
507    if short.is_empty() { collapsed } else { short.to_string() }
508}
509
510fn find_inline_metadata_marker(source: &str, from: usize) -> Option<(usize, &'static str)> {
511    let mut inline_ticks: Option<usize> = None;
512    let mut cursor = from.min(source.len());
513    while cursor < source.len() {
514        let remainder = &source[cursor..];
515        if remainder.starts_with('`') {
516            let run = remainder.bytes().take_while(|byte| *byte == b'`').count();
517            if inline_ticks.is_some_and(|ticks| ticks == run) {
518                inline_ticks = None;
519            } else if inline_ticks.is_none() {
520                inline_ticks = Some(run);
521            }
522            cursor += run;
523            continue;
524        }
525        if inline_ticks.is_none() {
526            for marker in INLINE_TASK_METADATA_MARKERS {
527                if remainder.starts_with(marker) {
528                    return Some((cursor, marker));
529                }
530            }
531        }
532        let character = remainder.chars().next()?;
533        cursor += character.len_utf8();
534    }
535    None
536}
537
538/// A renderer-independent task tree used by both task-tracker implementations.
539///
540/// The persisted checklist formats remain implementation-specific; this type is
541/// only the shared shape required to produce the compact visible view.
542#[derive(Debug, Clone, PartialEq, Eq)]
543pub(crate) struct TaskTreeNode {
544    pub(crate) index_path: String,
545    pub(crate) description: String,
546    pub(crate) status: TaskTrackingStatus,
547    pub(crate) metadata: TaskStepMetadata,
548    pub(crate) children: Vec<TaskTreeNode>,
549}
550
551/// Format task nodes as the compact tree shared by inline output and the task
552/// panel. Root leaves retain `├`/`└` connectors; nested leaves use their status
553/// glyph as the connector so the hierarchy stays readable without redundant
554/// branch characters.
555pub(crate) fn compact_task_tree_view(nodes: &[TaskTreeNode]) -> Vec<Value> {
556    let mut lines = Vec::new();
557    append_compact_task_tree_view(nodes, "  ", &mut lines);
558    lines
559}
560
561fn append_compact_task_tree_view(nodes: &[TaskTreeNode], tree_prefix: &str, out: &mut Vec<Value>) {
562    for (index, node) in nodes.iter().enumerate() {
563        let is_last = index + 1 == nodes.len();
564        let branch = if is_last { "└" } else { "├" };
565        let display = if node.children.is_empty() {
566            if tree_prefix == "  " {
567                format!("{tree_prefix}{branch} {} {}", node.status.compact_view_symbol(), node.description)
568            } else {
569                format!("{tree_prefix}{} {}", node.status.compact_view_symbol(), node.description)
570            }
571        } else {
572            format!("{tree_prefix}{branch} {}", node.description)
573        };
574
575        out.push(json!({
576            "display": display,
577            "index_path": node.index_path.clone(),
578            "status": node.status.as_str(),
579            "text": node.description.clone(),
580            "files": node.metadata.files.clone(),
581            "outcome": node.metadata.outcome.clone(),
582            "verify": node.metadata.verify.clone(),
583        }));
584
585        let next_prefix = if is_last {
586            format!("{tree_prefix}  ")
587        } else {
588            format!("{tree_prefix}│ ")
589        };
590        append_compact_task_tree_view(&node.children, &next_prefix, out);
591    }
592}
593
594/// Build the compact tree from a flattened checklist summary.
595///
596/// Standard task trackers expose numeric `index` values while planning
597/// trackers expose dotted `index_path` values. Accept both forms so callers at
598/// the UI boundary do not need to know which tracker produced the payload.
599pub fn compact_task_tree_view_from_items(items: &[Value]) -> Vec<Value> {
600    let mut ordered_paths = Vec::with_capacity(items.len());
601    let mut nodes_by_path = std::collections::HashMap::with_capacity(items.len());
602
603    for (position, item) in items.iter().enumerate() {
604        let index_path = item
605            .get("index_path")
606            .and_then(Value::as_str)
607            .filter(|path| !path.trim().is_empty())
608            .map(ToOwned::to_owned)
609            .or_else(|| item.get("index").and_then(Value::as_u64).map(|index| index.to_string()))
610            .unwrap_or_else(|| (position + 1).to_string());
611        if nodes_by_path.contains_key(&index_path) {
612            continue;
613        }
614
615        let status = match item.get("status").and_then(Value::as_str) {
616            Some(value) => match TaskTrackingStatus::from_str(value) {
617                Ok(status) => status,
618                Err(_) => continue,
619            },
620            None => TaskTrackingStatus::Pending,
621        };
622        let Some(description) = item
623            .get("description")
624            .and_then(Value::as_str)
625            .or_else(|| item.get("text").and_then(Value::as_str))
626            .filter(|description| !description.trim().is_empty())
627        else {
628            continue;
629        };
630        // Descriptions copied from plan steps may still carry an inline
631        // `Action -> files: [...] -> verify: [...]` suffix plus a long detail
632        // tail (`"Add X – document Y, sourced from Z"`). Keep only the short
633        // leading clause for the visible row and fold the rest into structured
634        // metadata so the compact view never reintroduces files/verify/detail
635        // text.
636        let (clean_description, parsed_files, parsed_verify) = split_task_description_metadata(description);
637        let raw = if clean_description.trim().is_empty() {
638            description.trim()
639        } else {
640            clean_description.trim()
641        };
642        let description = short_task_description(raw);
643        let mut metadata = TaskStepMetadata {
644            files: string_array(item.get("files")),
645            outcome: item.get("outcome").and_then(Value::as_str).map(ToOwned::to_owned),
646            verify: string_or_string_array(item.get("verify")),
647        };
648        if metadata.files.is_empty() {
649            metadata.files = parsed_files;
650        }
651        if metadata.verify.is_empty() {
652            metadata.verify = parsed_verify;
653        }
654
655        ordered_paths.push(index_path.clone());
656        nodes_by_path.insert(
657            index_path.clone(),
658            TaskTreeNode {
659                index_path,
660                description,
661                status,
662                metadata,
663                children: Vec::new(),
664            },
665        );
666    }
667
668    let mut attachment_order = ordered_paths.clone();
669    attachment_order.sort_by_key(|path| std::cmp::Reverse(path.split('.').count()));
670    for path in attachment_order {
671        let Some(parent_path) = path.rsplit_once('.').map(|(parent, _)| parent.to_string()) else {
672            continue;
673        };
674        let Some(child) = nodes_by_path.remove(&path) else {
675            continue;
676        };
677        if let Some(parent) = nodes_by_path.get_mut(&parent_path) {
678            parent.children.push(child);
679        } else {
680            nodes_by_path.insert(path, child);
681        }
682    }
683
684    let roots = ordered_paths
685        .into_iter()
686        .filter_map(|path| nodes_by_path.remove(&path))
687        .collect::<Vec<_>>();
688    compact_task_tree_view(&roots)
689}
690
691fn string_array(value: Option<&Value>) -> Vec<String> {
692    value
693        .and_then(Value::as_array)
694        .into_iter()
695        .flatten()
696        .filter_map(Value::as_str)
697        .map(ToOwned::to_owned)
698        .collect()
699}
700
701fn string_or_string_array(value: Option<&Value>) -> Vec<String> {
702    match value {
703        Some(Value::String(value)) => vec![value.clone()],
704        Some(Value::Array(_)) => string_array(value),
705        _ => Vec::new(),
706    }
707}
708
709#[derive(Default)]
710pub struct TaskCounts {
711    pub total: usize,
712    pub completed: usize,
713    pub in_progress: usize,
714    pub pending: usize,
715    pub blocked: usize,
716}
717
718impl TaskCounts {
719    pub fn add(&mut self, status: &TaskTrackingStatus) {
720        self.total += 1;
721        match status {
722            TaskTrackingStatus::Pending => self.pending += 1,
723            TaskTrackingStatus::InProgress => self.in_progress += 1,
724            TaskTrackingStatus::Completed => self.completed += 1,
725            TaskTrackingStatus::Blocked => self.blocked += 1,
726        }
727    }
728
729    pub fn progress_percent(&self) -> usize {
730        if self.total > 0 {
731            #[allow(
732                clippy::cast_sign_loss,
733                clippy::let_and_return,
734                reason = "Intentional compatibility, platform, or test-only suppression."
735            )]
736            let progress = ((self.completed as f64 / self.total as f64 * 100.0).round()).max(0.0) as usize;
737            progress
738        } else {
739            0
740        }
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    #[test]
749    fn parse_marked_status_prefix_rejects_unmarked_text() {
750        let parsed = parse_marked_status_prefix("plain text without marker");
751        assert!(parsed.is_none());
752    }
753
754    #[test]
755    fn parse_status_prefix_defaults_to_pending_for_unmarked_text() {
756        let (status, description) = parse_status_prefix("plain text without marker");
757        assert_eq!(status, TaskTrackingStatus::Pending);
758        assert_eq!(description, "plain text without marker");
759    }
760
761    #[test]
762    fn parse_status_prefix_supports_both_in_progress_markers() {
763        let (status_tilde, text_tilde) = parse_status_prefix("[~] do thing");
764        let (status_slash, text_slash) = parse_status_prefix("[/] do thing");
765        assert_eq!(status_tilde, TaskTrackingStatus::InProgress);
766        assert_eq!(status_slash, TaskTrackingStatus::InProgress);
767        assert_eq!(text_tilde, "do thing");
768        assert_eq!(text_slash, "do thing");
769    }
770
771    #[test]
772    fn append_notes_joins_with_single_newline() {
773        let merged = append_notes(Some("left".to_string()), Some("right"));
774        assert_eq!(merged, Some("left\nright".to_string()));
775    }
776
777    #[test]
778    fn append_notes_section_ignores_blank_notes() {
779        let mut markdown = "# Title\n".to_string();
780        append_notes_section(&mut markdown, Some("   "));
781        assert_eq!(markdown, "# Title\n");
782    }
783
784    #[test]
785    fn bulk_sync_validation_preserves_text_and_actual_structured_items() {
786        let valid = vec![
787            TaskItemInput::Text(r#"[x] Document {"status":"completed"}"#.to_string()),
788            TaskItemInput::Text(r#"{"example":"literal JSON"}"#.to_string()),
789            TaskItemInput::Structured(TaskItemInputObject {
790                description: "Original step".to_string(),
791                status: Some("completed".to_string()),
792                ..Default::default()
793            }),
794        ];
795        validate_task_item_inputs(&valid).unwrap();
796        for text in [
797            r#"{"index":1,"status":"completed"}"#,
798            r#"{"description":"Encoded step","status":"completed"}"#,
799        ] {
800            for prefix in ["", "[x] ", "[X] ", "[~] ", "[/] ", "[!] ", "[ ] "] {
801                let item = TaskItemInput::Text(format!("  {prefix}{text}  "));
802                assert!(validate_task_item_inputs(&[item]).is_err(), "prefix {prefix:?}");
803            }
804        }
805    }
806
807    #[test]
808    fn is_bulk_sync_update_requires_items_and_missing_single_item_fields() {
809        let items = vec!["Step".to_string()];
810        let no_items: Option<&[String]> = None;
811        assert!(is_bulk_sync_update(Some(&items), None, None, None));
812        assert!(!is_bulk_sync_update(no_items, Some(1), None, Some("completed")));
813    }
814
815    #[test]
816    fn is_bulk_sync_update_rejects_mixed_single_item_fields() {
817        let items = vec!["Step".to_string()];
818        let error = validate_update_shape(Some(&items), Some(1), None, Some("completed"))
819            .expect_err("mixed bulk and single update fields must fail closed");
820
821        assert!(error.to_string().contains("cannot combine 'items'"));
822    }
823
824    #[test]
825    fn task_tracker_update_rejects_both_index_forms() {
826        let error = validate_update_shape::<String>(None, Some(1), Some("1"), Some("completed"))
827            .expect_err("an update must select exactly one index form");
828
829        assert!(error.to_string().contains("cannot combine 'index' and 'index_path'"));
830    }
831
832    #[test]
833    fn task_counts_tracks_progress() {
834        let mut counts = TaskCounts::default();
835        counts.add(&TaskTrackingStatus::Completed);
836        counts.add(&TaskTrackingStatus::Pending);
837        counts.add(&TaskTrackingStatus::Blocked);
838        counts.add(&TaskTrackingStatus::InProgress);
839        assert_eq!(counts.total, 4);
840        assert_eq!(counts.completed, 1);
841        assert_eq!(counts.pending, 1);
842        assert_eq!(counts.blocked, 1);
843        assert_eq!(counts.in_progress, 1);
844        assert_eq!(counts.progress_percent(), 25);
845    }
846
847    #[test]
848    fn compact_task_tree_view_formats_flat_leaf_statuses_exactly() {
849        let nodes = [
850            TaskTreeNode {
851                index_path: "1".to_string(),
852                description: "Pending".to_string(),
853                status: TaskTrackingStatus::Pending,
854                metadata: TaskStepMetadata::default(),
855                children: Vec::new(),
856            },
857            TaskTreeNode {
858                index_path: "2".to_string(),
859                description: "In progress".to_string(),
860                status: TaskTrackingStatus::InProgress,
861                metadata: TaskStepMetadata::default(),
862                children: Vec::new(),
863            },
864            TaskTreeNode {
865                index_path: "3".to_string(),
866                description: "Completed".to_string(),
867                status: TaskTrackingStatus::Completed,
868                metadata: TaskStepMetadata::default(),
869                children: Vec::new(),
870            },
871            TaskTreeNode {
872                index_path: "4".to_string(),
873                description: "Blocked".to_string(),
874                status: TaskTrackingStatus::Blocked,
875                metadata: TaskStepMetadata::default(),
876                children: Vec::new(),
877            },
878        ];
879
880        let displays = compact_task_tree_view(&nodes)
881            .into_iter()
882            .filter_map(|line| line.get("display").and_then(Value::as_str).map(ToOwned::to_owned))
883            .collect::<Vec<_>>();
884        assert_eq!(
885            displays,
886            vec![
887                "  ├ □ Pending".to_string(),
888                "  ├ [-] In progress".to_string(),
889                "  ├ [x] Completed".to_string(),
890                "  └ [!] Blocked".to_string(),
891            ]
892        );
893    }
894
895    #[test]
896    fn compact_task_tree_view_omits_parent_status_but_keeps_metadata_fields() {
897        let nodes = [TaskTreeNode {
898            index_path: "1".to_string(),
899            description: "Parent".to_string(),
900            status: TaskTrackingStatus::InProgress,
901            metadata: TaskStepMetadata {
902                files: vec!["Cargo.toml".to_string()],
903                outcome: Some("Ready".to_string()),
904                verify: vec!["cargo check".to_string()],
905            },
906            children: vec![TaskTreeNode {
907                index_path: "1.1".to_string(),
908                description: "Child".to_string(),
909                status: TaskTrackingStatus::Completed,
910                metadata: TaskStepMetadata::default(),
911                children: Vec::new(),
912            }],
913        }];
914
915        let lines = compact_task_tree_view(&nodes);
916        assert_eq!(lines[0]["display"], "  └ Parent");
917        assert_eq!(lines[1]["display"], "    [x] Child");
918        assert_eq!(lines[0]["files"], json!(["Cargo.toml"]));
919        assert_eq!(lines[0]["outcome"], "Ready");
920        assert_eq!(lines[0]["verify"], json!(["cargo check"]));
921    }
922
923    #[test]
924    fn split_task_description_metadata_strips_files_and_verify_suffix() {
925        let (clean, files, verify) =
926            split_task_description_metadata("Emit summary -> files: [src/a.rs, src/b.rs] -> verify: [cargo check]");
927        assert_eq!(clean, "Emit summary");
928        assert_eq!(files, vec!["src/a.rs".to_string(), "src/b.rs".to_string()]);
929        assert_eq!(verify, vec!["cargo check".to_string()]);
930    }
931
932    #[test]
933    fn split_task_description_metadata_keeps_inline_code_arrows_intact() {
934        let (clean, files, verify) =
935            split_task_description_metadata("Use `a -> files: b` in code -> files: [src/a.rs]");
936        assert_eq!(clean, "Use `a -> files: b` in code");
937        assert_eq!(files, vec!["src/a.rs".to_string()]);
938        assert!(verify.is_empty());
939    }
940
941    #[test]
942    fn split_task_description_metadata_without_markers_returns_trimmed_description() {
943        let (clean, files, verify) = split_task_description_metadata("  Just an action  ");
944        assert_eq!(clean, "Just an action");
945        assert!(files.is_empty());
946        assert!(verify.is_empty());
947    }
948
949    #[test]
950    fn split_task_description_metadata_preserves_quoted_commas_in_files_and_verify() {
951        let (clean, files, verify) = split_task_description_metadata(
952            "Inspect range -> files: ['src/a.rs, b.rs', src/c.rs] -> verify: [sed -n '/^## A/,/^## B/p']",
953        );
954        assert_eq!(clean, "Inspect range");
955        assert_eq!(files, vec!["'src/a.rs, b.rs'".to_string(), "src/c.rs".to_string()]);
956        assert_eq!(verify, vec!["sed -n '/^## A/,/^## B/p'".to_string()]);
957    }
958
959    #[test]
960    fn compact_task_tree_view_from_items_strips_inline_metadata_into_fields() {
961        let items = vec![json!({
962            "index_path": "1",
963            "description": "Update parser -> files: [src/a.rs] -> verify: [cargo check]",
964            "status": "pending",
965        })];
966        let rows = compact_task_tree_view_from_items(&items);
967        assert_eq!(rows.len(), 1);
968        assert_eq!(rows[0]["display"], "  └ □ Update parser");
969        assert_eq!(rows[0]["text"], "Update parser");
970        assert_eq!(rows[0]["files"], json!(["src/a.rs"]));
971        assert_eq!(rows[0]["verify"], json!(["cargo check"]));
972    }
973
974    #[test]
975    fn short_task_description_keeps_leading_clause_only() {
976        assert_eq!(
977            short_task_description(
978                "Add vtcode exec resume to the Commands section – document the cross-turn contract, sourced from ExecSubcommand::Resume"
979            ),
980            "Add vtcode exec resume to the Commands section"
981        );
982        assert_eq!(
983            short_task_description("Update the Everyday recipes block — add a headless example"),
984            "Update the Everyday recipes block"
985        );
986        assert_eq!(
987            short_task_description("Emit summary -> files: [src/a.rs] -> verify: [cargo check]"),
988            "Emit summary"
989        );
990        assert_eq!(short_task_description("Verify with cargo check"), "Verify with cargo check");
991        assert_eq!(short_task_description(""), "");
992    }
993
994    #[test]
995    fn compact_task_tree_view_from_items_shortens_detail_tail() {
996        let items = vec![json!({
997            "index_path": "1",
998            "description": "Add vtcode exec resume to the Commands section – document the cross-turn contract in the command table",
999            "status": "pending",
1000        })];
1001        let rows = compact_task_tree_view_from_items(&items);
1002        assert_eq!(rows.len(), 1);
1003        assert_eq!(rows[0]["display"], "  └ □ Add vtcode exec resume to the Commands section");
1004        assert_eq!(rows[0]["text"], "Add vtcode exec resume to the Commands section");
1005    }
1006
1007    #[test]
1008    fn shared_schema_helpers_stay_actionable_and_drift_free() {
1009        assert_eq!(TASK_STATUS_VALUES, ["pending", "in_progress", "completed", "blocked"]);
1010
1011        let status = task_status_schema();
1012        assert_eq!(status["type"], "string");
1013        assert_eq!(status["enum"], json!(TASK_STATUS_VALUES));
1014        assert!(status.get("description").is_none());
1015
1016        let with_desc = task_status_property_schema("New status.");
1017        assert_eq!(with_desc["description"], "New status.");
1018        assert_eq!(with_desc["enum"], json!(TASK_STATUS_VALUES));
1019
1020        // Asymmetric: plain string item vs structured item with metadata.
1021        let items_schema = task_items_array_schema("Items desc.");
1022        let validator = jsonschema::validator_for(&items_schema).expect("items schema is valid");
1023        assert!(validator.validate(&json!(["plain step"])).is_ok());
1024        assert!(
1025            validator
1026                .validate(&json!([{
1027                    "description": "Structured step",
1028                    "status": "in_progress",
1029                    "files": ["src/a.rs"],
1030                    "outcome": "Done",
1031                    "verify": ["cargo check"],
1032                }]))
1033                .is_ok()
1034        );
1035        // Boundary: verify also accepts a bare string; missing description fails.
1036        assert!(validator.validate(&json!([{"status": "completed"}])).is_err());
1037        assert!(validator.validate(&json!([{"description": "Bad", "status": "done"}])).is_err());
1038
1039        assert_eq!(task_files_property_schema("F.")["description"], "F.");
1040        assert_eq!(task_verify_property_schema("V.")["description"], "V.");
1041    }
1042
1043    #[test]
1044    fn shared_status_values_match_runtime_parser() {
1045        use std::str::FromStr;
1046        for status in TASK_STATUS_VALUES {
1047            assert!(TaskTrackingStatus::from_str(status).is_ok(), "schema status {status:?} must parse");
1048        }
1049        assert!(TaskTrackingStatus::from_str("done").is_err());
1050    }
1051}