1use std::collections::{BTreeMap, BTreeSet};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14mod guidance;
15mod review;
16mod schema_assets;
17mod task_parsing;
18mod types;
19pub use guidance::{
20 load_composed_user_guidance, load_user_guidance, load_user_guidance_for_artifact,
21};
22pub use review::compute_review_context;
23pub use schema_assets::{ExportSchemasResult, export_embedded_schemas};
24use schema_assets::{
25 embedded_schema_names, is_safe_relative_path, is_safe_schema_name, load_embedded_schema_yaml,
26 load_embedded_validation_yaml, package_schemas_dir, project_schemas_dir, read_schema_template,
27 user_schemas_dir,
28};
29use task_parsing::{looks_like_enhanced_tasks, parse_checkbox_tasks, parse_enhanced_tasks};
30pub use types::{
31 AgentInstructionResponse, ApplyInstructionsResponse, ApplyYaml, ArtifactStatus, ArtifactYaml,
32 ChangeStatus, DependencyInfo, InstructionsResponse, PeerReviewContext, ProgressInfo,
33 ResolvedSchema, ReviewAffectedSpecInfo, ReviewArtifactInfo, ReviewTaskSummaryInfo,
34 ReviewTestingPolicy, ReviewValidationIssueInfo, SchemaSource, SchemaYaml, TaskDiagnostic,
35 TaskItem, TemplateInfo, ValidationArtifactYaml, ValidationDefaultsYaml, ValidationLevelYaml,
36 ValidationTrackingSourceYaml, ValidationTrackingYaml, ValidationYaml, ValidatorId,
37 WorkflowError,
38};
39
40use ito_common::fs::StdFs;
41use ito_common::paths;
42use ito_config::ConfigContext;
43
44pub type TemplatesError = WorkflowError;
46pub fn default_schema_name() -> &'static str {
48 "spec-driven"
49}
50
51pub fn validate_change_name_input(name: &str) -> bool {
69 if name.is_empty() {
70 return false;
71 }
72 if name.starts_with('/') || name.starts_with('\\') {
73 return false;
74 }
75 if name.contains('/') || name.contains('\\') {
76 return false;
77 }
78 if name.contains("..") {
79 return false;
80 }
81 true
82}
83
84pub fn read_change_schema(ito_path: &Path, change: &str) -> String {
97 let meta = paths::change_meta_path(ito_path, change);
98 if let Ok(Some(s)) = ito_common::io::read_to_string_optional(&meta) {
99 for line in s.lines() {
100 let l = line.trim();
101 if let Some(rest) = l.strip_prefix("schema:") {
102 let v = rest.trim();
103 if !v.is_empty() {
104 return v.to_string();
105 }
106 }
107 }
108 }
109 default_schema_name().to_string()
110}
111
112pub fn list_available_changes(ito_path: &Path) -> Vec<String> {
125 let fs = StdFs;
126 ito_domain::discovery::list_change_dir_names(&fs, ito_path).unwrap_or_default()
127}
128
129pub fn list_available_schemas(ctx: &ConfigContext) -> Vec<String> {
145 let mut set: BTreeSet<String> = BTreeSet::new();
146 let fs = StdFs;
147 for dir in [
148 project_schemas_dir(ctx),
149 user_schemas_dir(ctx),
150 Some(package_schemas_dir()),
151 ] {
152 let Some(dir) = dir else { continue };
153 let Ok(names) = ito_domain::discovery::list_dir_names(&fs, &dir) else {
154 continue;
155 };
156 for name in names {
157 let schema_dir = dir.join(&name);
158 if schema_dir.join("schema.yaml").exists() {
159 set.insert(name);
160 }
161 }
162 }
163
164 for name in embedded_schema_names() {
165 set.insert(name);
166 }
167
168 set.into_iter().collect()
169}
170
171pub fn resolve_schema(
199 schema_name: Option<&str>,
200 ctx: &ConfigContext,
201) -> Result<ResolvedSchema, TemplatesError> {
202 let name = schema_name.unwrap_or(default_schema_name());
203 if !is_safe_schema_name(name) {
204 return Err(WorkflowError::SchemaNotFound(name.to_string()));
205 }
206
207 let project_dir = project_schemas_dir(ctx).map(|d| d.join(name));
208 if let Some(d) = project_dir
209 && d.join("schema.yaml").exists()
210 {
211 let schema = load_schema_yaml(&d)?;
212 return Ok(ResolvedSchema {
213 schema,
214 schema_dir: d,
215 source: SchemaSource::Project,
216 });
217 }
218
219 let user_dir = user_schemas_dir(ctx).map(|d| d.join(name));
220 if let Some(d) = user_dir
221 && d.join("schema.yaml").exists()
222 {
223 let schema = load_schema_yaml(&d)?;
224 return Ok(ResolvedSchema {
225 schema,
226 schema_dir: d,
227 source: SchemaSource::User,
228 });
229 }
230
231 if let Some(schema) = load_embedded_schema_yaml(name)? {
232 return Ok(ResolvedSchema {
233 schema,
234 schema_dir: PathBuf::from(format!("embedded://schemas/{name}")),
235 source: SchemaSource::Embedded,
236 });
237 }
238
239 let pkg = package_schemas_dir().join(name);
240 if pkg.join("schema.yaml").exists() {
241 let schema = load_schema_yaml(&pkg)?;
242 return Ok(ResolvedSchema {
243 schema,
244 schema_dir: pkg,
245 source: SchemaSource::Package,
246 });
247 }
248
249 Err(TemplatesError::SchemaNotFound(name.to_string()))
250}
251
252pub fn compute_change_status(
287 ito_path: &Path,
288 change: &str,
289 schema_name: Option<&str>,
290 ctx: &ConfigContext,
291) -> Result<ChangeStatus, TemplatesError> {
292 if !validate_change_name_input(change) {
293 return Err(TemplatesError::InvalidChangeName);
294 }
295 let schema_name = schema_name
296 .map(|s| s.to_string())
297 .unwrap_or_else(|| read_change_schema(ito_path, change));
298 let resolved = resolve_schema(Some(&schema_name), ctx)?;
299
300 let change_dir = paths::change_dir(ito_path, change);
301 if !change_dir.exists() {
302 return Err(TemplatesError::ChangeNotFound(change.to_string()));
303 }
304
305 let mut artifacts_out: Vec<ArtifactStatus> = Vec::new();
306 let mut done_count: usize = 0;
307 let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
308
309 let order = build_order(&resolved.schema);
310 for id in order {
311 let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
312 continue;
313 };
314 let done = *done_by_id.get(&a.id).unwrap_or(&false);
315 let mut missing: Vec<String> = Vec::new();
316 if !done {
317 for r in &a.requires {
318 if !*done_by_id.get(r).unwrap_or(&false) {
319 missing.push(r.clone());
320 }
321 }
322 }
323
324 let status = if done {
325 done_count += 1;
326 "done".to_string()
327 } else if missing.is_empty() {
328 "ready".to_string()
329 } else {
330 "blocked".to_string()
331 };
332 artifacts_out.push(ArtifactStatus {
333 id: a.id.clone(),
334 output_path: a.generates.clone(),
335 status,
336 missing_deps: missing,
337 });
338 }
339
340 let all_artifact_ids: Vec<String> = resolved
341 .schema
342 .artifacts
343 .iter()
344 .map(|a| a.id.clone())
345 .collect();
346 let apply_requires: Vec<String> = match resolved.schema.apply.as_ref() {
347 Some(apply) => apply
348 .requires
349 .clone()
350 .unwrap_or_else(|| all_artifact_ids.clone()),
351 None => all_artifact_ids.clone(),
352 };
353
354 let is_complete = done_count == resolved.schema.artifacts.len();
355 Ok(ChangeStatus {
356 change_name: change.to_string(),
357 schema_name: resolved.schema.name,
358 is_complete,
359 apply_requires,
360 artifacts: artifacts_out,
361 })
362}
363
364fn build_order(schema: &SchemaYaml) -> Vec<String> {
415 let mut in_degree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
418 let mut dependents: std::collections::HashMap<String, Vec<String>> =
419 std::collections::HashMap::new();
420
421 for a in &schema.artifacts {
422 in_degree.insert(a.id.clone(), a.requires.len());
423 dependents.insert(a.id.clone(), Vec::new());
424 }
425 for a in &schema.artifacts {
426 for req in &a.requires {
427 dependents
428 .entry(req.clone())
429 .or_default()
430 .push(a.id.clone());
431 }
432 }
433
434 let mut queue: Vec<String> = schema
435 .artifacts
436 .iter()
437 .map(|a| a.id.clone())
438 .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
439 .collect();
440 queue.sort();
441
442 let mut result: Vec<String> = Vec::new();
443 while !queue.is_empty() {
444 let current = queue.remove(0);
445 result.push(current.clone());
446
447 let mut newly_ready: Vec<String> = Vec::new();
448 if let Some(deps) = dependents.get(¤t) {
449 for dep in deps {
450 let new_degree = in_degree.get(dep).copied().unwrap_or(0).saturating_sub(1);
451 in_degree.insert(dep.clone(), new_degree);
452 if new_degree == 0 {
453 newly_ready.push(dep.clone());
454 }
455 }
456 }
457 newly_ready.sort();
458 queue.extend(newly_ready);
459 }
460
461 result
462}
463
464pub fn resolve_templates(
481 schema_name: Option<&str>,
482 ctx: &ConfigContext,
483) -> Result<(String, BTreeMap<String, TemplateInfo>), TemplatesError> {
484 let resolved = resolve_schema(schema_name, ctx)?;
485
486 let mut templates: BTreeMap<String, TemplateInfo> = BTreeMap::new();
487 for a in &resolved.schema.artifacts {
488 if !is_safe_relative_path(&a.template) {
489 return Err(WorkflowError::Io(std::io::Error::new(
490 std::io::ErrorKind::InvalidInput,
491 format!("invalid template path: {}", a.template),
492 )));
493 }
494
495 let path = if resolved.source == SchemaSource::Embedded {
496 format!(
497 "embedded://schemas/{}/templates/{}",
498 resolved.schema.name, a.template
499 )
500 } else {
501 resolved
502 .schema_dir
503 .join("templates")
504 .join(&a.template)
505 .to_string_lossy()
506 .to_string()
507 };
508 templates.insert(
509 a.id.clone(),
510 TemplateInfo {
511 source: resolved.source.as_str().to_string(),
512 path,
513 },
514 );
515 }
516 Ok((resolved.schema.name, templates))
517}
518
519pub fn resolve_instructions(
547 ito_path: &Path,
548 change: &str,
549 schema_name: Option<&str>,
550 artifact_id: &str,
551 ctx: &ConfigContext,
552) -> Result<InstructionsResponse, TemplatesError> {
553 if !validate_change_name_input(change) {
554 return Err(TemplatesError::InvalidChangeName);
555 }
556 let schema_name = schema_name
557 .map(|s| s.to_string())
558 .unwrap_or_else(|| read_change_schema(ito_path, change));
559 let resolved = resolve_schema(Some(&schema_name), ctx)?;
560
561 let change_dir = paths::change_dir(ito_path, change);
562 if !change_dir.exists() {
563 return Err(TemplatesError::ChangeNotFound(change.to_string()));
564 }
565
566 let a = resolved
567 .schema
568 .artifacts
569 .iter()
570 .find(|a| a.id == artifact_id)
571 .ok_or_else(|| TemplatesError::ArtifactNotFound(artifact_id.to_string()))?;
572
573 let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
574
575 let deps: Vec<DependencyInfo> = a
576 .requires
577 .iter()
578 .map(|id| {
579 let dep = resolved.schema.artifacts.iter().find(|d| d.id == *id);
580 DependencyInfo {
581 id: id.clone(),
582 done: *done_by_id.get(id).unwrap_or(&false),
583 path: dep
584 .map(|d| d.generates.clone())
585 .unwrap_or_else(|| id.clone()),
586 description: dep.and_then(|d| d.description.clone()).unwrap_or_default(),
587 }
588 })
589 .collect();
590
591 let mut unlocks: Vec<String> = resolved
592 .schema
593 .artifacts
594 .iter()
595 .filter(|other| other.requires.iter().any(|r| r == artifact_id))
596 .map(|a| a.id.clone())
597 .collect();
598 unlocks.sort();
599
600 let template = read_schema_template(&resolved, &a.template)?;
601
602 Ok(InstructionsResponse {
603 change_name: change.to_string(),
604 artifact_id: a.id.clone(),
605 schema_name: resolved.schema.name,
606 change_dir: change_dir.to_string_lossy().to_string(),
607 output_path: a.generates.clone(),
608 description: a.description.clone().unwrap_or_default(),
609 instruction: a.instruction.clone(),
610 template,
611 dependencies: deps,
612 unlocks,
613 })
614}
615
616pub fn compute_apply_instructions(
618 ito_path: &Path,
619 change: &str,
620 schema_name: Option<&str>,
621 ctx: &ConfigContext,
622) -> Result<ApplyInstructionsResponse, TemplatesError> {
623 if !validate_change_name_input(change) {
624 return Err(TemplatesError::InvalidChangeName);
625 }
626 let schema_name = schema_name
627 .map(|s| s.to_string())
628 .unwrap_or_else(|| read_change_schema(ito_path, change));
629 let resolved = resolve_schema(Some(&schema_name), ctx)?;
630 let change_dir = paths::change_dir(ito_path, change);
631 if !change_dir.exists() {
632 return Err(TemplatesError::ChangeNotFound(change.to_string()));
633 }
634
635 let schema = &resolved.schema;
636 let apply = schema.apply.as_ref();
637 let all_artifact_ids: Vec<String> = schema.artifacts.iter().map(|a| a.id.clone()).collect();
638
639 let required_artifact_ids: Vec<String> = apply
642 .and_then(|a| a.requires.clone())
643 .unwrap_or_else(|| all_artifact_ids.clone());
644 let tracks_file: Option<String> = apply.and_then(|a| a.tracks.clone());
645 let schema_instruction: Option<String> = apply.and_then(|a| a.instruction.clone());
646
647 let mut missing_artifacts: Vec<String> = Vec::new();
649 for artifact_id in &required_artifact_ids {
650 let Some(artifact) = schema.artifacts.iter().find(|a| a.id == *artifact_id) else {
651 continue;
652 };
653 if !artifact_done(&change_dir, &artifact.generates) {
654 missing_artifacts.push(artifact_id.clone());
655 }
656 }
657
658 let mut context_files: BTreeMap<String, String> = BTreeMap::new();
660 for artifact in &schema.artifacts {
661 if artifact_done(&change_dir, &artifact.generates) {
662 context_files.insert(
663 artifact.id.clone(),
664 change_dir
665 .join(&artifact.generates)
666 .to_string_lossy()
667 .to_string(),
668 );
669 }
670 }
671
672 let mut tasks: Vec<TaskItem> = Vec::new();
674 let mut tracks_file_exists = false;
675 let mut tracks_path: Option<String> = None;
676 let mut tracks_format: Option<String> = None;
677 let tracks_diagnostics: Option<Vec<TaskDiagnostic>> = None;
678
679 if let Some(tf) = &tracks_file {
680 let p = change_dir.join(tf);
681 tracks_path = Some(p.to_string_lossy().to_string());
682 tracks_file_exists = p.exists();
683 if tracks_file_exists {
684 let content = ito_common::io::read_to_string_std(&p)?;
685 let checkbox = parse_checkbox_tasks(&content);
686 if !checkbox.is_empty() {
687 tracks_format = Some("checkbox".to_string());
688 tasks = checkbox;
689 } else {
690 let enhanced = parse_enhanced_tasks(&content);
691 if !enhanced.is_empty() {
692 tracks_format = Some("enhanced".to_string());
693 tasks = enhanced;
694 } else if looks_like_enhanced_tasks(&content) {
695 tracks_format = Some("enhanced".to_string());
696 } else {
697 tracks_format = Some("unknown".to_string());
698 }
699 }
700 }
701 }
702
703 let total = tasks.len();
705 let complete = tasks.iter().filter(|t| t.done).count();
706 let remaining = total.saturating_sub(complete);
707 let mut in_progress: Option<usize> = None;
708 let mut pending: Option<usize> = None;
709 if tracks_format.as_deref() == Some("enhanced") {
710 let mut in_progress_count = 0;
711 let mut pending_count = 0;
712 for task in &tasks {
713 let Some(status) = task.status.as_deref() else {
714 continue;
715 };
716 let status = status.trim();
717 match status {
718 "in-progress" | "in_progress" | "in progress" => in_progress_count += 1,
719 "pending" => pending_count += 1,
720 _ => {}
721 }
722 }
723 in_progress = Some(in_progress_count);
724 pending = Some(pending_count);
725 }
726 if tracks_format.as_deref() == Some("checkbox") {
727 let mut in_progress_count = 0;
728 for task in &tasks {
729 let Some(status) = task.status.as_deref() else {
730 continue;
731 };
732 if status.trim() == "in-progress" {
733 in_progress_count += 1;
734 }
735 }
736 in_progress = Some(in_progress_count);
737 pending = Some(total.saturating_sub(complete + in_progress_count));
738 }
739 let progress = ProgressInfo {
740 total,
741 complete,
742 remaining,
743 in_progress,
744 pending,
745 };
746
747 let (state, instruction) = if !missing_artifacts.is_empty() {
749 (
750 "blocked".to_string(),
751 format!(
752 "Cannot apply this change yet. Missing artifacts: {}.\nUse the ito-continue-change skill to create the missing artifacts first.",
753 missing_artifacts.join(", ")
754 ),
755 )
756 } else if tracks_file.is_some() && !tracks_file_exists {
757 let tracks_filename = tracks_file
758 .as_deref()
759 .and_then(|p| Path::new(p).file_name())
760 .map(|s| s.to_string_lossy().to_string())
761 .unwrap_or_else(|| "tasks.md".to_string());
762 (
763 "blocked".to_string(),
764 format!(
765 "The {tracks_filename} file is missing and must be created.\nUse ito-continue-change to generate the tracking file."
766 ),
767 )
768 } else if tracks_file.is_some() && tracks_file_exists && total == 0 {
769 let tracks_filename = tracks_file
770 .as_deref()
771 .and_then(|p| Path::new(p).file_name())
772 .map(|s| s.to_string_lossy().to_string())
773 .unwrap_or_else(|| "tasks.md".to_string());
774 (
775 "blocked".to_string(),
776 format!(
777 "The {tracks_filename} file exists but contains no tasks.\nAdd tasks to {tracks_filename} or regenerate it with ito-continue-change."
778 ),
779 )
780 } else if tracks_file.is_some() && remaining == 0 && total > 0 {
781 (
782 "all_done".to_string(),
783 "All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving."
784 .to_string(),
785 )
786 } else if tracks_file.is_none() {
787 (
788 "ready".to_string(),
789 schema_instruction
790 .as_deref()
791 .map(|s| s.trim().to_string())
792 .unwrap_or_else(|| {
793 "All required artifacts complete. Proceed with implementation.".to_string()
794 }),
795 )
796 } else {
797 (
798 "ready".to_string(),
799 schema_instruction
800 .as_deref()
801 .map(|s| s.trim().to_string())
802 .unwrap_or_else(|| {
803 "Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.".to_string()
804 }),
805 )
806 };
807
808 Ok(ApplyInstructionsResponse {
809 change_name: change.to_string(),
810 change_dir: change_dir.to_string_lossy().to_string(),
811 schema_name: schema.name.clone(),
812 tracks_path,
813 tracks_file,
814 tracks_format,
815 tracks_diagnostics,
816 context_files,
817 progress,
818 tasks,
819 state,
820 missing_artifacts: if missing_artifacts.is_empty() {
821 None
822 } else {
823 Some(missing_artifacts)
824 },
825 instruction,
826 })
827}
828
829fn load_schema_yaml(schema_dir: &Path) -> Result<SchemaYaml, WorkflowError> {
830 let s = ito_common::io::read_to_string_std(&schema_dir.join("schema.yaml"))?;
831 Ok(serde_yaml::from_str(&s)?)
832}
833
834fn load_validation_yaml(schema_dir: &Path) -> Result<Option<ValidationYaml>, WorkflowError> {
835 let path = schema_dir.join("validation.yaml");
836 if !path.exists() {
837 return Ok(None);
838 }
839 let s = ito_common::io::read_to_string_std(&path)?;
840 Ok(Some(serde_yaml::from_str(&s)?))
841}
842
843pub fn load_schema_validation(
845 resolved: &ResolvedSchema,
846) -> Result<Option<ValidationYaml>, WorkflowError> {
847 if resolved.source == SchemaSource::Embedded {
848 return load_embedded_validation_yaml(&resolved.schema.name);
849 }
850 load_validation_yaml(&resolved.schema_dir)
851}
852
853fn compute_done_by_id(change_dir: &Path, schema: &SchemaYaml) -> BTreeMap<String, bool> {
854 let mut out = BTreeMap::new();
855 for a in &schema.artifacts {
856 out.insert(a.id.clone(), artifact_done(change_dir, &a.generates));
857 }
858 out
859}
860
861pub(crate) fn artifact_done(change_dir: &Path, generates: &str) -> bool {
866 if !generates.contains('*') {
867 return change_dir.join(generates).exists();
868 }
869
870 let (base, suffix) = match split_glob_pattern(generates) {
875 Some(v) => v,
876 None => return false,
877 };
878 let base_dir = change_dir.join(base);
879 dir_contains_filename_suffix(&base_dir, &suffix)
880}
881
882fn split_glob_pattern(pattern: &str) -> Option<(String, String)> {
883 let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
884
885 let (dir_part, file_pat) = match pattern.rsplit_once('/') {
886 Some((d, f)) => (d, f),
887 None => ("", pattern),
888 };
889 if !file_pat.starts_with('*') {
890 return None;
891 }
892 let suffix = file_pat[1..].to_string();
893
894 let base = dir_part
895 .strip_suffix("/**")
896 .or_else(|| dir_part.strip_suffix("**"))
897 .unwrap_or(dir_part);
898
899 let base = if base.contains('*') { "" } else { base };
901 Some((base.to_string(), suffix))
902}
903
904fn dir_contains_filename_suffix(dir: &Path, suffix: &str) -> bool {
905 let Ok(entries) = fs::read_dir(dir) else {
906 return false;
907 };
908 for e in entries.flatten() {
909 let path = e.path();
910 if e.file_type().ok().is_some_and(|t| t.is_dir()) {
911 if dir_contains_filename_suffix(&path, suffix) {
912 return true;
913 }
914 continue;
915 }
916 let name = e.file_name().to_string_lossy().to_string();
917 if name.ends_with(suffix) {
918 return true;
919 }
920 }
921 false
922}
923
924