Skip to main content

vtcode_core/tools/handlers/
task_tracking.rs

1use std::str::FromStr;
2
3use anyhow::{Result, bail};
4use hashbrown::HashMap;
5use serde::{Deserialize, Serialize};
6use serde_json::{Value, json};
7
8mod persistence;
9pub(crate) use persistence::{
10    TrackerArtifact, persist_tracker_artifacts, read_tracker_artifact, write_tracker_artifact,
11};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
14pub struct TaskStepMetadata {
15    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16    pub files: Vec<String>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub outcome: Option<String>,
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub verify: Vec<String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(untagged)]
25pub enum TaskItemInput {
26    Text(String),
27    Structured(TaskItemInputObject),
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
31pub struct TaskItemInputObject {
32    pub description: String,
33    #[serde(default)]
34    pub status: Option<String>,
35    #[serde(default, deserialize_with = "deserialize_optional_string_list")]
36    pub files: Option<Vec<String>>,
37    #[serde(default)]
38    pub outcome: Option<String>,
39    #[serde(default, deserialize_with = "deserialize_optional_string_list")]
40    pub verify: Option<Vec<String>>,
41}
42
43pub(crate) struct TaskMetadataRef<'a> {
44    pub description: &'a str,
45    pub metadata: &'a TaskStepMetadata,
46}
47
48pub(crate) struct ParsedTaskItem {
49    pub level: usize,
50    pub description: String,
51    pub status: TaskTrackingStatus,
52    pub metadata: TaskStepMetadata,
53}
54
55/// Normalize both tracker variants and preserve omitted metadata only when
56/// a description identifies exactly one old step and exactly one new step.
57/// Creates pass no previous items; explicit empty metadata still clears it.
58pub(crate) fn parse_task_items(
59    items: &[TaskItemInput],
60    previous: &[TaskMetadataRef<'_>],
61) -> Result<Vec<ParsedTaskItem>> {
62    validate_task_item_inputs(items)?;
63    let mut previous_by_description = HashMap::new();
64    for item in previous {
65        previous_by_description
66            .entry(item.description.trim())
67            .and_modify(|metadata| *metadata = None)
68            .or_insert(Some(item.metadata));
69    }
70    let mut description_counts = HashMap::new();
71    for item in items {
72        let raw = match item {
73            TaskItemInput::Text(raw) => raw,
74            TaskItemInput::Structured(input) => &input.description,
75        };
76        let (_, description) = parse_status_prefix(raw.trim());
77        *description_counts.entry(description.trim().to_owned()).or_insert(0usize) += 1;
78    }
79
80    let mut parsed_items = Vec::with_capacity(items.len());
81    for item in items {
82        let raw = match item {
83            TaskItemInput::Text(raw) => raw,
84            TaskItemInput::Structured(input) => &input.description,
85        };
86        let (mut status, description) = parse_status_prefix(raw.trim());
87        let description = description.trim().to_owned();
88        if description.is_empty() {
89            continue;
90        }
91        let previous_metadata = if description_counts.get(&description) == Some(&1) {
92            previous_by_description.get(description.as_str()).copied().flatten()
93        } else {
94            None
95        };
96        if previous_metadata.is_none()
97            && previous
98                .iter()
99                .any(|item| item.description.trim() == description && item.metadata != &TaskStepMetadata::default())
100            && !matches!(item, TaskItemInput::Structured(input) if input.files.is_some() && input.outcome.is_some() && input.verify.is_some())
101        {
102            bail!(
103                "Ambiguous task description '{description}': provide explicit files, outcome, and verify metadata for duplicate steps. Use empty arrays and an empty outcome to clear them. The checklist was not changed."
104            );
105        }
106        let mut metadata = previous_metadata.cloned().unwrap_or_default();
107        if let TaskItemInput::Structured(input) = item {
108            if let Some(raw_status) = input.status.as_deref() {
109                status = TaskTrackingStatus::from_str(raw_status)?;
110            }
111            apply_metadata_input(
112                &mut metadata,
113                input.files.as_deref(),
114                input.outcome.as_deref(),
115                input.verify.as_deref(),
116            );
117        }
118        parsed_items.push(ParsedTaskItem {
119            level: raw.chars().take_while(|character| *character == ' ').count() / 2,
120            description,
121            status,
122            metadata,
123        });
124    }
125    Ok(parsed_items)
126}
127
128pub(crate) fn apply_metadata_input(
129    metadata: &mut TaskStepMetadata,
130    files: Option<&[String]>,
131    outcome: Option<&str>,
132    verify: Option<&[String]>,
133) {
134    if let Some(files) = files {
135        metadata.files = normalize_string_items(Some(files));
136    }
137    if let Some(outcome) = outcome {
138        metadata.outcome = normalize_optional_text(Some(outcome));
139    }
140    if let Some(verify) = verify {
141        metadata.verify = normalize_string_items(Some(verify));
142    }
143}
144
145#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(rename_all = "snake_case")]
147pub enum TaskTrackingStatus {
148    Pending,
149    InProgress,
150    Completed,
151    Blocked,
152}
153
154impl std::fmt::Display for TaskTrackingStatus {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        write!(f, "{}", self.as_str())
157    }
158}
159
160impl FromStr for TaskTrackingStatus {
161    type Err = anyhow::Error;
162
163    fn from_str(value: &str) -> Result<Self> {
164        match value {
165            "pending" => Ok(Self::Pending),
166            "in_progress" => Ok(Self::InProgress),
167            "completed" => Ok(Self::Completed),
168            "blocked" => Ok(Self::Blocked),
169            other => {
170                bail!("Invalid status '{other}'. Use: pending, in_progress, completed, blocked")
171            }
172        }
173    }
174}
175
176impl TaskTrackingStatus {
177    pub fn as_str(&self) -> &'static str {
178        match self {
179            Self::Pending => "pending",
180            Self::InProgress => "in_progress",
181            Self::Completed => "completed",
182            Self::Blocked => "blocked",
183        }
184    }
185
186    pub fn flat_checkbox(&self) -> &'static str {
187        match self {
188            Self::Pending => "[ ]",
189            Self::InProgress => "[/]",
190            Self::Completed => "[x]",
191            Self::Blocked => "[!]",
192        }
193    }
194
195    pub fn plan_checkbox(&self) -> &'static str {
196        match self {
197            Self::Pending => "[ ]",
198            Self::InProgress => "[~]",
199            Self::Completed => "[x]",
200            Self::Blocked => "[!]",
201        }
202    }
203
204    pub fn view_symbol(&self) -> &'static str {
205        match self {
206            Self::Pending => "•",
207            Self::InProgress => ">",
208            Self::Completed => "✔",
209            Self::Blocked => "!",
210        }
211    }
212
213    pub fn compact_view_symbol(&self) -> &'static str {
214        match self {
215            Self::Pending => "□",
216            Self::InProgress => "[-]",
217            Self::Completed => "[x]",
218            Self::Blocked => "[!]",
219        }
220    }
221}
222
223pub fn parse_marked_status_prefix(value: &str) -> Option<(TaskTrackingStatus, String)> {
224    let trimmed = value.trim_start();
225    let mapping = [
226        ("[x] ", TaskTrackingStatus::Completed),
227        ("[X] ", TaskTrackingStatus::Completed),
228        ("[~] ", TaskTrackingStatus::InProgress),
229        ("[/] ", TaskTrackingStatus::InProgress),
230        ("[!] ", TaskTrackingStatus::Blocked),
231        ("[ ] ", TaskTrackingStatus::Pending),
232    ];
233    for (prefix, status) in mapping {
234        if let Some(rest) = trimmed.strip_prefix(prefix) {
235            return Some((status, rest.to_string()));
236        }
237    }
238    None
239}
240
241pub fn parse_status_prefix(value: &str) -> (TaskTrackingStatus, String) {
242    parse_marked_status_prefix(value).unwrap_or((TaskTrackingStatus::Pending, value.trim_start().to_string()))
243}
244
245pub fn append_notes(existing: Option<String>, append: Option<&str>) -> Option<String> {
246    match (existing, append) {
247        (None, None) => None,
248        (Some(text), None) => {
249            if text.trim().is_empty() {
250                None
251            } else {
252                Some(text)
253            }
254        }
255        (None, Some(extra)) => {
256            let trimmed = extra.trim();
257            if trimmed.is_empty() {
258                None
259            } else {
260                Some(trimmed.to_string())
261            }
262        }
263        (Some(text), Some(extra)) => {
264            let left = text.trim();
265            let right = extra.trim();
266            if left.is_empty() && right.is_empty() {
267                None
268            } else if left.is_empty() {
269                Some(right.to_string())
270            } else if right.is_empty() {
271                Some(left.to_string())
272            } else {
273                Some(format!("{left}\n{right}"))
274            }
275        }
276    }
277}
278
279pub fn append_notes_section(markdown: &mut String, notes: Option<&str>) {
280    if let Some(text) = notes {
281        let trimmed = text.trim();
282        if !trimmed.is_empty() {
283            markdown.push_str("\n## Notes\n\n");
284            markdown.push_str(trimmed);
285            markdown.push('\n');
286        }
287    }
288}
289
290pub(crate) fn validate_update_shape<T>(
291    items: Option<&[T]>,
292    index: Option<usize>,
293    index_path: Option<&str>,
294    status: Option<&str>,
295) -> Result<()> {
296    if index.is_some() && index_path.is_some() {
297        bail!("task-tracker updates cannot combine 'index' and 'index_path'");
298    }
299
300    if items.is_some() && (index.is_some() || index_path.is_some() || status.is_some()) {
301        bail!("bulk task-tracker updates cannot combine 'items' with 'index', 'index_path', or 'status'");
302    }
303
304    Ok(())
305}
306
307pub(crate) fn validate_action_index_fields(action: &str, index: Option<usize>, index_path: Option<&str>) -> Result<()> {
308    if action != "update" && (index.is_some() || index_path.is_some()) {
309        bail!(
310            "task-tracker action '{action}' cannot use 'index' or 'index_path'; indices are only valid with action='update'"
311        );
312    }
313
314    Ok(())
315}
316
317pub(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. Bulk updates preserve omitted metadata for unique unchanged descriptions. Explicit metadata replaces it; use empty arrays or an empty outcome to clear fields. Duplicate descriptions with existing metadata require explicit files, outcome, and verify.";
318
319/// Shared status values for every task-tracker schema (standard + planning,
320/// top-level and nested item objects). Single source so a future status
321/// addition cannot drift between the planning on/off variants.
322pub(crate) const TASK_STATUS_VALUES: [&str; 4] = ["pending", "in_progress", "completed", "blocked"];
323
324/// Base `status` type schema without a description (callers add their own).
325pub(crate) fn task_status_schema() -> Value {
326    json!({
327        "type": "string",
328        "enum": TASK_STATUS_VALUES
329    })
330}
331
332/// Base `files` type schema without a description.
333pub(crate) fn task_files_schema() -> Value {
334    json!({
335        "type": "array",
336        "items": { "type": "string" }
337    })
338}
339
340/// Base `verify` type schema without a description (string or string list).
341pub(crate) fn task_verify_schema() -> Value {
342    json!({
343        "anyOf": [
344            { "type": "string" },
345            {
346                "type": "array",
347                "items": { "type": "string" }
348            }
349        ]
350    })
351}
352
353fn with_description(mut schema: Value, description: &str) -> Value {
354    if let Value::Object(map) = &mut schema {
355        map.insert("description".to_string(), Value::String(description.to_string()));
356    }
357    schema
358}
359
360/// Shared `allOf` conditional blocks for both tracker parameter schemas.
361///
362/// The create, update, no-indices, and add blocks are identical across
363/// trackers; the update `anyOf` arms are order-normalized (validation is
364/// order-independent and no test indexes arm positions). The `index == 0`
365/// completion rule is standard-tracker-only (planning indices start at 1):
366/// `include_index_zero_rule` inserts it at position 2, preserving the
367/// `allOf[2]` contract asserted by the standard schema tests.
368pub(crate) fn tracker_schema_allof(include_index_zero_rule: bool) -> Vec<Value> {
369    let mut allof = vec![
370        json!({
371            "if": {
372                "properties": { "action": { "const": "create" } },
373                "required": ["action"]
374            },
375            "then": {
376                "required": ["items"]
377            }
378        }),
379        json!({
380            "if": {
381                "properties": { "action": { "const": "update" } },
382                "required": ["action"]
383            },
384            "then": {
385                "anyOf": [
386                    {
387                        "required": ["index", "status"],
388                        "not": {
389                            "anyOf": [
390                                { "required": ["items"] },
391                                { "required": ["index_path"] }
392                            ]
393                        }
394                    },
395                    {
396                        "required": ["index_path", "status"],
397                        "not": {
398                            "anyOf": [
399                                { "required": ["items"] },
400                                { "required": ["index"] }
401                            ]
402                        }
403                    },
404                    {
405                        "required": ["items"],
406                        "not": {
407                            "anyOf": [
408                                { "required": ["index"] },
409                                { "required": ["index_path"] },
410                                { "required": ["status"] }
411                            ]
412                        }
413                    }
414                ]
415            }
416        }),
417        json!({
418            "if": {
419                "properties": { "action": { "enum": ["create", "list", "add"] } },
420                "required": ["action"]
421            },
422            "then": {
423                "not": {
424                    "anyOf": [
425                        { "required": ["index"] },
426                        { "required": ["index_path"] }
427                    ]
428                }
429            }
430        }),
431        json!({
432            "if": {
433                "properties": { "action": { "const": "add" } },
434                "required": ["action"]
435            },
436            "then": {
437                "required": ["description"]
438            }
439        }),
440    ];
441    if include_index_zero_rule {
442        allof.insert(
443            2,
444            json!({
445                "if": {
446                    "properties": { "index": { "const": 0 } },
447                    "required": ["index"]
448                },
449                "then": {
450                    "properties": {
451                        "action": { "const": "update" },
452                        "status": { "const": "completed" }
453                    },
454                    "required": ["action", "status"]
455                }
456            }),
457        );
458    }
459    allof
460}
461
462/// Shared nested item-object schema (description + optional status/files/
463/// outcome/verify). Used inside the `items` array of both tracker schemas.
464pub(crate) fn task_item_object_schema() -> Value {
465    json!({
466        "type": "object",
467        "properties": {
468            "description": { "type": "string" },
469            "status": task_status_schema(),
470            "files": task_files_schema(),
471            "outcome": { "type": "string" },
472            "verify": task_verify_schema()
473        },
474        "required": ["description"]
475    })
476}
477
478/// Shared `items` array schema with a caller-supplied description.
479pub(crate) fn task_items_array_schema(items_description: &str) -> Value {
480    with_description(
481        json!({
482            "type": "array",
483            "items": {
484                "anyOf": [
485                    { "type": "string" },
486                    task_item_object_schema()
487                ]
488            }
489        }),
490        items_description,
491    )
492}
493
494/// Top-level `status` property with its own description.
495pub(crate) fn task_status_property_schema(description: &str) -> Value {
496    with_description(task_status_schema(), description)
497}
498
499/// Top-level `files` property with its own description.
500pub(crate) fn task_files_property_schema(description: &str) -> Value {
501    with_description(task_files_schema(), description)
502}
503
504/// Top-level `verify` property with its own description.
505pub(crate) fn task_verify_property_schema(description: &str) -> Value {
506    with_description(task_verify_schema(), description)
507}
508
509/// Checklist replacement must not interpret encoded update commands as descriptions.
510pub(crate) fn validate_task_item_inputs(items: &[TaskItemInput]) -> Result<()> {
511    for item in items {
512        let TaskItemInput::Text(text) = item else {
513            continue;
514        };
515        let (_, description) = parse_status_prefix(text.trim());
516        if let Ok(Value::Object(object)) = serde_json::from_str::<Value>(&description)
517            && ["index", "index_path", "description", "status"]
518                .iter()
519                .any(|key| object.contains_key(*key))
520        {
521            bail!(
522                "'items' replaces the complete checklist; JSON-encoded task updates are not task descriptions. \
523                 Update an existing step directly: {{\"action\":\"update\",\"index_path\":\"1\",\"status\":\"completed\"}}. \
524                 For full synchronization, send descriptions with statuses, for example \
525                 {{\"action\":\"update\",\"items\":[{{\"description\":\"Original step\",\"status\":\"completed\"}}]}}. \
526                 The checklist was not changed. Retry once with the intended shape."
527            );
528        }
529    }
530    Ok(())
531}
532
533pub fn is_bulk_sync_update<T>(
534    items: Option<&[T]>,
535    index: Option<usize>,
536    index_path: Option<&str>,
537    status: Option<&str>,
538) -> bool {
539    items.is_some() && index.is_none() && index_path.is_none() && status.is_none()
540}
541
542pub fn deserialize_optional_string_list<'de, D>(deserializer: D) -> std::result::Result<Option<Vec<String>>, D::Error>
543where
544    D: serde::Deserializer<'de>,
545{
546    #[derive(Deserialize)]
547    #[serde(untagged)]
548    enum OneOrMany {
549        One(String),
550        Many(Vec<String>),
551    }
552
553    let parsed = Option::<OneOrMany>::deserialize(deserializer)?;
554    Ok(parsed.map(|value| match value {
555        OneOrMany::One(item) => vec![item],
556        OneOrMany::Many(items) => items,
557    }))
558}
559
560pub fn normalize_string_items(items: Option<&[String]>) -> Vec<String> {
561    items
562        .unwrap_or(&[])
563        .iter()
564        .map(|item| item.trim())
565        .filter(|item| !item.is_empty())
566        .map(ToOwned::to_owned)
567        .collect()
568}
569
570pub fn normalize_optional_text(value: Option<&str>) -> Option<String> {
571    value.map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned)
572}
573
574/// Parse a `files: [...]` metadata value into trimmed non-empty paths.
575/// Commas inside single/double quotes stay part of one item.
576pub fn parse_files_metadata(value: &str) -> Vec<String> {
577    super::planning_workflow::split_bracket_items(value)
578        .into_iter()
579        .map(|item| item.trim().to_owned())
580        .filter(|item| !item.is_empty())
581        .collect()
582}
583
584/// Consume one indented markdown metadata line (`files:`, `outcome:`,
585/// `verify:`) into `metadata`, tracking multi-line `verify:` blocks through
586/// `in_verify_block`. Returns `true` when the line was metadata.
587pub fn apply_metadata_line(metadata: &mut TaskStepMetadata, raw: &str, in_verify_block: &mut bool) -> bool {
588    let trimmed = raw.trim_start();
589
590    if *in_verify_block {
591        if let Some(command) = trimmed
592            .strip_prefix("- ")
593            .or_else(|| trimmed.strip_prefix("* "))
594            .or_else(|| trimmed.strip_prefix("+ "))
595        {
596            if let Some(command) = normalize_optional_text(Some(command)) {
597                metadata.verify.push(command);
598            }
599            return true;
600        }
601        *in_verify_block = false;
602    }
603
604    if let Some(rest) = trimmed.strip_prefix("files:") {
605        metadata.files = parse_files_metadata(rest);
606        return true;
607    }
608    if let Some(rest) = trimmed.strip_prefix("outcome:") {
609        metadata.outcome = normalize_optional_text(Some(rest));
610        return true;
611    }
612    if trimmed == "verify:" {
613        metadata.verify.clear();
614        *in_verify_block = true;
615        return true;
616    }
617    if let Some(rest) = trimmed.strip_prefix("verify:") {
618        metadata.verify = normalize_string_items(Some(&[rest.to_string()]));
619        return true;
620    }
621
622    false
623}
624
625pub fn append_task_step_metadata(markdown: &mut String, indent: &str, metadata: &TaskStepMetadata) {
626    if !metadata.files.is_empty() {
627        markdown.push_str(indent);
628        markdown.push_str("  files: ");
629        markdown.push_str(&metadata.files.join(", "));
630        markdown.push('\n');
631    }
632
633    if let Some(outcome) = metadata.outcome.as_deref() {
634        markdown.push_str(indent);
635        markdown.push_str("  outcome: ");
636        markdown.push_str(outcome);
637        markdown.push('\n');
638    }
639
640    if metadata.verify.len() == 1 {
641        markdown.push_str(indent);
642        markdown.push_str("  verify: ");
643        markdown.push_str(&metadata.verify[0]);
644        markdown.push('\n');
645    } else if !metadata.verify.is_empty() {
646        markdown.push_str(indent);
647        markdown.push_str("  verify:\n");
648        for command in &metadata.verify {
649            markdown.push_str(indent);
650            markdown.push_str("    - ");
651            markdown.push_str(command);
652            markdown.push('\n');
653        }
654    }
655}
656
657pub fn metadata_from_input(
658    files: Option<&[String]>,
659    outcome: Option<&str>,
660    verify: Option<&[String]>,
661) -> TaskStepMetadata {
662    let mut metadata = TaskStepMetadata::default();
663    apply_metadata_input(&mut metadata, files, outcome, verify);
664    metadata
665}
666
667/// Markers that may trail a task description as inline `Action -> files: [...]
668/// -> verify: [...]` metadata. `outcome` is accepted so a polluted suffix is
669/// still stripped from the visible row even though the compact view never
670/// renders it.
671const INLINE_TASK_METADATA_MARKERS: &[&str] = &[" -> files:", " -> verify:", " -> outcome:"];
672
673fn parse_inline_bracket_list(raw: &str) -> Vec<String> {
674    let trimmed = raw.trim();
675    let inner = trimmed
676        .strip_prefix('[')
677        .and_then(|value| value.strip_suffix(']'))
678        .unwrap_or(trimmed);
679    super::planning_workflow::split_bracket_items(inner)
680        .into_iter()
681        .map(|item| item.trim().to_owned())
682        .filter(|value| !value.is_empty())
683        .collect()
684}
685
686/// Split an inline `Action -> files: [...] -> verify: [...]` suffix out of a
687/// task description.
688///
689/// Returns `(clean_description, files, verify)`. Markers are recognized only
690/// outside inline-code spans so `` `a -> files: b` `` stays intact, and the
691/// Unicode `→` arrow is normalized first so both spellings agree with plan
692/// validation. When no marker exists outside backticks the description is
693/// returned trimmed with empty metadata.
694pub fn split_task_description_metadata(description: &str) -> (String, Vec<String>, Vec<String>) {
695    let normalized;
696    let source = if description.contains('→') {
697        normalized = description.replace('→', "->");
698        normalized.as_str()
699    } else {
700        description
701    };
702    let Some(first) = find_inline_metadata_marker(source, 0) else {
703        return (source.trim().to_string(), Vec::new(), Vec::new());
704    };
705    let head = source[..first.0].trim();
706    if head.is_empty() {
707        return (source.trim().to_string(), Vec::new(), Vec::new());
708    }
709
710    let mut files = Vec::new();
711    let mut verify = Vec::new();
712    let mut cursor = first.0;
713    while let Some((marker_start, marker)) = find_inline_metadata_marker(source, cursor) {
714        let value_start = marker_start + marker.len();
715        let next = find_inline_metadata_marker(source, value_start)
716            .map(|(start, _)| start)
717            .unwrap_or(source.len());
718        let value = source[value_start..next].trim();
719        if marker.contains("files:") {
720            files.extend(parse_inline_bracket_list(value));
721        } else if marker.contains("verify:") {
722            verify.extend(parse_inline_bracket_list(value));
723        }
724        cursor = next;
725        if cursor >= source.len() {
726            break;
727        }
728    }
729    (head.to_string(), files, verify)
730}
731
732/// Strip an inline `-> files:` / `-> verify:` / `-> outcome:` suffix for
733/// display. Structured metadata stays in the payload; the visible compact row
734/// must not reintroduce it as detail text.
735pub fn strip_task_description_metadata(description: &str) -> String {
736    split_task_description_metadata(description).0
737}
738
739/// One short description for a task row: the leading clause before detail
740/// separators (` — `/` – `/` - `/`: `, `; `, `. `, ` (`).
741///
742/// Plan steps often carry a long detail tail
743/// (`"Add X – document Y in Z, sourced from W"`); the TODO transcript and
744/// panel must show only `"Add X"` so each item stays on one scannable row.
745/// Inline `-> files:`/`-> verify:` suffixes are stripped first; the full text
746/// stays in structured tracker metadata.
747pub fn short_task_description(description: &str) -> String {
748    let (clean, _, _) = split_task_description_metadata(description);
749    let base = if clean.trim().is_empty() {
750        description.trim()
751    } else {
752        clean.trim()
753    };
754    let first_line = base.lines().map(str::trim).find(|line| !line.is_empty()).unwrap_or("");
755    let collapsed = vtcode_commons::formatting::collapse_whitespace(first_line);
756    if collapsed.is_empty() {
757        return String::new();
758    }
759    let mut cut = collapsed.len();
760    for separator in [" — ", " – ", " - ", ": ", "; ", ". ", " ("] {
761        if let Some(index) = collapsed.find(separator) {
762            cut = cut.min(index);
763        }
764    }
765    let short = collapsed[..cut].trim().trim_end_matches([',', ';', ':', '.']).trim();
766    if short.is_empty() { collapsed } else { short.to_string() }
767}
768
769fn find_inline_metadata_marker(source: &str, from: usize) -> Option<(usize, &'static str)> {
770    let mut inline_ticks: Option<usize> = None;
771    let mut cursor = from.min(source.len());
772    while cursor < source.len() {
773        let remainder = &source[cursor..];
774        if remainder.starts_with('`') {
775            let run = remainder.bytes().take_while(|byte| *byte == b'`').count();
776            if inline_ticks.is_some_and(|ticks| ticks == run) {
777                inline_ticks = None;
778            } else if inline_ticks.is_none() {
779                inline_ticks = Some(run);
780            }
781            cursor += run;
782            continue;
783        }
784        if inline_ticks.is_none() {
785            for marker in INLINE_TASK_METADATA_MARKERS {
786                if remainder.starts_with(marker) {
787                    return Some((cursor, marker));
788                }
789            }
790        }
791        let character = remainder.chars().next()?;
792        cursor += character.len_utf8();
793    }
794    None
795}
796
797/// A renderer-independent task tree used by both task-tracker implementations.
798///
799/// The persisted checklist formats remain implementation-specific; this type is
800/// only the shared shape required to produce the compact visible view.
801#[derive(Debug, Clone, PartialEq, Eq)]
802pub(crate) struct TaskTreeNode {
803    pub(crate) index_path: String,
804    pub(crate) description: String,
805    pub(crate) status: TaskTrackingStatus,
806    pub(crate) metadata: TaskStepMetadata,
807    pub(crate) children: Vec<TaskTreeNode>,
808}
809
810/// Format task nodes as the compact tree shared by inline output and the task
811/// panel. Root leaves retain `├`/`└` connectors; nested leaves use their status
812/// glyph as the connector so the hierarchy stays readable without redundant
813/// branch characters.
814pub(crate) fn compact_task_tree_view(nodes: &[TaskTreeNode]) -> Vec<Value> {
815    let mut lines = Vec::new();
816    append_compact_task_tree_view(nodes, "  ", &mut lines);
817    lines
818}
819
820fn append_compact_task_tree_view(nodes: &[TaskTreeNode], tree_prefix: &str, out: &mut Vec<Value>) {
821    for (index, node) in nodes.iter().enumerate() {
822        let is_last = index + 1 == nodes.len();
823        let branch = if is_last { "└" } else { "├" };
824        let display = if node.children.is_empty() {
825            if tree_prefix == "  " {
826                format!("{tree_prefix}{branch} {} {}", node.status.compact_view_symbol(), node.description)
827            } else {
828                format!("{tree_prefix}{} {}", node.status.compact_view_symbol(), node.description)
829            }
830        } else {
831            format!("{tree_prefix}{branch} {}", node.description)
832        };
833
834        out.push(json!({
835            "display": display,
836            "index_path": node.index_path.clone(),
837            "status": node.status.as_str(),
838            "text": node.description.clone(),
839            "files": node.metadata.files.clone(),
840            "outcome": node.metadata.outcome.clone(),
841            "verify": node.metadata.verify.clone(),
842        }));
843
844        let next_prefix = if is_last {
845            format!("{tree_prefix}  ")
846        } else {
847            format!("{tree_prefix}│ ")
848        };
849        append_compact_task_tree_view(&node.children, &next_prefix, out);
850    }
851}
852
853/// Build the compact tree from a flattened checklist summary.
854///
855/// Standard task trackers expose numeric `index` values while planning
856/// trackers expose dotted `index_path` values. Accept both forms so callers at
857/// the UI boundary do not need to know which tracker produced the payload.
858pub fn compact_task_tree_view_from_items(items: &[Value]) -> Vec<Value> {
859    let mut ordered_paths = Vec::with_capacity(items.len());
860    let mut nodes_by_path = std::collections::HashMap::with_capacity(items.len());
861
862    for (position, item) in items.iter().enumerate() {
863        let index_path = item
864            .get("index_path")
865            .and_then(Value::as_str)
866            .filter(|path| !path.trim().is_empty())
867            .map(ToOwned::to_owned)
868            .or_else(|| item.get("index").and_then(Value::as_u64).map(|index| index.to_string()))
869            .unwrap_or_else(|| (position + 1).to_string());
870        if nodes_by_path.contains_key(&index_path) {
871            continue;
872        }
873
874        let status = match item.get("status").and_then(Value::as_str) {
875            Some(value) => match TaskTrackingStatus::from_str(value) {
876                Ok(status) => status,
877                Err(_) => continue,
878            },
879            None => TaskTrackingStatus::Pending,
880        };
881        let Some(description) = item
882            .get("description")
883            .and_then(Value::as_str)
884            .or_else(|| item.get("text").and_then(Value::as_str))
885            .filter(|description| !description.trim().is_empty())
886        else {
887            continue;
888        };
889        // Descriptions copied from plan steps may still carry an inline
890        // `Action -> files: [...] -> verify: [...]` suffix plus a long detail
891        // tail (`"Add X – document Y, sourced from Z"`). Keep only the short
892        // leading clause for the visible row and fold the rest into structured
893        // metadata so the compact view never reintroduces files/verify/detail
894        // text.
895        let (clean_description, parsed_files, parsed_verify) = split_task_description_metadata(description);
896        let raw = if clean_description.trim().is_empty() {
897            description.trim()
898        } else {
899            clean_description.trim()
900        };
901        let description = short_task_description(raw);
902        let mut metadata = TaskStepMetadata {
903            files: string_array(item.get("files")),
904            outcome: item.get("outcome").and_then(Value::as_str).map(ToOwned::to_owned),
905            verify: string_or_string_array(item.get("verify")),
906        };
907        if metadata.files.is_empty() {
908            metadata.files = parsed_files;
909        }
910        if metadata.verify.is_empty() {
911            metadata.verify = parsed_verify;
912        }
913
914        ordered_paths.push(index_path.clone());
915        nodes_by_path.insert(
916            index_path.clone(),
917            TaskTreeNode {
918                index_path,
919                description,
920                status,
921                metadata,
922                children: Vec::new(),
923            },
924        );
925    }
926
927    let mut attachment_order = ordered_paths.clone();
928    attachment_order.sort_by_key(|path| std::cmp::Reverse(path.split('.').count()));
929    for path in attachment_order {
930        let Some(parent_path) = path.rsplit_once('.').map(|(parent, _)| parent.to_string()) else {
931            continue;
932        };
933        let Some(child) = nodes_by_path.remove(&path) else {
934            continue;
935        };
936        if let Some(parent) = nodes_by_path.get_mut(&parent_path) {
937            parent.children.push(child);
938        } else {
939            nodes_by_path.insert(path, child);
940        }
941    }
942
943    let roots = ordered_paths
944        .into_iter()
945        .filter_map(|path| nodes_by_path.remove(&path))
946        .collect::<Vec<_>>();
947    compact_task_tree_view(&roots)
948}
949
950fn string_array(value: Option<&Value>) -> Vec<String> {
951    value
952        .and_then(Value::as_array)
953        .into_iter()
954        .flatten()
955        .filter_map(Value::as_str)
956        .map(ToOwned::to_owned)
957        .collect()
958}
959
960fn string_or_string_array(value: Option<&Value>) -> Vec<String> {
961    match value {
962        Some(Value::String(value)) => vec![value.clone()],
963        Some(Value::Array(_)) => string_array(value),
964        _ => Vec::new(),
965    }
966}
967
968#[derive(Default)]
969pub struct TaskCounts {
970    pub total: usize,
971    pub completed: usize,
972    pub in_progress: usize,
973    pub pending: usize,
974    pub blocked: usize,
975}
976
977impl TaskCounts {
978    pub fn add(&mut self, status: &TaskTrackingStatus) {
979        self.total += 1;
980        match status {
981            TaskTrackingStatus::Pending => self.pending += 1,
982            TaskTrackingStatus::InProgress => self.in_progress += 1,
983            TaskTrackingStatus::Completed => self.completed += 1,
984            TaskTrackingStatus::Blocked => self.blocked += 1,
985        }
986    }
987
988    pub fn progress_percent(&self) -> usize {
989        if self.total > 0 {
990            #[allow(
991                clippy::cast_sign_loss,
992                clippy::let_and_return,
993                reason = "Intentional compatibility, platform, or test-only suppression."
994            )]
995            let progress = ((self.completed as f64 / self.total as f64 * 100.0).round()).max(0.0) as usize;
996            progress
997        } else {
998            0
999        }
1000    }
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005    use super::*;
1006
1007    #[test]
1008    fn parse_files_metadata_keeps_quoted_commas_as_single_item() {
1009        let items = parse_files_metadata("'src/a.rs, b.rs', src/c.rs");
1010        assert_eq!(items, vec!["'src/a.rs, b.rs'".to_string(), "src/c.rs".to_string()]);
1011    }
1012
1013    #[test]
1014    fn apply_metadata_line_drives_verify_block_state_machine() {
1015        let mut metadata = TaskStepMetadata::default();
1016        let mut in_verify_block = false;
1017
1018        // Non-metadata lines are rejected without touching state.
1019        assert!(!apply_metadata_line(&mut metadata, "just a description", &mut in_verify_block));
1020        assert!(!in_verify_block);
1021        assert!(metadata.verify.is_empty());
1022
1023        // files:/outcome: lines are consumed directly (persisted bracket-free).
1024        assert!(apply_metadata_line(&mut metadata, "  files: a.rs, b.rs", &mut in_verify_block));
1025        assert!(apply_metadata_line(&mut metadata, "  outcome: did the thing", &mut in_verify_block));
1026        assert_eq!(metadata.files, vec!["a.rs".to_string(), "b.rs".to_string()]);
1027        assert_eq!(metadata.outcome.as_deref(), Some("did the thing"));
1028
1029        // Bare `verify:` opens a block; mixed bullet markers accumulate.
1030        assert!(apply_metadata_line(&mut metadata, "  verify:", &mut in_verify_block));
1031        assert!(in_verify_block);
1032        assert!(apply_metadata_line(&mut metadata, "    - cargo test", &mut in_verify_block));
1033        assert!(apply_metadata_line(&mut metadata, "    * cargo clippy", &mut in_verify_block));
1034        assert_eq!(metadata.verify, vec!["cargo test".to_string(), "cargo clippy".to_string()]);
1035
1036        // A non-bullet line closes the block and is itself rejected.
1037        assert!(!apply_metadata_line(&mut metadata, "  other: x", &mut in_verify_block));
1038        assert!(!in_verify_block);
1039
1040        // Inline `verify:` replaces the accumulated block.
1041        assert!(apply_metadata_line(&mut metadata, "  verify: make check", &mut in_verify_block));
1042        assert_eq!(metadata.verify, vec!["make check".to_string()]);
1043    }
1044
1045    #[test]
1046    fn parse_marked_status_prefix_rejects_unmarked_text() {
1047        let parsed = parse_marked_status_prefix("plain text without marker");
1048        assert!(parsed.is_none());
1049    }
1050
1051    #[test]
1052    fn parse_status_prefix_defaults_to_pending_for_unmarked_text() {
1053        let (status, description) = parse_status_prefix("plain text without marker");
1054        assert_eq!(status, TaskTrackingStatus::Pending);
1055        assert_eq!(description, "plain text without marker");
1056    }
1057
1058    #[test]
1059    fn parse_status_prefix_supports_both_in_progress_markers() {
1060        let (status_tilde, text_tilde) = parse_status_prefix("[~] do thing");
1061        let (status_slash, text_slash) = parse_status_prefix("[/] do thing");
1062        assert_eq!(status_tilde, TaskTrackingStatus::InProgress);
1063        assert_eq!(status_slash, TaskTrackingStatus::InProgress);
1064        assert_eq!(text_tilde, "do thing");
1065        assert_eq!(text_slash, "do thing");
1066    }
1067
1068    #[test]
1069    fn append_notes_joins_with_single_newline() {
1070        let merged = append_notes(Some("left".to_string()), Some("right"));
1071        assert_eq!(merged, Some("left\nright".to_string()));
1072    }
1073
1074    #[test]
1075    fn append_notes_section_ignores_blank_notes() {
1076        let mut markdown = "# Title\n".to_string();
1077        append_notes_section(&mut markdown, Some("   "));
1078        assert_eq!(markdown, "# Title\n");
1079    }
1080
1081    #[test]
1082    fn bulk_sync_validation_preserves_text_and_actual_structured_items() {
1083        let valid = vec![
1084            TaskItemInput::Text(r#"[x] Document {"status":"completed"}"#.to_string()),
1085            TaskItemInput::Text(r#"{"example":"literal JSON"}"#.to_string()),
1086            TaskItemInput::Structured(TaskItemInputObject {
1087                description: "Original step".to_string(),
1088                status: Some("completed".to_string()),
1089                ..Default::default()
1090            }),
1091        ];
1092        validate_task_item_inputs(&valid).unwrap();
1093        for text in [
1094            r#"{"index":1,"status":"completed"}"#,
1095            r#"{"description":"Encoded step","status":"completed"}"#,
1096        ] {
1097            for prefix in ["", "[x] ", "[X] ", "[~] ", "[/] ", "[!] ", "[ ] "] {
1098                let item = TaskItemInput::Text(format!("  {prefix}{text}  "));
1099                assert!(validate_task_item_inputs(&[item]).is_err(), "prefix {prefix:?}");
1100            }
1101        }
1102    }
1103
1104    #[test]
1105    fn is_bulk_sync_update_requires_items_and_missing_single_item_fields() {
1106        let items = vec!["Step".to_string()];
1107        let no_items: Option<&[String]> = None;
1108        assert!(is_bulk_sync_update(Some(&items), None, None, None));
1109        assert!(!is_bulk_sync_update(no_items, Some(1), None, Some("completed")));
1110    }
1111
1112    #[test]
1113    fn is_bulk_sync_update_rejects_mixed_single_item_fields() {
1114        let items = vec!["Step".to_string()];
1115        let error = validate_update_shape(Some(&items), Some(1), None, Some("completed"))
1116            .expect_err("mixed bulk and single update fields must fail closed");
1117
1118        assert!(error.to_string().contains("cannot combine 'items'"));
1119    }
1120
1121    #[test]
1122    fn task_tracker_update_rejects_both_index_forms() {
1123        let error = validate_update_shape::<String>(None, Some(1), Some("1"), Some("completed"))
1124            .expect_err("an update must select exactly one index form");
1125
1126        assert!(error.to_string().contains("cannot combine 'index' and 'index_path'"));
1127    }
1128
1129    #[test]
1130    fn task_counts_tracks_progress() {
1131        let mut counts = TaskCounts::default();
1132        counts.add(&TaskTrackingStatus::Completed);
1133        counts.add(&TaskTrackingStatus::Pending);
1134        counts.add(&TaskTrackingStatus::Blocked);
1135        counts.add(&TaskTrackingStatus::InProgress);
1136        assert_eq!(counts.total, 4);
1137        assert_eq!(counts.completed, 1);
1138        assert_eq!(counts.pending, 1);
1139        assert_eq!(counts.blocked, 1);
1140        assert_eq!(counts.in_progress, 1);
1141        assert_eq!(counts.progress_percent(), 25);
1142    }
1143
1144    #[test]
1145    fn compact_task_tree_view_formats_flat_leaf_statuses_exactly() {
1146        let nodes = [
1147            TaskTreeNode {
1148                index_path: "1".to_string(),
1149                description: "Pending".to_string(),
1150                status: TaskTrackingStatus::Pending,
1151                metadata: TaskStepMetadata::default(),
1152                children: Vec::new(),
1153            },
1154            TaskTreeNode {
1155                index_path: "2".to_string(),
1156                description: "In progress".to_string(),
1157                status: TaskTrackingStatus::InProgress,
1158                metadata: TaskStepMetadata::default(),
1159                children: Vec::new(),
1160            },
1161            TaskTreeNode {
1162                index_path: "3".to_string(),
1163                description: "Completed".to_string(),
1164                status: TaskTrackingStatus::Completed,
1165                metadata: TaskStepMetadata::default(),
1166                children: Vec::new(),
1167            },
1168            TaskTreeNode {
1169                index_path: "4".to_string(),
1170                description: "Blocked".to_string(),
1171                status: TaskTrackingStatus::Blocked,
1172                metadata: TaskStepMetadata::default(),
1173                children: Vec::new(),
1174            },
1175        ];
1176
1177        let displays = compact_task_tree_view(&nodes)
1178            .into_iter()
1179            .filter_map(|line| line.get("display").and_then(Value::as_str).map(ToOwned::to_owned))
1180            .collect::<Vec<_>>();
1181        assert_eq!(
1182            displays,
1183            vec![
1184                "  ├ □ Pending".to_string(),
1185                "  ├ [-] In progress".to_string(),
1186                "  ├ [x] Completed".to_string(),
1187                "  └ [!] Blocked".to_string(),
1188            ]
1189        );
1190    }
1191
1192    #[test]
1193    fn compact_task_tree_view_omits_parent_status_but_keeps_metadata_fields() {
1194        let nodes = [TaskTreeNode {
1195            index_path: "1".to_string(),
1196            description: "Parent".to_string(),
1197            status: TaskTrackingStatus::InProgress,
1198            metadata: TaskStepMetadata {
1199                files: vec!["Cargo.toml".to_string()],
1200                outcome: Some("Ready".to_string()),
1201                verify: vec!["cargo check".to_string()],
1202            },
1203            children: vec![TaskTreeNode {
1204                index_path: "1.1".to_string(),
1205                description: "Child".to_string(),
1206                status: TaskTrackingStatus::Completed,
1207                metadata: TaskStepMetadata::default(),
1208                children: Vec::new(),
1209            }],
1210        }];
1211
1212        let lines = compact_task_tree_view(&nodes);
1213        assert_eq!(lines[0]["display"], "  └ Parent");
1214        assert_eq!(lines[1]["display"], "    [x] Child");
1215        assert_eq!(lines[0]["files"], json!(["Cargo.toml"]));
1216        assert_eq!(lines[0]["outcome"], "Ready");
1217        assert_eq!(lines[0]["verify"], json!(["cargo check"]));
1218    }
1219
1220    #[test]
1221    fn split_task_description_metadata_strips_files_and_verify_suffix() {
1222        let (clean, files, verify) =
1223            split_task_description_metadata("Emit summary -> files: [src/a.rs, src/b.rs] -> verify: [cargo check]");
1224        assert_eq!(clean, "Emit summary");
1225        assert_eq!(files, vec!["src/a.rs".to_string(), "src/b.rs".to_string()]);
1226        assert_eq!(verify, vec!["cargo check".to_string()]);
1227    }
1228
1229    #[test]
1230    fn split_task_description_metadata_keeps_inline_code_arrows_intact() {
1231        let (clean, files, verify) =
1232            split_task_description_metadata("Use `a -> files: b` in code -> files: [src/a.rs]");
1233        assert_eq!(clean, "Use `a -> files: b` in code");
1234        assert_eq!(files, vec!["src/a.rs".to_string()]);
1235        assert!(verify.is_empty());
1236    }
1237
1238    #[test]
1239    fn split_task_description_metadata_without_markers_returns_trimmed_description() {
1240        let (clean, files, verify) = split_task_description_metadata("  Just an action  ");
1241        assert_eq!(clean, "Just an action");
1242        assert!(files.is_empty());
1243        assert!(verify.is_empty());
1244    }
1245
1246    #[test]
1247    fn split_task_description_metadata_preserves_quoted_commas_in_files_and_verify() {
1248        let (clean, files, verify) = split_task_description_metadata(
1249            "Inspect range -> files: ['src/a.rs, b.rs', src/c.rs] -> verify: [sed -n '/^## A/,/^## B/p']",
1250        );
1251        assert_eq!(clean, "Inspect range");
1252        assert_eq!(files, vec!["'src/a.rs, b.rs'".to_string(), "src/c.rs".to_string()]);
1253        assert_eq!(verify, vec!["sed -n '/^## A/,/^## B/p'".to_string()]);
1254    }
1255
1256    #[test]
1257    fn compact_task_tree_view_from_items_strips_inline_metadata_into_fields() {
1258        let items = vec![json!({
1259            "index_path": "1",
1260            "description": "Update parser -> files: [src/a.rs] -> verify: [cargo check]",
1261            "status": "pending",
1262        })];
1263        let rows = compact_task_tree_view_from_items(&items);
1264        assert_eq!(rows.len(), 1);
1265        assert_eq!(rows[0]["display"], "  └ □ Update parser");
1266        assert_eq!(rows[0]["text"], "Update parser");
1267        assert_eq!(rows[0]["files"], json!(["src/a.rs"]));
1268        assert_eq!(rows[0]["verify"], json!(["cargo check"]));
1269    }
1270
1271    #[test]
1272    fn short_task_description_keeps_leading_clause_only() {
1273        assert_eq!(
1274            short_task_description(
1275                "Add vtcode exec resume to the Commands section – document the cross-turn contract, sourced from ExecSubcommand::Resume"
1276            ),
1277            "Add vtcode exec resume to the Commands section"
1278        );
1279        assert_eq!(
1280            short_task_description("Update the Everyday recipes block — add a headless example"),
1281            "Update the Everyday recipes block"
1282        );
1283        assert_eq!(
1284            short_task_description("Emit summary -> files: [src/a.rs] -> verify: [cargo check]"),
1285            "Emit summary"
1286        );
1287        assert_eq!(short_task_description("Verify with cargo check"), "Verify with cargo check");
1288        assert_eq!(short_task_description(""), "");
1289    }
1290
1291    #[test]
1292    fn compact_task_tree_view_from_items_shortens_detail_tail() {
1293        let items = vec![json!({
1294            "index_path": "1",
1295            "description": "Add vtcode exec resume to the Commands section – document the cross-turn contract in the command table",
1296            "status": "pending",
1297        })];
1298        let rows = compact_task_tree_view_from_items(&items);
1299        assert_eq!(rows.len(), 1);
1300        assert_eq!(rows[0]["display"], "  └ □ Add vtcode exec resume to the Commands section");
1301        assert_eq!(rows[0]["text"], "Add vtcode exec resume to the Commands section");
1302    }
1303
1304    #[test]
1305    fn shared_schema_helpers_stay_actionable_and_drift_free() {
1306        assert_eq!(TASK_STATUS_VALUES, ["pending", "in_progress", "completed", "blocked"]);
1307
1308        let status = task_status_schema();
1309        assert_eq!(status["type"], "string");
1310        assert_eq!(status["enum"], json!(TASK_STATUS_VALUES));
1311        assert!(status.get("description").is_none());
1312
1313        let with_desc = task_status_property_schema("New status.");
1314        assert_eq!(with_desc["description"], "New status.");
1315        assert_eq!(with_desc["enum"], json!(TASK_STATUS_VALUES));
1316
1317        // Asymmetric: plain string item vs structured item with metadata.
1318        let items_schema = task_items_array_schema("Items desc.");
1319        let validator = jsonschema::validator_for(&items_schema).expect("items schema is valid");
1320        assert!(validator.validate(&json!(["plain step"])).is_ok());
1321        assert!(
1322            validator
1323                .validate(&json!([{
1324                    "description": "Structured step",
1325                    "status": "in_progress",
1326                    "files": ["src/a.rs"],
1327                    "outcome": "Done",
1328                    "verify": ["cargo check"],
1329                }]))
1330                .is_ok()
1331        );
1332        // Boundary: verify also accepts a bare string; missing description fails.
1333        assert!(validator.validate(&json!([{"status": "completed"}])).is_err());
1334        assert!(validator.validate(&json!([{"description": "Bad", "status": "done"}])).is_err());
1335
1336        assert_eq!(task_files_property_schema("F.")["description"], "F.");
1337        assert_eq!(task_verify_property_schema("V.")["description"], "V.");
1338    }
1339
1340    #[test]
1341    fn shared_status_values_match_runtime_parser() {
1342        use std::str::FromStr;
1343        for status in TASK_STATUS_VALUES {
1344            assert!(TaskTrackingStatus::from_str(status).is_ok(), "schema status {status:?} must parse");
1345        }
1346        assert!(TaskTrackingStatus::from_str("done").is_err());
1347    }
1348
1349    /// Drift guard: `TaskTrackingStatus` reaches the wire through three
1350    /// independent paths — the derived serde `snake_case` form, `as_str()`, and
1351    /// the hand-written `FromStr`. Nothing in the type system ties them
1352    /// together, so this locks the as_str <-> serde <-> FromStr triangle for
1353    /// every variant and pins the schema enum list to the same source. A
1354    /// one-sided edit to any path fails here instead of silently producing a
1355    /// value the runtime cannot parse back.
1356    #[test]
1357    fn task_status_as_str_serde_and_from_str_stay_in_lockstep() {
1358        use std::str::FromStr;
1359        let variants = [
1360            TaskTrackingStatus::Pending,
1361            TaskTrackingStatus::InProgress,
1362            TaskTrackingStatus::Completed,
1363            TaskTrackingStatus::Blocked,
1364        ];
1365        let strings: Vec<&str> = variants.iter().map(TaskTrackingStatus::as_str).collect();
1366        assert_eq!(strings, TASK_STATUS_VALUES);
1367
1368        for variant in variants {
1369            let expected = variant.as_str();
1370            assert_eq!(
1371                serde_json::to_value(variant).expect("serializes"),
1372                json!(expected),
1373                "serde form drifted for {variant:?}"
1374            );
1375            assert_eq!(
1376                TaskTrackingStatus::from_str(expected).expect("as_str output must parse back"),
1377                variant,
1378                "FromStr drift for {variant:?}"
1379            );
1380        }
1381    }
1382
1383    /// Cross-crate drift guard. `vtcode-commons::ui_protocol::TaskItemStatus`
1384    /// (the TUI-facing row status) is a distinct type from
1385    /// `TaskTrackingStatus` but must carry the *same* canonical strings — its
1386    /// doc comment promises they match, and the TUI parses runtime strings
1387    /// through it (`raw.parse::<TaskItemStatus>()`). The type system does not
1388    /// connect the two enums, so pin them token-for-token here.
1389    #[test]
1390    fn task_item_status_matches_tracking_status_strings() {
1391        use std::str::FromStr;
1392        use vtcode_commons::ui_protocol::TaskItemStatus;
1393
1394        for variant in [
1395            TaskTrackingStatus::Pending,
1396            TaskTrackingStatus::InProgress,
1397            TaskTrackingStatus::Completed,
1398            TaskTrackingStatus::Blocked,
1399        ] {
1400            let canonical = variant.as_str();
1401            let display = TaskItemStatus::from_str(canonical)
1402                .unwrap_or_else(|_| panic!("TaskItemStatus must parse runtime status {canonical:?}"));
1403            assert_eq!(display.as_str(), canonical, "TaskItemStatus/TaskTrackingStatus string drift for {variant:?}");
1404        }
1405    }
1406}