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
211pub(crate) const TASK_STATUS_VALUES: [&str; 4] = ["pending", "in_progress", "completed", "blocked"];
215
216pub(crate) fn task_status_schema() -> Value {
218 json!({
219 "type": "string",
220 "enum": TASK_STATUS_VALUES
221 })
222}
223
224pub(crate) fn task_files_schema() -> Value {
226 json!({
227 "type": "array",
228 "items": { "type": "string" }
229 })
230}
231
232pub(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
252pub(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
268pub(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
284pub(crate) fn task_status_property_schema(description: &str) -> Value {
286 with_description(task_status_schema(), description)
287}
288
289pub(crate) fn task_files_property_schema(description: &str) -> Value {
291 with_description(task_files_schema(), description)
292}
293
294pub(crate) fn task_verify_property_schema(description: &str) -> Value {
296 with_description(task_verify_schema(), description)
297}
298
299pub(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
408const 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
427pub 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
473pub fn strip_task_description_metadata(description: &str) -> String {
477 split_task_description_metadata(description).0
478}
479
480pub 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#[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
551pub(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
594pub 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 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 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 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}