1use ito_templates::ITO_END_MARKER;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::env;
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use ito_common::fs::StdFs;
18use ito_common::paths;
19use ito_config::ConfigContext;
20
21#[derive(Debug, thiserror::Error)]
22pub enum WorkflowError {
24 #[error("Invalid change name")]
25 InvalidChangeName,
27
28 #[error("Missing required option --change")]
29 MissingChange,
31
32 #[error("Change '{0}' not found")]
33 ChangeNotFound(String),
35
36 #[error("Schema '{0}' not found")]
37 SchemaNotFound(String),
39
40 #[error("Artifact '{0}' not found")]
41 ArtifactNotFound(String),
43
44 #[error(transparent)]
45 Io(#[from] std::io::Error),
47
48 #[error(transparent)]
49 Yaml(#[from] serde_yaml::Error),
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct ArtifactStatus {
56 pub id: String,
58 #[serde(rename = "outputPath")]
59 pub output_path: String,
61
62 pub status: String,
64 #[serde(rename = "missingDeps", skip_serializing_if = "Vec::is_empty")]
65 pub missing_deps: Vec<String>,
67}
68
69#[derive(Debug, Clone, Serialize)]
70pub struct ChangeStatus {
72 #[serde(rename = "changeName")]
73 pub change_name: String,
75 #[serde(rename = "schemaName")]
76 pub schema_name: String,
78 #[serde(rename = "isComplete")]
79 pub is_complete: bool,
81 #[serde(rename = "applyRequires")]
82 pub apply_requires: Vec<String>,
84
85 pub artifacts: Vec<ArtifactStatus>,
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct TemplateInfo {
92 pub source: String,
94 pub path: String,
96}
97
98#[derive(Debug, Clone, Serialize)]
99pub struct DependencyInfo {
101 pub id: String,
103 pub done: bool,
105 pub path: String,
107 pub description: String,
109}
110
111#[derive(Debug, Clone, Serialize)]
112pub struct InstructionsResponse {
114 #[serde(rename = "changeName")]
115 pub change_name: String,
117 #[serde(rename = "artifactId")]
118 pub artifact_id: String,
120 #[serde(rename = "schemaName")]
121 pub schema_name: String,
123 #[serde(rename = "changeDir")]
124 pub change_dir: String,
126 #[serde(rename = "outputPath")]
127 pub output_path: String,
129
130 pub description: String,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub instruction: Option<String>,
135
136 pub template: String,
138
139 pub dependencies: Vec<DependencyInfo>,
141
142 pub unlocks: Vec<String>,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct TaskItem {
149 pub id: String,
151 pub description: String,
153 pub done: bool,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 pub status: Option<String>,
158}
159
160#[derive(Debug, Clone, Serialize)]
161pub struct ProgressInfo {
163 pub total: usize,
165 pub complete: usize,
167 pub remaining: usize,
169 #[serde(rename = "inProgress", skip_serializing_if = "Option::is_none")]
170 pub in_progress: Option<usize>,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub pending: Option<usize>,
175}
176
177#[derive(Debug, Clone, Serialize)]
178pub struct TaskDiagnostic {
180 pub level: String,
182 pub message: String,
184 #[serde(rename = "taskId", skip_serializing_if = "Option::is_none")]
185 pub task_id: Option<String>,
187}
188
189#[derive(Debug, Clone, Serialize)]
190pub struct ApplyInstructionsResponse {
192 #[serde(rename = "changeName")]
193 pub change_name: String,
195 #[serde(rename = "changeDir")]
196 pub change_dir: String,
198 #[serde(rename = "schemaName")]
199 pub schema_name: String,
201 #[serde(rename = "tracksPath")]
202 pub tracks_path: Option<String>,
204 #[serde(rename = "tracksFile")]
205 pub tracks_file: Option<String>,
207 #[serde(rename = "tracksFormat")]
208 pub tracks_format: Option<String>,
210 #[serde(rename = "tracksDiagnostics", skip_serializing_if = "Option::is_none")]
211 pub tracks_diagnostics: Option<Vec<TaskDiagnostic>>,
213
214 pub state: String,
216 #[serde(rename = "contextFiles")]
217 pub context_files: BTreeMap<String, String>,
219
220 pub progress: ProgressInfo,
222
223 pub tasks: Vec<TaskItem>,
225 #[serde(rename = "missingArtifacts", skip_serializing_if = "Option::is_none")]
226 pub missing_artifacts: Option<Vec<String>>,
228
229 pub instruction: String,
231}
232
233#[derive(Debug, Clone, Serialize)]
234pub struct AgentInstructionResponse {
236 #[serde(rename = "artifactId")]
237 pub artifact_id: String,
239
240 pub instruction: String,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum SchemaSource {
247 Package,
249 User,
251}
252
253impl SchemaSource {
254 pub fn as_str(self) -> &'static str {
256 match self {
257 SchemaSource::Package => "package",
258 SchemaSource::User => "user",
259 }
260 }
261}
262
263#[derive(Debug, Clone)]
264pub struct ResolvedSchema {
266 pub schema: SchemaYaml,
268 pub schema_dir: PathBuf,
270 pub source: SchemaSource,
272}
273
274#[derive(Debug, Clone, Deserialize)]
275pub struct SchemaYaml {
277 pub name: String,
279 #[serde(default)]
280 pub version: Option<u32>,
282 #[serde(default)]
283 pub description: Option<String>,
285
286 pub artifacts: Vec<ArtifactYaml>,
288 #[serde(default)]
289 pub apply: Option<ApplyYaml>,
291}
292
293#[derive(Debug, Clone, Deserialize)]
294pub struct ArtifactYaml {
296 pub id: String,
298 pub generates: String,
300 #[serde(default)]
301 pub description: Option<String>,
303 pub template: String,
305 #[serde(default)]
306 pub instruction: Option<String>,
308 #[serde(default)]
309 pub requires: Vec<String>,
311}
312
313#[derive(Debug, Clone, Deserialize)]
314pub struct ApplyYaml {
316 #[serde(default)]
317 pub requires: Option<Vec<String>>,
319 #[serde(default)]
320 pub tracks: Option<String>,
322 #[serde(default)]
323 pub instruction: Option<String>,
325}
326
327pub fn default_schema_name() -> &'static str {
329 "spec-driven"
330}
331
332pub fn validate_change_name_input(name: &str) -> bool {
334 if name.is_empty() {
335 return false;
336 }
337 if name.starts_with('/') || name.starts_with('\\') {
338 return false;
339 }
340 if name.contains('/') || name.contains('\\') {
341 return false;
342 }
343 if name.contains("..") {
344 return false;
345 }
346 true
347}
348
349pub fn read_change_schema(ito_path: &Path, change: &str) -> String {
351 let meta = paths::change_meta_path(ito_path, change);
352 if let Ok(Some(s)) = ito_common::io::read_to_string_optional(&meta) {
353 for line in s.lines() {
354 let l = line.trim();
355 if let Some(rest) = l.strip_prefix("schema:") {
356 let v = rest.trim();
357 if !v.is_empty() {
358 return v.to_string();
359 }
360 }
361 }
362 }
363 default_schema_name().to_string()
364}
365
366pub fn list_available_changes(ito_path: &Path) -> Vec<String> {
368 let fs = StdFs;
369 ito_domain::discovery::list_change_dir_names(&fs, ito_path).unwrap_or_default()
370}
371
372pub fn list_available_schemas(ctx: &ConfigContext) -> Vec<String> {
374 let mut set: BTreeSet<String> = BTreeSet::new();
375 let fs = StdFs;
376 for dir in [Some(package_schemas_dir()), user_schemas_dir(ctx)] {
377 let Some(dir) = dir else { continue };
378 let Ok(names) = ito_domain::discovery::list_dir_names(&fs, &dir) else {
379 continue;
380 };
381 for name in names {
382 let schema_dir = dir.join(&name);
383 if schema_dir.join("schema.yaml").exists() {
384 set.insert(name);
385 }
386 }
387 }
388 set.into_iter().collect()
389}
390
391pub fn resolve_schema(
395 schema_name: Option<&str>,
396 ctx: &ConfigContext,
397) -> Result<ResolvedSchema, WorkflowError> {
398 let name = schema_name.unwrap_or(default_schema_name());
399 let user_dir = user_schemas_dir(ctx).map(|d| d.join(name));
400 if let Some(d) = user_dir
401 && d.join("schema.yaml").exists()
402 {
403 let schema = load_schema_yaml(&d)?;
404 return Ok(ResolvedSchema {
405 schema,
406 schema_dir: d,
407 source: SchemaSource::User,
408 });
409 }
410
411 let pkg = package_schemas_dir().join(name);
412 if pkg.join("schema.yaml").exists() {
413 let schema = load_schema_yaml(&pkg)?;
414 return Ok(ResolvedSchema {
415 schema,
416 schema_dir: pkg,
417 source: SchemaSource::Package,
418 });
419 }
420
421 Err(WorkflowError::SchemaNotFound(name.to_string()))
422}
423
424pub fn compute_change_status(
426 ito_path: &Path,
427 change: &str,
428 schema_name: Option<&str>,
429 ctx: &ConfigContext,
430) -> Result<ChangeStatus, WorkflowError> {
431 if !validate_change_name_input(change) {
432 return Err(WorkflowError::InvalidChangeName);
433 }
434 let schema_name = schema_name
435 .map(|s| s.to_string())
436 .unwrap_or_else(|| read_change_schema(ito_path, change));
437 let resolved = resolve_schema(Some(&schema_name), ctx)?;
438
439 let change_dir = paths::change_dir(ito_path, change);
440 if !change_dir.exists() {
441 return Err(WorkflowError::ChangeNotFound(change.to_string()));
442 }
443
444 let mut artifacts_out: Vec<ArtifactStatus> = Vec::new();
445 let mut done_count: usize = 0;
446 let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
447
448 let order = build_order(&resolved.schema);
449 for id in order {
450 let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
451 continue;
452 };
453 let done = *done_by_id.get(&a.id).unwrap_or(&false);
454 let mut missing: Vec<String> = Vec::new();
455 if !done {
456 for r in &a.requires {
457 if !*done_by_id.get(r).unwrap_or(&false) {
458 missing.push(r.clone());
459 }
460 }
461 }
462
463 let status = if done {
464 done_count += 1;
465 "done".to_string()
466 } else if missing.is_empty() {
467 "ready".to_string()
468 } else {
469 "blocked".to_string()
470 };
471 artifacts_out.push(ArtifactStatus {
472 id: a.id.clone(),
473 output_path: a.generates.clone(),
474 status,
475 missing_deps: missing,
476 });
477 }
478
479 let all_artifact_ids: Vec<String> = resolved
480 .schema
481 .artifacts
482 .iter()
483 .map(|a| a.id.clone())
484 .collect();
485 let apply_requires: Vec<String> = match resolved.schema.apply.as_ref() {
486 Some(apply) => apply
487 .requires
488 .clone()
489 .unwrap_or_else(|| all_artifact_ids.clone()),
490 None => all_artifact_ids.clone(),
491 };
492
493 let is_complete = done_count == resolved.schema.artifacts.len();
494 Ok(ChangeStatus {
495 change_name: change.to_string(),
496 schema_name: resolved.schema.name,
497 is_complete,
498 apply_requires,
499 artifacts: artifacts_out,
500 })
501}
502
503fn build_order(schema: &SchemaYaml) -> Vec<String> {
504 let mut in_degree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
507 let mut dependents: std::collections::HashMap<String, Vec<String>> =
508 std::collections::HashMap::new();
509
510 for a in &schema.artifacts {
511 in_degree.insert(a.id.clone(), a.requires.len());
512 dependents.insert(a.id.clone(), Vec::new());
513 }
514 for a in &schema.artifacts {
515 for req in &a.requires {
516 dependents
517 .entry(req.clone())
518 .or_default()
519 .push(a.id.clone());
520 }
521 }
522
523 let mut queue: Vec<String> = schema
524 .artifacts
525 .iter()
526 .map(|a| a.id.clone())
527 .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
528 .collect();
529 queue.sort();
530
531 let mut result: Vec<String> = Vec::new();
532 while !queue.is_empty() {
533 let current = queue.remove(0);
534 result.push(current.clone());
535
536 let mut newly_ready: Vec<String> = Vec::new();
537 if let Some(deps) = dependents.get(¤t) {
538 for dep in deps {
539 let new_degree = in_degree.get(dep).copied().unwrap_or(0).saturating_sub(1);
540 in_degree.insert(dep.clone(), new_degree);
541 if new_degree == 0 {
542 newly_ready.push(dep.clone());
543 }
544 }
545 }
546 newly_ready.sort();
547 queue.extend(newly_ready);
548 }
549
550 result
551}
552
553pub fn resolve_templates(
555 schema_name: Option<&str>,
556 ctx: &ConfigContext,
557) -> Result<(String, BTreeMap<String, TemplateInfo>), WorkflowError> {
558 let resolved = resolve_schema(schema_name, ctx)?;
559 let templates_dir = resolved.schema_dir.join("templates");
560
561 let mut templates: BTreeMap<String, TemplateInfo> = BTreeMap::new();
562 for a in &resolved.schema.artifacts {
563 templates.insert(
564 a.id.clone(),
565 TemplateInfo {
566 source: resolved.source.as_str().to_string(),
567 path: templates_dir
568 .join(&a.template)
569 .to_string_lossy()
570 .to_string(),
571 },
572 );
573 }
574 Ok((resolved.schema.name, templates))
575}
576
577pub fn resolve_instructions(
579 ito_path: &Path,
580 change: &str,
581 schema_name: Option<&str>,
582 artifact_id: &str,
583 ctx: &ConfigContext,
584) -> Result<InstructionsResponse, WorkflowError> {
585 if !validate_change_name_input(change) {
586 return Err(WorkflowError::InvalidChangeName);
587 }
588 let schema_name = schema_name
589 .map(|s| s.to_string())
590 .unwrap_or_else(|| read_change_schema(ito_path, change));
591 let resolved = resolve_schema(Some(&schema_name), ctx)?;
592
593 let change_dir = paths::change_dir(ito_path, change);
594 if !change_dir.exists() {
595 return Err(WorkflowError::ChangeNotFound(change.to_string()));
596 }
597
598 let a = resolved
599 .schema
600 .artifacts
601 .iter()
602 .find(|a| a.id == artifact_id)
603 .ok_or_else(|| WorkflowError::ArtifactNotFound(artifact_id.to_string()))?;
604
605 let templates_dir = resolved.schema_dir.join("templates");
606 let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
607
608 let deps: Vec<DependencyInfo> = a
609 .requires
610 .iter()
611 .map(|id| {
612 let dep = resolved.schema.artifacts.iter().find(|d| d.id == *id);
613 DependencyInfo {
614 id: id.clone(),
615 done: *done_by_id.get(id).unwrap_or(&false),
616 path: dep
617 .map(|d| d.generates.clone())
618 .unwrap_or_else(|| id.clone()),
619 description: dep.and_then(|d| d.description.clone()).unwrap_or_default(),
620 }
621 })
622 .collect();
623
624 let mut unlocks: Vec<String> = resolved
625 .schema
626 .artifacts
627 .iter()
628 .filter(|other| other.requires.iter().any(|r| r == artifact_id))
629 .map(|a| a.id.clone())
630 .collect();
631 unlocks.sort();
632
633 let template = ito_common::io::read_to_string_std(&templates_dir.join(&a.template))?;
634
635 Ok(InstructionsResponse {
636 change_name: change.to_string(),
637 artifact_id: a.id.clone(),
638 schema_name: resolved.schema.name,
639 change_dir: change_dir.to_string_lossy().to_string(),
640 output_path: a.generates.clone(),
641 description: a.description.clone().unwrap_or_default(),
642 instruction: a.instruction.clone(),
643 template,
644 dependencies: deps,
645 unlocks,
646 })
647}
648
649pub fn compute_apply_instructions(
651 ito_path: &Path,
652 change: &str,
653 schema_name: Option<&str>,
654 ctx: &ConfigContext,
655) -> Result<ApplyInstructionsResponse, WorkflowError> {
656 if !validate_change_name_input(change) {
657 return Err(WorkflowError::InvalidChangeName);
658 }
659 let schema_name = schema_name
660 .map(|s| s.to_string())
661 .unwrap_or_else(|| read_change_schema(ito_path, change));
662 let resolved = resolve_schema(Some(&schema_name), ctx)?;
663 let change_dir = paths::change_dir(ito_path, change);
664 if !change_dir.exists() {
665 return Err(WorkflowError::ChangeNotFound(change.to_string()));
666 }
667
668 let schema = &resolved.schema;
669 let apply = schema.apply.as_ref();
670 let all_artifact_ids: Vec<String> = schema.artifacts.iter().map(|a| a.id.clone()).collect();
671
672 let required_artifact_ids: Vec<String> = apply
675 .and_then(|a| a.requires.clone())
676 .unwrap_or_else(|| all_artifact_ids.clone());
677 let tracks_file: Option<String> = apply.and_then(|a| a.tracks.clone());
678 let schema_instruction: Option<String> = apply.and_then(|a| a.instruction.clone());
679
680 let mut missing_artifacts: Vec<String> = Vec::new();
682 for artifact_id in &required_artifact_ids {
683 let Some(artifact) = schema.artifacts.iter().find(|a| a.id == *artifact_id) else {
684 continue;
685 };
686 if !artifact_done(&change_dir, &artifact.generates) {
687 missing_artifacts.push(artifact_id.clone());
688 }
689 }
690
691 let mut context_files: BTreeMap<String, String> = BTreeMap::new();
693 for artifact in &schema.artifacts {
694 if artifact_done(&change_dir, &artifact.generates) {
695 context_files.insert(
696 artifact.id.clone(),
697 change_dir
698 .join(&artifact.generates)
699 .to_string_lossy()
700 .to_string(),
701 );
702 }
703 }
704
705 let mut tasks: Vec<TaskItem> = Vec::new();
707 let mut tracks_file_exists = false;
708 let mut tracks_path: Option<String> = None;
709 let mut tracks_format: Option<String> = None;
710 let tracks_diagnostics: Option<Vec<TaskDiagnostic>> = None;
711
712 if let Some(tf) = &tracks_file {
713 let p = change_dir.join(tf);
714 tracks_path = Some(p.to_string_lossy().to_string());
715 tracks_file_exists = p.exists();
716 if tracks_file_exists {
717 let content = ito_common::io::read_to_string_std(&p)?;
718 let checkbox = parse_checkbox_tasks(&content);
719 if !checkbox.is_empty() {
720 tracks_format = Some("checkbox".to_string());
721 tasks = checkbox;
722 } else {
723 let enhanced = parse_enhanced_tasks(&content);
724 if !enhanced.is_empty() {
725 tracks_format = Some("enhanced".to_string());
726 tasks = enhanced;
727 } else if looks_like_enhanced_tasks(&content) {
728 tracks_format = Some("enhanced".to_string());
729 } else {
730 tracks_format = Some("unknown".to_string());
731 }
732 }
733 }
734 }
735
736 let total = tasks.len();
738 let complete = tasks.iter().filter(|t| t.done).count();
739 let remaining = total.saturating_sub(complete);
740 let mut in_progress: Option<usize> = None;
741 let mut pending: Option<usize> = None;
742 if tracks_format.as_deref() == Some("enhanced") {
743 let mut in_progress_count = 0;
744 let mut pending_count = 0;
745 for task in &tasks {
746 let Some(status) = task.status.as_deref() else {
747 continue;
748 };
749 let status = status.trim();
750 match status {
751 "in-progress" | "in_progress" | "in progress" => in_progress_count += 1,
752 "pending" => pending_count += 1,
753 _ => {}
754 }
755 }
756 in_progress = Some(in_progress_count);
757 pending = Some(pending_count);
758 }
759 if tracks_format.as_deref() == Some("checkbox") {
760 let mut in_progress_count = 0;
761 for task in &tasks {
762 let Some(status) = task.status.as_deref() else {
763 continue;
764 };
765 if status.trim() == "in-progress" {
766 in_progress_count += 1;
767 }
768 }
769 in_progress = Some(in_progress_count);
770 pending = Some(total.saturating_sub(complete + in_progress_count));
771 }
772 let progress = ProgressInfo {
773 total,
774 complete,
775 remaining,
776 in_progress,
777 pending,
778 };
779
780 let (state, instruction) = if !missing_artifacts.is_empty() {
782 (
783 "blocked".to_string(),
784 format!(
785 "Cannot apply this change yet. Missing artifacts: {}.\nUse the ito-continue-change skill to create the missing artifacts first.",
786 missing_artifacts.join(", ")
787 ),
788 )
789 } else if tracks_file.is_some() && !tracks_file_exists {
790 let tracks_filename = tracks_file
791 .as_deref()
792 .and_then(|p| Path::new(p).file_name())
793 .map(|s| s.to_string_lossy().to_string())
794 .unwrap_or_else(|| "tasks.md".to_string());
795 (
796 "blocked".to_string(),
797 format!(
798 "The {tracks_filename} file is missing and must be created.\nUse ito-continue-change to generate the tracking file."
799 ),
800 )
801 } else if tracks_file.is_some() && tracks_file_exists && total == 0 {
802 let tracks_filename = tracks_file
803 .as_deref()
804 .and_then(|p| Path::new(p).file_name())
805 .map(|s| s.to_string_lossy().to_string())
806 .unwrap_or_else(|| "tasks.md".to_string());
807 (
808 "blocked".to_string(),
809 format!(
810 "The {tracks_filename} file exists but contains no tasks.\nAdd tasks to {tracks_filename} or regenerate it with ito-continue-change."
811 ),
812 )
813 } else if tracks_file.is_some() && remaining == 0 && total > 0 {
814 (
815 "all_done".to_string(),
816 "All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving."
817 .to_string(),
818 )
819 } else if tracks_file.is_none() {
820 (
821 "ready".to_string(),
822 schema_instruction
823 .as_deref()
824 .map(|s| s.trim().to_string())
825 .unwrap_or_else(|| {
826 "All required artifacts complete. Proceed with implementation.".to_string()
827 }),
828 )
829 } else {
830 (
831 "ready".to_string(),
832 schema_instruction
833 .as_deref()
834 .map(|s| s.trim().to_string())
835 .unwrap_or_else(|| {
836 "Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.".to_string()
837 }),
838 )
839 };
840
841 Ok(ApplyInstructionsResponse {
842 change_name: change.to_string(),
843 change_dir: change_dir.to_string_lossy().to_string(),
844 schema_name: schema.name.clone(),
845 tracks_path,
846 tracks_file,
847 tracks_format,
848 tracks_diagnostics,
849 context_files,
850 progress,
851 tasks,
852 state,
853 missing_artifacts: if missing_artifacts.is_empty() {
854 None
855 } else {
856 Some(missing_artifacts)
857 },
858 instruction,
859 })
860}
861
862fn parse_checkbox_tasks(contents: &str) -> Vec<TaskItem> {
863 let mut tasks: Vec<TaskItem> = Vec::new();
864 for line in contents.lines() {
865 let l = line.trim_start();
866 let bytes = l.as_bytes();
867 if bytes.len() < 6 {
868 continue;
869 }
870 let bullet = bytes[0] as char;
871 if bullet != '-' && bullet != '*' {
872 continue;
873 }
874 if bytes[1] != b' ' || bytes[2] != b'[' || bytes[4] != b']' || bytes[5] != b' ' {
875 continue;
876 }
877
878 let marker = bytes[3] as char;
879 let (done, rest, status) = match marker {
880 'x' | 'X' => (true, &l[6..], None),
881 ' ' => (false, &l[6..], None),
882 '~' | '>' => (false, &l[6..], Some("in-progress".to_string())),
883 _ => continue,
884 };
885 tasks.push(TaskItem {
886 id: (tasks.len() + 1).to_string(),
887 description: rest.trim().to_string(),
888 done,
889 status,
890 });
891 }
892 tasks
893}
894
895fn looks_like_enhanced_tasks(contents: &str) -> bool {
896 for line in contents.lines() {
897 let l = line.trim_start();
898 if l.starts_with("### Task ") {
899 return true;
900 }
901 }
902 false
903}
904
905fn parse_enhanced_tasks(contents: &str) -> Vec<TaskItem> {
906 let mut tasks: Vec<TaskItem> = Vec::new();
907 let mut current_id: Option<String> = None;
908 let mut current_desc: Option<String> = None;
909 let mut current_done = false;
910 let mut current_status: Option<String> = None;
911
912 fn push_current(
913 tasks: &mut Vec<TaskItem>,
914 current_id: &mut Option<String>,
915 current_desc: &mut Option<String>,
916 current_done: &mut bool,
917 current_status: &mut Option<String>,
918 ) {
919 let Some(desc) = current_desc.take() else {
920 current_id.take();
921 *current_done = false;
922 *current_status = None;
923 return;
924 };
925 let id = current_id
926 .take()
927 .filter(|s| !s.trim().is_empty())
928 .unwrap_or_else(|| (tasks.len() + 1).to_string());
929 tasks.push(TaskItem {
930 id,
931 description: desc,
932 done: *current_done,
933 status: current_status.take(),
934 });
935 *current_done = false;
936 }
937
938 for line in contents.lines() {
939 let l = line.trim_start();
940
941 if let Some(rest) = l.strip_prefix("### Task ") {
942 push_current(
943 &mut tasks,
944 &mut current_id,
945 &mut current_desc,
946 &mut current_done,
947 &mut current_status,
948 );
949
950 let (id, desc) = rest.split_once(':').unwrap_or((rest, ""));
951 let id = id.trim();
952 let desc = if desc.trim().is_empty() {
953 rest.trim()
954 } else {
955 desc.trim()
956 };
957
958 current_id = Some(id.to_string());
959 current_desc = Some(desc.to_string());
960 current_done = false;
961 current_status = Some("pending".to_string());
962 continue;
963 }
964
965 if let Some(rest) = l.strip_prefix("- **Status**:") {
966 let status = rest.trim();
967 if let Some(status) = status
968 .strip_prefix("[x]")
969 .or_else(|| status.strip_prefix("[X]"))
970 {
971 current_done = true;
972 current_status = Some(status.trim().to_string());
973 continue;
974 }
975 if let Some(status) = status.strip_prefix("[ ]") {
976 current_done = false;
977 current_status = Some(status.trim().to_string());
978 continue;
979 }
980 }
981 }
982
983 push_current(
984 &mut tasks,
985 &mut current_id,
986 &mut current_desc,
987 &mut current_done,
988 &mut current_status,
989 );
990
991 tasks
992}
993
994pub fn load_user_guidance(ito_path: &Path) -> Result<Option<String>, WorkflowError> {
999 let path = ito_path.join("user-guidance.md");
1000 if !path.exists() {
1001 return Ok(None);
1002 }
1003
1004 let content = ito_common::io::read_to_string_std(&path)?;
1005 let content = content.replace("\r\n", "\n");
1006 let content = match content.find(ITO_END_MARKER) {
1007 Some(i) => &content[i + ITO_END_MARKER.len()..],
1008 None => content.as_str(),
1009 };
1010 let content = content.trim();
1011 if content.is_empty() {
1012 return Ok(None);
1013 }
1014
1015 Ok(Some(content.to_string()))
1016}
1017
1018fn package_schemas_dir() -> PathBuf {
1019 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1021 let root = manifest_dir
1022 .ancestors()
1023 .nth(3)
1024 .unwrap_or(manifest_dir.as_path());
1025 root.join("schemas")
1026}
1027
1028fn user_schemas_dir(ctx: &ConfigContext) -> Option<PathBuf> {
1029 let data_home = match env::var("XDG_DATA_HOME") {
1030 Ok(v) if !v.trim().is_empty() => Some(PathBuf::from(v)),
1031 _ => ctx
1032 .home_dir
1033 .as_ref()
1034 .map(|h| h.join(".local").join("share")),
1035 }?;
1036 Some(data_home.join("ito").join("schemas"))
1037}
1038
1039fn load_schema_yaml(schema_dir: &Path) -> Result<SchemaYaml, WorkflowError> {
1040 let s = ito_common::io::read_to_string_std(&schema_dir.join("schema.yaml"))?;
1041 Ok(serde_yaml::from_str(&s)?)
1042}
1043
1044fn compute_done_by_id(change_dir: &Path, schema: &SchemaYaml) -> BTreeMap<String, bool> {
1045 let mut out = BTreeMap::new();
1046 for a in &schema.artifacts {
1047 out.insert(a.id.clone(), artifact_done(change_dir, &a.generates));
1048 }
1049 out
1050}
1051
1052fn artifact_done(change_dir: &Path, generates: &str) -> bool {
1053 if !generates.contains('*') {
1054 return change_dir.join(generates).exists();
1055 }
1056
1057 let (base, suffix) = match split_glob_pattern(generates) {
1062 Some(v) => v,
1063 None => return false,
1064 };
1065 let base_dir = change_dir.join(base);
1066 dir_contains_filename_suffix(&base_dir, &suffix)
1067}
1068
1069fn split_glob_pattern(pattern: &str) -> Option<(String, String)> {
1070 let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
1071
1072 let (dir_part, file_pat) = match pattern.rsplit_once('/') {
1073 Some((d, f)) => (d, f),
1074 None => ("", pattern),
1075 };
1076 if !file_pat.starts_with('*') {
1077 return None;
1078 }
1079 let suffix = file_pat[1..].to_string();
1080
1081 let base = dir_part
1082 .strip_suffix("/**")
1083 .or_else(|| dir_part.strip_suffix("**"))
1084 .unwrap_or(dir_part);
1085
1086 let base = if base.contains('*') { "" } else { base };
1088 Some((base.to_string(), suffix))
1089}
1090
1091fn dir_contains_filename_suffix(dir: &Path, suffix: &str) -> bool {
1092 let Ok(entries) = fs::read_dir(dir) else {
1093 return false;
1094 };
1095 for e in entries.flatten() {
1096 let path = e.path();
1097 if e.file_type().ok().is_some_and(|t| t.is_dir()) {
1098 if dir_contains_filename_suffix(&path, suffix) {
1099 return true;
1100 }
1101 continue;
1102 }
1103 let name = e.file_name().to_string_lossy().to_string();
1104 if name.ends_with(suffix) {
1105 return true;
1106 }
1107 }
1108 false
1109}
1110
1111#[cfg(test)]
1114mod tests {
1115 use super::*;
1116
1117 #[test]
1118 fn load_user_guidance_returns_trimmed_content_after_marker() {
1119 let dir = tempfile::tempdir().expect("tempdir should succeed");
1120 let ito_path = dir.path();
1121
1122 let content = "<!-- ITO:START -->\nheader\n<!-- ITO:END -->\n\nPrefer BDD.\n";
1123 std::fs::write(ito_path.join("user-guidance.md"), content).expect("write should succeed");
1124
1125 let guidance = load_user_guidance(ito_path)
1126 .expect("load should succeed")
1127 .expect("should be present");
1128
1129 assert_eq!(guidance, "Prefer BDD.");
1130 }
1131
1132 #[test]
1133 fn parse_enhanced_tasks_extracts_ids_status_and_done() {
1134 let contents = r#"### Task 1.1: First
1135 - **Status**: [x] complete
1136
1137 ### Task 1.2: Second
1138 - **Status**: [ ] in-progress
1139 "#;
1140
1141 let tasks = parse_enhanced_tasks(contents);
1142 assert_eq!(tasks.len(), 2);
1143 assert_eq!(tasks[0].id, "1.1");
1144 assert_eq!(tasks[0].description, "First");
1145 assert!(tasks[0].done);
1146 assert_eq!(tasks[0].status.as_deref(), Some("complete"));
1147
1148 assert_eq!(tasks[1].id, "1.2");
1149 assert_eq!(tasks[1].description, "Second");
1150 assert!(!tasks[1].done);
1151 assert_eq!(tasks[1].status.as_deref(), Some("in-progress"));
1152 }
1153}