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