Skip to main content

ito_core/templates/
mod.rs

1//! Schema/template helpers for change artifacts.
2//!
3//! This module reads a change directory and a schema definition (`schema.yaml`) and
4//! produces JSON-friendly status and instruction payloads.
5//!
6//! These types are designed for use by the CLI and by any web/API layer that
7//! wants to present "what should I do next?" without duplicating the filesystem
8//! logic.
9
10use 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/// One entry in the schema listing returned by [`list_schemas_detail`].
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
43pub struct SchemaListEntry {
44    /// Schema name (e.g. `spec-driven`).
45    pub name: String,
46    /// Human-readable description from `schema.yaml`.
47    pub description: String,
48    /// Artifact IDs defined by this schema.
49    pub artifacts: Vec<String>,
50    /// Where the schema was resolved from.
51    pub source: String,
52}
53
54/// Detailed schema listing suitable for agent consumption.
55#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56pub struct SchemaListResponse {
57    /// All discovered schemas with metadata.
58    pub schemas: Vec<SchemaListEntry>,
59    /// The recommended default schema name.
60    pub recommended_default: String,
61}
62
63use ito_common::fs::StdFs;
64use ito_common::paths;
65use ito_config::ConfigContext;
66
67/// Backward-compatible alias for callers using the renamed error type.
68pub type TemplatesError = WorkflowError;
69/// Default schema name used when a change does not specify one.
70pub fn default_schema_name() -> &'static str {
71    "spec-driven"
72}
73
74/// Validates a user-provided change name to ensure it is safe to use as a filesystem path segment.
75///
76/// The name must be non-empty, must not start with `/` or `\`, must not contain `/` or `\` anywhere, and must not contain the substring `..`.
77///
78/// # Examples
79///
80/// ```ignore
81/// assert!(validate_change_name_input("feature-123"));
82/// assert!(!validate_change_name_input("")); // empty
83/// assert!(!validate_change_name_input("../escape"));
84/// assert!(!validate_change_name_input("dir/name"));
85/// assert!(!validate_change_name_input("\\absolute"));
86/// ```
87///
88/// # Returns
89///
90/// `true` if the name meets the safety constraints described above, `false` otherwise.
91pub 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
107/// Determines the schema name configured for a change by reading its metadata.
108///
109/// Returns the schema name configured for the change, or the default schema name (`spec-driven`) if none is set.
110///
111/// # Examples
112///
113/// ```ignore
114/// use std::path::Path;
115///
116/// let name = read_change_schema(Path::new("/nonexistent/path"), "nope");
117/// assert_eq!(name, "spec-driven");
118/// ```
119pub 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
135/// List change directory names under the `.ito/changes` directory.
136///
137/// Each element is the change directory name (not a full path).
138///
139/// # Examples
140///
141/// ```
142/// use std::path::Path;
143///
144/// let names = ito_core::templates::list_available_changes(Path::new("."));
145/// // `names` is a `Vec<String>` of change directory names
146/// ```
147pub 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
152/// Lists available schema names discovered from the project, user, embedded, and package schema locations.
153///
154/// The result contains unique schema names and is deterministically sorted.
155///
156/// # Returns
157///
158/// A sorted, de-duplicated `Vec<String>` of available schema names.
159///
160/// # Examples
161///
162/// ```ignore
163/// // `ctx` should be a prepared `ConfigContext` for the current project.
164/// let names = list_available_schemas(&ctx);
165/// assert!(names.iter().all(|s| !s.is_empty()));
166/// ```
167pub 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
194/// List all available schemas with full metadata for agent/UI consumption.
195///
196/// Iterates over all discoverable schema names, resolves each one, and returns
197/// a [`SchemaListResponse`] containing per-schema entries (name, description,
198/// artifact IDs, source) plus the recommended default.
199///
200/// Schemas that fail to resolve are silently skipped.
201///
202/// # Examples
203///
204/// ```ignore
205/// let response = list_schemas_detail(&ctx);
206/// assert!(!response.schemas.is_empty());
207/// assert_eq!(response.recommended_default, "spec-driven");
208/// ```
209pub 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
246/// Resolves a schema name into a [`ResolvedSchema`].
247///
248/// If `schema_name` is `None`, the default schema name is used. Resolution
249/// precedence is project-local -> user -> embedded -> package; the returned
250/// `ResolvedSchema` contains the loaded `SchemaYaml`, the directory or embedded
251/// path that contained `schema.yaml`, and a `SchemaSource` indicating where it
252/// was found.
253///
254/// # Parameters
255///
256/// - `schema_name`: Optional schema name to resolve; uses the module default when
257///   `None`.
258/// - `ctx`: Configuration context used to locate project and user schema paths.
259///
260/// # Errors
261///
262/// Returns `WorkflowError::SchemaNotFound(name)` when the schema cannot be
263/// located. Other `WorkflowError` variants may be returned for IO or YAML
264/// parsing failures encountered while loading `schema.yaml`.
265///
266/// # Examples
267///
268/// ```ignore
269/// // Resolves the default schema using `ctx`.
270/// let resolved = resolve_schema(None, &ctx).expect("schema not found");
271/// println!("Resolved {} from {}", resolved.schema.name, resolved.schema_dir.display());
272/// ```
273pub 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
327/// Compute the workflow status for every artifact in a change.
328///
329/// Validates the change name, resolves the effective schema (explicit or from the change metadata),
330/// verifies the change directory exists, and produces per-artifact statuses plus the list of
331/// artifacts required before an apply operation.
332///
333/// # Parameters
334///
335/// - `ito_path`: base repository path containing the `.ito` state directories.
336/// - `change`: change directory name to inspect (must be a validated change name).
337/// - `schema_name`: optional explicit schema name; when `None`, the change's metadata is consulted.
338/// - `ctx`: configuration/context used to locate and load schemas.
339///
340/// # Returns
341///
342/// `ChangeStatus` describing the change name, resolved schema, overall completion flag,
343/// the set of artifact ids required for apply, and a list of `ArtifactStatus` entries where each
344/// artifact is labeled `done`, `ready`, or `blocked` and includes any missing dependency ids.
345///
346/// # Errors
347///
348/// Returns a `WorkflowError` when the change name is invalid, the change directory is missing,
349/// or the schema cannot be resolved or loaded.
350///
351/// # Examples
352///
353/// ```ignore
354/// # use std::path::Path;
355/// # use ito_core::templates::{compute_change_status, ChangeStatus};
356/// # use ito_core::config::ConfigContext;
357/// let ctx = ConfigContext::default();
358/// let status = compute_change_status(Path::new("."), "my-change", None, &ctx).unwrap();
359/// assert_eq!(status.change_name, "my-change");
360/// ```
361pub 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
439/// Computes a deterministic topological build order of artifact ids for the given schema.
440///
441/// The returned vector lists artifact ids in an order where each artifact appears after all of
442/// its declared `requires`. When multiple artifacts become ready at the same time, their ids
443/// are emitted in sorted order to ensure deterministic output.
444///
445/// # Examples
446///
447/// ```ignore
448/// // Construct a minimal schema with three artifacts:
449/// // - "a" has no requirements
450/// // - "b" requires "a"
451/// // - "c" requires "a"
452/// let schema = SchemaYaml {
453///     name: "example".to_string(),
454///     version: None,
455///     description: None,
456///     artifacts: vec![
457///         ArtifactYaml {
458///             id: "a".to_string(),
459///             generates: "a.out".to_string(),
460///             description: None,
461///             template: "a.tpl".to_string(),
462///             instruction: None,
463///             requires: vec![],
464///         },
465///         ArtifactYaml {
466///             id: "b".to_string(),
467///             generates: "b.out".to_string(),
468///             description: None,
469///             template: "b.tpl".to_string(),
470///             instruction: None,
471///             requires: vec!["a".to_string()],
472///         },
473///         ArtifactYaml {
474///             id: "c".to_string(),
475///             generates: "c.out".to_string(),
476///             description: None,
477///             template: "c.tpl".to_string(),
478///             instruction: None,
479///             requires: vec!["a".to_string()],
480///         },
481///     ],
482///     apply: None,
483/// };
484///
485/// let order = build_order(&schema);
486/// // "a" must come before both "b" and "c"; "b" and "c" are sorted deterministically
487/// assert_eq!(order, vec!["a".to_string(), "b".to_string(), "c".to_string()]);
488/// ```
489fn build_order(schema: &SchemaYaml) -> Vec<String> {
490    // Match TS ArtifactGraph.getBuildOrder (Kahn's algorithm with deterministic sorting
491    // of roots + newlyReady only).
492    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(&current) {
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
539/// Resolve template paths for every artifact in a schema.
540///
541/// If `schema_name` is `None`, the schema is resolved using project -> user -> embedded -> package
542/// precedence. For embedded schemas each template path is returned as an `embedded://schemas/{name}/templates/{file}`
543/// URI; for filesystem-backed schemas each template path is an absolute filesystem string.
544///
545/// Returns the resolved schema name and a map from artifact id to `TemplateInfo` (contains `source` and `path`).
546///
547/// # Examples
548///
549/// ```ignore
550/// // Obtain a ConfigContext from your application environment.
551/// let ctx = /* obtain ConfigContext */ unimplemented!();
552/// let (schema_name, templates) = resolve_templates(None, &ctx).unwrap();
553/// // `templates` maps artifact ids to TemplateInfo with `source` and `path`.
554/// ```
555pub 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
594/// Produce user-facing instructions and metadata for performing a single artifact in a change.
595///
596/// Resolves the effective schema for the change, verifies the change directory and artifact exist,
597/// computes the artifact's declared dependencies and which artifacts it will unlock, loads the
598/// artifact's template and instruction text, and returns an InstructionsResponse containing the
599/// fields required by CLI/API layers.
600///
601/// # Errors
602///
603/// Returns a `WorkflowError` when the change name is invalid, the change directory or schema cannot be found,
604/// the requested artifact is not defined in the schema, or when underlying I/O/YAML/template reads fail
605/// (for example: `InvalidChangeName`, `ChangeNotFound`, `SchemaNotFound`, `ArtifactNotFound`, `Io`, `Yaml`).
606///
607/// # Examples
608///
609/// ```ignore
610/// use std::path::Path;
611/// // `config_ctx` should be a prepared ConfigContext in real usage.
612/// let resp = resolve_instructions(
613///     Path::new("/project/ito"),
614///     "0001-add-feature",
615///     Some("spec-driven"),
616///     "service-config",
617///     &config_ctx,
618/// ).unwrap();
619/// assert_eq!(resp.artifact_id, "service-config");
620/// ```
621pub 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
691/// Compute apply-stage instructions and progress for a change.
692pub 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    // Determine required artifacts and tracking file from schema.
715    // Match TS: apply.requires ?? allArtifacts (nullish coalescing).
716    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    // Check which required artifacts are missing.
723    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    // Build context files from all existing artifacts in schema.
734    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    // Parse tasks if tracking file exists.
748    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    // Calculate progress.
779    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    // Determine state and instruction.
823    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
918/// Load schema validation configuration when present.
919pub 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
936/// Returns whether an artifact output is present for the given `generates` pattern.
937///
938/// This is used outside the templates module (for example, schema-aware validation) to
939/// reuse the same minimal glob semantics as schema artifact completion.
940pub(crate) fn artifact_done(change_dir: &Path, generates: &str) -> bool {
941    if !generates.contains('*') {
942        return change_dir.join(generates).exists();
943    }
944
945    // Minimal glob support for patterns used by schemas:
946    //   dir/**/*.ext
947    //   dir/*.suffix
948    //   **/*.ext
949    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    // If the directory still contains wildcards (e.g. "**"), search from change_dir.
975    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// (intentionally no checkbox counting helpers here; checkbox tasks are parsed into TaskItems)