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
55pub(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
319pub(crate) const TASK_STATUS_VALUES: [&str; 4] = ["pending", "in_progress", "completed", "blocked"];
323
324pub(crate) fn task_status_schema() -> Value {
326 json!({
327 "type": "string",
328 "enum": TASK_STATUS_VALUES
329 })
330}
331
332pub(crate) fn task_files_schema() -> Value {
334 json!({
335 "type": "array",
336 "items": { "type": "string" }
337 })
338}
339
340pub(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
360pub(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
462pub(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
478pub(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
494pub(crate) fn task_status_property_schema(description: &str) -> Value {
496 with_description(task_status_schema(), description)
497}
498
499pub(crate) fn task_files_property_schema(description: &str) -> Value {
501 with_description(task_files_schema(), description)
502}
503
504pub(crate) fn task_verify_property_schema(description: &str) -> Value {
506 with_description(task_verify_schema(), description)
507}
508
509pub(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
574pub 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
584pub 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
667const 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
686pub 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
732pub fn strip_task_description_metadata(description: &str) -> String {
736 split_task_description_metadata(description).0
737}
738
739pub 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#[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
810pub(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
853pub 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 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 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 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 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 assert!(!apply_metadata_line(&mut metadata, " other: x", &mut in_verify_block));
1038 assert!(!in_verify_block);
1039
1040 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 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 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 #[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 #[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}