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
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
42pub struct SchemaListEntry {
43 pub name: String,
45 pub description: String,
47 pub artifacts: Vec<String>,
49 pub source: String,
51}
52
53#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
55pub struct SchemaListResponse {
56 pub schemas: Vec<SchemaListEntry>,
58 pub recommended_default: String,
60}
61
62use ito_common::fs::StdFs;
63use ito_common::paths;
64use ito_config::ConfigContext;
65
66pub type TemplatesError = WorkflowError;
68pub fn default_schema_name() -> &'static str {
70 "spec-driven"
71}
72
73pub fn validate_change_name_input(name: &str) -> bool {
91 if name.is_empty() {
92 return false;
93 }
94 if name.starts_with('/') || name.starts_with('\\') {
95 return false;
96 }
97 if name.contains('/') || name.contains('\\') {
98 return false;
99 }
100 if name.contains("..") {
101 return false;
102 }
103 true
104}
105
106pub fn read_change_schema(ito_path: &Path, change: &str) -> String {
119 let meta = paths::change_meta_path(ito_path, change);
120 if let Ok(Some(s)) = ito_common::io::read_to_string_optional(&meta) {
121 for line in s.lines() {
122 let l = line.trim();
123 if let Some(rest) = l.strip_prefix("schema:") {
124 let v = rest.trim();
125 if !v.is_empty() {
126 return v.to_string();
127 }
128 }
129 }
130 }
131 default_schema_name().to_string()
132}
133
134pub fn list_available_changes(ito_path: &Path) -> Vec<String> {
147 let fs = StdFs;
148 ito_domain::discovery::list_change_dir_names(&fs, ito_path).unwrap_or_default()
149}
150
151pub fn list_available_schemas(ctx: &ConfigContext) -> Vec<String> {
167 let mut set: BTreeSet<String> = BTreeSet::new();
168 let fs = StdFs;
169 for dir in [
170 project_schemas_dir(ctx),
171 user_schemas_dir(ctx),
172 Some(package_schemas_dir()),
173 ] {
174 let Some(dir) = dir else { continue };
175 let Ok(names) = ito_domain::discovery::list_dir_names(&fs, &dir) else {
176 continue;
177 };
178 for name in names {
179 let schema_dir = dir.join(&name);
180 if schema_dir.join("schema.yaml").exists() {
181 set.insert(name);
182 }
183 }
184 }
185
186 for name in embedded_schema_names() {
187 set.insert(name);
188 }
189
190 set.into_iter().collect()
191}
192
193pub fn list_schemas_detail(ctx: &ConfigContext) -> SchemaListResponse {
209 let names = list_available_schemas(ctx);
210 let mut schemas = Vec::new();
211
212 for name in &names {
213 let Ok(resolved) = resolve_schema(Some(name), ctx) else {
214 continue;
215 };
216 let description = resolved.schema.description.clone().unwrap_or_default();
217 let artifacts: Vec<String> = resolved
218 .schema
219 .artifacts
220 .iter()
221 .map(|a| a.id.clone())
222 .collect();
223 let source = match resolved.source {
224 SchemaSource::Project => "project",
225 SchemaSource::User => "user",
226 SchemaSource::Embedded => "embedded",
227 SchemaSource::Package => "package",
228 }
229 .to_string();
230
231 schemas.push(SchemaListEntry {
232 name: name.clone(),
233 description,
234 artifacts,
235 source,
236 });
237 }
238
239 SchemaListResponse {
240 schemas,
241 recommended_default: default_schema_name().to_string(),
242 }
243}
244
245pub fn resolve_schema(
273 schema_name: Option<&str>,
274 ctx: &ConfigContext,
275) -> Result<ResolvedSchema, TemplatesError> {
276 let name = schema_name.unwrap_or(default_schema_name());
277 if !is_safe_schema_name(name) {
278 return Err(WorkflowError::SchemaNotFound(name.to_string()));
279 }
280
281 let project_dir = project_schemas_dir(ctx).map(|d| d.join(name));
282 if let Some(d) = project_dir
283 && d.join("schema.yaml").exists()
284 {
285 let schema = load_schema_yaml(&d)?;
286 return Ok(ResolvedSchema {
287 schema,
288 schema_dir: d,
289 source: SchemaSource::Project,
290 });
291 }
292
293 let user_dir = user_schemas_dir(ctx).map(|d| d.join(name));
294 if let Some(d) = user_dir
295 && d.join("schema.yaml").exists()
296 {
297 let schema = load_schema_yaml(&d)?;
298 return Ok(ResolvedSchema {
299 schema,
300 schema_dir: d,
301 source: SchemaSource::User,
302 });
303 }
304
305 if let Some(schema) = load_embedded_schema_yaml(name)? {
306 return Ok(ResolvedSchema {
307 schema,
308 schema_dir: PathBuf::from(format!("embedded://schemas/{name}")),
309 source: SchemaSource::Embedded,
310 });
311 }
312
313 let pkg = package_schemas_dir().join(name);
314 if pkg.join("schema.yaml").exists() {
315 let schema = load_schema_yaml(&pkg)?;
316 return Ok(ResolvedSchema {
317 schema,
318 schema_dir: pkg,
319 source: SchemaSource::Package,
320 });
321 }
322
323 Err(TemplatesError::SchemaNotFound(name.to_string()))
324}
325
326pub fn compute_change_status(
361 ito_path: &Path,
362 change: &str,
363 schema_name: Option<&str>,
364 ctx: &ConfigContext,
365) -> Result<ChangeStatus, TemplatesError> {
366 if !validate_change_name_input(change) {
367 return Err(TemplatesError::InvalidChangeName);
368 }
369 let schema_name = schema_name
370 .map(|s| s.to_string())
371 .unwrap_or_else(|| read_change_schema(ito_path, change));
372 let resolved = resolve_schema(Some(&schema_name), ctx)?;
373
374 let change_dir = paths::change_dir(ito_path, change);
375 if !change_dir.exists() {
376 return Err(TemplatesError::ChangeNotFound(change.to_string()));
377 }
378
379 let mut artifacts_out: Vec<ArtifactStatus> = Vec::new();
380 let mut done_count: usize = 0;
381 let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
382
383 let order = build_order(&resolved.schema);
384 for id in order {
385 let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
386 continue;
387 };
388 let done = *done_by_id.get(&a.id).unwrap_or(&false);
389 let mut missing: Vec<String> = Vec::new();
390 if !done {
391 for r in &a.requires {
392 if !*done_by_id.get(r).unwrap_or(&false) {
393 missing.push(r.clone());
394 }
395 }
396 }
397
398 let status = if done {
399 done_count += 1;
400 "done".to_string()
401 } else if missing.is_empty() {
402 "ready".to_string()
403 } else {
404 "blocked".to_string()
405 };
406 artifacts_out.push(ArtifactStatus {
407 id: a.id.clone(),
408 output_path: a.generates.clone(),
409 status,
410 missing_deps: missing,
411 });
412 }
413
414 let all_artifact_ids: Vec<String> = resolved
415 .schema
416 .artifacts
417 .iter()
418 .map(|a| a.id.clone())
419 .collect();
420 let apply_requires: Vec<String> = match resolved.schema.apply.as_ref() {
421 Some(apply) => apply
422 .requires
423 .clone()
424 .unwrap_or_else(|| all_artifact_ids.clone()),
425 None => all_artifact_ids.clone(),
426 };
427
428 let is_complete = done_count == resolved.schema.artifacts.len();
429 Ok(ChangeStatus {
430 change_name: change.to_string(),
431 schema_name: resolved.schema.name,
432 is_complete,
433 apply_requires,
434 artifacts: artifacts_out,
435 })
436}
437
438fn build_order(schema: &SchemaYaml) -> Vec<String> {
489 let mut in_degree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
492 let mut dependents: std::collections::HashMap<String, Vec<String>> =
493 std::collections::HashMap::new();
494
495 for a in &schema.artifacts {
496 in_degree.insert(a.id.clone(), a.requires.len());
497 dependents.insert(a.id.clone(), Vec::new());
498 }
499 for a in &schema.artifacts {
500 for req in &a.requires {
501 dependents
502 .entry(req.clone())
503 .or_default()
504 .push(a.id.clone());
505 }
506 }
507
508 let mut queue: Vec<String> = schema
509 .artifacts
510 .iter()
511 .map(|a| a.id.clone())
512 .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
513 .collect();
514 queue.sort();
515
516 let mut result: Vec<String> = Vec::new();
517 while !queue.is_empty() {
518 let current = queue.remove(0);
519 result.push(current.clone());
520
521 let mut newly_ready: Vec<String> = Vec::new();
522 if let Some(deps) = dependents.get(¤t) {
523 for dep in deps {
524 let new_degree = in_degree.get(dep).copied().unwrap_or(0).saturating_sub(1);
525 in_degree.insert(dep.clone(), new_degree);
526 if new_degree == 0 {
527 newly_ready.push(dep.clone());
528 }
529 }
530 }
531 newly_ready.sort();
532 queue.extend(newly_ready);
533 }
534
535 result
536}
537
538pub fn resolve_templates(
555 schema_name: Option<&str>,
556 ctx: &ConfigContext,
557) -> Result<(String, BTreeMap<String, TemplateInfo>), TemplatesError> {
558 let resolved = resolve_schema(schema_name, ctx)?;
559
560 let mut templates: BTreeMap<String, TemplateInfo> = BTreeMap::new();
561 for a in &resolved.schema.artifacts {
562 if !is_safe_relative_path(&a.template) {
563 return Err(WorkflowError::Io(std::io::Error::new(
564 std::io::ErrorKind::InvalidInput,
565 format!("invalid template path: {}", a.template),
566 )));
567 }
568
569 let path = if resolved.source == SchemaSource::Embedded {
570 format!(
571 "embedded://schemas/{}/templates/{}",
572 resolved.schema.name, a.template
573 )
574 } else {
575 resolved
576 .schema_dir
577 .join("templates")
578 .join(&a.template)
579 .to_string_lossy()
580 .to_string()
581 };
582 templates.insert(
583 a.id.clone(),
584 TemplateInfo {
585 source: resolved.source.as_str().to_string(),
586 path,
587 },
588 );
589 }
590 Ok((resolved.schema.name, templates))
591}
592
593pub fn resolve_instructions(
621 ito_path: &Path,
622 change: &str,
623 schema_name: Option<&str>,
624 artifact_id: &str,
625 ctx: &ConfigContext,
626) -> Result<InstructionsResponse, TemplatesError> {
627 if !validate_change_name_input(change) {
628 return Err(TemplatesError::InvalidChangeName);
629 }
630 let schema_name = schema_name
631 .map(|s| s.to_string())
632 .unwrap_or_else(|| read_change_schema(ito_path, change));
633 let resolved = resolve_schema(Some(&schema_name), ctx)?;
634
635 let change_dir = paths::change_dir(ito_path, change);
636 if !change_dir.exists() {
637 return Err(TemplatesError::ChangeNotFound(change.to_string()));
638 }
639
640 let a = resolved
641 .schema
642 .artifacts
643 .iter()
644 .find(|a| a.id == artifact_id)
645 .ok_or_else(|| TemplatesError::ArtifactNotFound(artifact_id.to_string()))?;
646
647 let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
648
649 let deps: Vec<DependencyInfo> = a
650 .requires
651 .iter()
652 .map(|id| {
653 let dep = resolved.schema.artifacts.iter().find(|d| d.id == *id);
654 DependencyInfo {
655 id: id.clone(),
656 done: *done_by_id.get(id).unwrap_or(&false),
657 path: dep
658 .map(|d| d.generates.clone())
659 .unwrap_or_else(|| id.clone()),
660 description: dep.and_then(|d| d.description.clone()).unwrap_or_default(),
661 }
662 })
663 .collect();
664
665 let mut unlocks: Vec<String> = resolved
666 .schema
667 .artifacts
668 .iter()
669 .filter(|other| other.requires.iter().any(|r| r == artifact_id))
670 .map(|a| a.id.clone())
671 .collect();
672 unlocks.sort();
673
674 let template = read_schema_template(&resolved, &a.template)?;
675
676 Ok(InstructionsResponse {
677 change_name: change.to_string(),
678 artifact_id: a.id.clone(),
679 schema_name: resolved.schema.name,
680 change_dir: change_dir.to_string_lossy().to_string(),
681 output_path: a.generates.clone(),
682 description: a.description.clone().unwrap_or_default(),
683 instruction: a.instruction.clone(),
684 template,
685 dependencies: deps,
686 unlocks,
687 })
688}
689
690pub fn compute_apply_instructions(
692 ito_path: &Path,
693 change: &str,
694 schema_name: Option<&str>,
695 ctx: &ConfigContext,
696) -> Result<ApplyInstructionsResponse, TemplatesError> {
697 if !validate_change_name_input(change) {
698 return Err(TemplatesError::InvalidChangeName);
699 }
700 let schema_name = schema_name
701 .map(|s| s.to_string())
702 .unwrap_or_else(|| read_change_schema(ito_path, change));
703 let resolved = resolve_schema(Some(&schema_name), ctx)?;
704 let change_dir = paths::change_dir(ito_path, change);
705 if !change_dir.exists() {
706 return Err(TemplatesError::ChangeNotFound(change.to_string()));
707 }
708
709 let schema = &resolved.schema;
710 let apply = schema.apply.as_ref();
711 let all_artifact_ids: Vec<String> = schema.artifacts.iter().map(|a| a.id.clone()).collect();
712
713 let required_artifact_ids: Vec<String> = apply
716 .and_then(|a| a.requires.clone())
717 .unwrap_or_else(|| all_artifact_ids.clone());
718 let tracks_file: Option<String> = apply.and_then(|a| a.tracks.clone());
719 let schema_instruction: Option<String> = apply.and_then(|a| a.instruction.clone());
720
721 let mut missing_artifacts: Vec<String> = Vec::new();
723 for artifact_id in &required_artifact_ids {
724 let Some(artifact) = schema.artifacts.iter().find(|a| a.id == *artifact_id) else {
725 continue;
726 };
727 if !artifact_done(&change_dir, &artifact.generates) {
728 missing_artifacts.push(artifact_id.clone());
729 }
730 }
731
732 let mut context_files: BTreeMap<String, String> = BTreeMap::new();
734 for artifact in &schema.artifacts {
735 if artifact_done(&change_dir, &artifact.generates) {
736 context_files.insert(
737 artifact.id.clone(),
738 change_dir
739 .join(&artifact.generates)
740 .to_string_lossy()
741 .to_string(),
742 );
743 }
744 }
745
746 let mut tasks: Vec<TaskItem> = Vec::new();
748 let mut tracks_file_exists = false;
749 let mut tracks_path: Option<String> = None;
750 let mut tracks_format: Option<String> = None;
751 let tracks_diagnostics: Option<Vec<TaskDiagnostic>> = None;
752
753 if let Some(tf) = &tracks_file {
754 let p = change_dir.join(tf);
755 tracks_path = Some(p.to_string_lossy().to_string());
756 tracks_file_exists = p.exists();
757 if tracks_file_exists {
758 let content = ito_common::io::read_to_string_std(&p)?;
759 let checkbox = parse_checkbox_tasks(&content);
760 if !checkbox.is_empty() {
761 tracks_format = Some("checkbox".to_string());
762 tasks = checkbox;
763 } else {
764 let enhanced = parse_enhanced_tasks(&content);
765 if !enhanced.is_empty() {
766 tracks_format = Some("enhanced".to_string());
767 tasks = enhanced;
768 } else if looks_like_enhanced_tasks(&content) {
769 tracks_format = Some("enhanced".to_string());
770 } else {
771 tracks_format = Some("unknown".to_string());
772 }
773 }
774 }
775 }
776
777 let total = tasks.len();
779 let complete = tasks.iter().filter(|t| t.done).count();
780 let remaining = total.saturating_sub(complete);
781 let mut in_progress: Option<usize> = None;
782 let mut pending: Option<usize> = None;
783 if tracks_format.as_deref() == Some("enhanced") {
784 let mut in_progress_count = 0;
785 let mut pending_count = 0;
786 for task in &tasks {
787 let Some(status) = task.status.as_deref() else {
788 continue;
789 };
790 let status = status.trim();
791 match status {
792 "in-progress" | "in_progress" | "in progress" => in_progress_count += 1,
793 "pending" => pending_count += 1,
794 _ => {}
795 }
796 }
797 in_progress = Some(in_progress_count);
798 pending = Some(pending_count);
799 }
800 if tracks_format.as_deref() == Some("checkbox") {
801 let mut in_progress_count = 0;
802 for task in &tasks {
803 let Some(status) = task.status.as_deref() else {
804 continue;
805 };
806 if status.trim() == "in-progress" {
807 in_progress_count += 1;
808 }
809 }
810 in_progress = Some(in_progress_count);
811 pending = Some(total.saturating_sub(complete + in_progress_count));
812 }
813 let progress = ProgressInfo {
814 total,
815 complete,
816 remaining,
817 in_progress,
818 pending,
819 };
820
821 let (state, instruction) = if !missing_artifacts.is_empty() {
823 (
824 "blocked".to_string(),
825 format!(
826 "Cannot apply this change yet. Missing artifacts: {}.\nUse the ito-continue-change skill to create the missing artifacts first.",
827 missing_artifacts.join(", ")
828 ),
829 )
830 } else if tracks_file.is_some() && !tracks_file_exists {
831 let tracks_filename = tracks_file
832 .as_deref()
833 .and_then(|p| Path::new(p).file_name())
834 .map(|s| s.to_string_lossy().to_string())
835 .unwrap_or_else(|| "tasks.md".to_string());
836 (
837 "blocked".to_string(),
838 format!(
839 "The {tracks_filename} file is missing and must be created.\nUse ito-continue-change to generate the tracking file."
840 ),
841 )
842 } else if tracks_file.is_some() && tracks_file_exists && total == 0 {
843 let tracks_filename = tracks_file
844 .as_deref()
845 .and_then(|p| Path::new(p).file_name())
846 .map(|s| s.to_string_lossy().to_string())
847 .unwrap_or_else(|| "tasks.md".to_string());
848 (
849 "blocked".to_string(),
850 format!(
851 "The {tracks_filename} file exists but contains no tasks.\nAdd tasks to {tracks_filename} or regenerate it with ito-continue-change."
852 ),
853 )
854 } else if tracks_file.is_some() && remaining == 0 && total > 0 {
855 (
856 "all_done".to_string(),
857 "All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving."
858 .to_string(),
859 )
860 } else if tracks_file.is_none() {
861 (
862 "ready".to_string(),
863 schema_instruction
864 .as_deref()
865 .map(|s| s.trim().to_string())
866 .unwrap_or_else(|| {
867 "All required artifacts complete. Proceed with implementation.".to_string()
868 }),
869 )
870 } else {
871 (
872 "ready".to_string(),
873 schema_instruction
874 .as_deref()
875 .map(|s| s.trim().to_string())
876 .unwrap_or_else(|| {
877 "Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.".to_string()
878 }),
879 )
880 };
881
882 Ok(ApplyInstructionsResponse {
883 change_name: change.to_string(),
884 change_dir: change_dir.to_string_lossy().to_string(),
885 schema_name: schema.name.clone(),
886 tracks_path,
887 tracks_file,
888 tracks_format,
889 tracks_diagnostics,
890 context_files,
891 progress,
892 tasks,
893 state,
894 missing_artifacts: if missing_artifacts.is_empty() {
895 None
896 } else {
897 Some(missing_artifacts)
898 },
899 instruction,
900 })
901}
902
903fn load_schema_yaml(schema_dir: &Path) -> Result<SchemaYaml, WorkflowError> {
904 let s = ito_common::io::read_to_string_std(&schema_dir.join("schema.yaml"))?;
905 Ok(serde_yaml::from_str(&s)?)
906}
907
908fn load_validation_yaml(schema_dir: &Path) -> Result<Option<ValidationYaml>, WorkflowError> {
909 let path = schema_dir.join("validation.yaml");
910 if !path.exists() {
911 return Ok(None);
912 }
913 let s = ito_common::io::read_to_string_std(&path)?;
914 Ok(Some(serde_yaml::from_str(&s)?))
915}
916
917pub fn load_schema_validation(
919 resolved: &ResolvedSchema,
920) -> Result<Option<ValidationYaml>, WorkflowError> {
921 if resolved.source == SchemaSource::Embedded {
922 return load_embedded_validation_yaml(&resolved.schema.name);
923 }
924 load_validation_yaml(&resolved.schema_dir)
925}
926
927fn compute_done_by_id(change_dir: &Path, schema: &SchemaYaml) -> BTreeMap<String, bool> {
928 let mut out = BTreeMap::new();
929 for a in &schema.artifacts {
930 out.insert(a.id.clone(), artifact_done(change_dir, &a.generates));
931 }
932 out
933}
934
935pub(crate) fn artifact_done(change_dir: &Path, generates: &str) -> bool {
940 if !generates.contains('*') {
941 return change_dir.join(generates).exists();
942 }
943
944 let (base, suffix) = match split_glob_pattern(generates) {
949 Some(v) => v,
950 None => return false,
951 };
952 let base_dir = change_dir.join(base);
953 dir_contains_filename_suffix(&base_dir, &suffix)
954}
955
956fn split_glob_pattern(pattern: &str) -> Option<(String, String)> {
957 let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
958
959 let (dir_part, file_pat) = match pattern.rsplit_once('/') {
960 Some((d, f)) => (d, f),
961 None => ("", pattern),
962 };
963 if !file_pat.starts_with('*') {
964 return None;
965 }
966 let suffix = file_pat[1..].to_string();
967
968 let base = dir_part
969 .strip_suffix("/**")
970 .or_else(|| dir_part.strip_suffix("**"))
971 .unwrap_or(dir_part);
972
973 let base = if base.contains('*') { "" } else { base };
975 Some((base.to_string(), suffix))
976}
977
978fn dir_contains_filename_suffix(dir: &Path, suffix: &str) -> bool {
979 let Ok(entries) = fs::read_dir(dir) else {
980 return false;
981 };
982 for e in entries.flatten() {
983 let path = e.path();
984 if e.file_type().ok().is_some_and(|t| t.is_dir()) {
985 if dir_contains_filename_suffix(&path, suffix) {
986 return true;
987 }
988 continue;
989 }
990 let name = e.file_name().to_string_lossy().to_string();
991 if name.ends_with(suffix) {
992 return true;
993 }
994 }
995 false
996}
997
998