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