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
40use ito_common::fs::StdFs;
41use ito_common::paths;
42use ito_config::ConfigContext;
43
44/// Backward-compatible alias for callers using the renamed error type.
45pub type TemplatesError = WorkflowError;
46/// Default schema name used when a change does not specify one.
47pub fn default_schema_name() -> &'static str {
48    "spec-driven"
49}
50
51/// Validates a user-provided change name to ensure it is safe to use as a filesystem path segment.
52///
53/// The name must be non-empty, must not start with `/` or `\`, must not contain `/` or `\` anywhere, and must not contain the substring `..`.
54///
55/// # Examples
56///
57/// ```ignore
58/// assert!(validate_change_name_input("feature-123"));
59/// assert!(!validate_change_name_input("")); // empty
60/// assert!(!validate_change_name_input("../escape"));
61/// assert!(!validate_change_name_input("dir/name"));
62/// assert!(!validate_change_name_input("\\absolute"));
63/// ```
64///
65/// # Returns
66///
67/// `true` if the name meets the safety constraints described above, `false` otherwise.
68pub fn validate_change_name_input(name: &str) -> bool {
69    if name.is_empty() {
70        return false;
71    }
72    if name.starts_with('/') || name.starts_with('\\') {
73        return false;
74    }
75    if name.contains('/') || name.contains('\\') {
76        return false;
77    }
78    if name.contains("..") {
79        return false;
80    }
81    true
82}
83
84/// Determines the schema name configured for a change by reading its metadata.
85///
86/// Returns the schema name configured for the change, or the default schema name (`spec-driven`) if none is set.
87///
88/// # Examples
89///
90/// ```ignore
91/// use std::path::Path;
92///
93/// let name = read_change_schema(Path::new("/nonexistent/path"), "nope");
94/// assert_eq!(name, "spec-driven");
95/// ```
96pub fn read_change_schema(ito_path: &Path, change: &str) -> String {
97    let meta = paths::change_meta_path(ito_path, change);
98    if let Ok(Some(s)) = ito_common::io::read_to_string_optional(&meta) {
99        for line in s.lines() {
100            let l = line.trim();
101            if let Some(rest) = l.strip_prefix("schema:") {
102                let v = rest.trim();
103                if !v.is_empty() {
104                    return v.to_string();
105                }
106            }
107        }
108    }
109    default_schema_name().to_string()
110}
111
112/// List change directory names under the `.ito/changes` directory.
113///
114/// Each element is the change directory name (not a full path).
115///
116/// # Examples
117///
118/// ```
119/// use std::path::Path;
120///
121/// let names = ito_core::templates::list_available_changes(Path::new("."));
122/// // `names` is a `Vec<String>` of change directory names
123/// ```
124pub fn list_available_changes(ito_path: &Path) -> Vec<String> {
125    let fs = StdFs;
126    ito_domain::discovery::list_change_dir_names(&fs, ito_path).unwrap_or_default()
127}
128
129/// Lists available schema names discovered from the project, user, embedded, and package schema locations.
130///
131/// The result contains unique schema names and is deterministically sorted.
132///
133/// # Returns
134///
135/// A sorted, de-duplicated `Vec<String>` of available schema names.
136///
137/// # Examples
138///
139/// ```ignore
140/// // `ctx` should be a prepared `ConfigContext` for the current project.
141/// let names = list_available_schemas(&ctx);
142/// assert!(names.iter().all(|s| !s.is_empty()));
143/// ```
144pub fn list_available_schemas(ctx: &ConfigContext) -> Vec<String> {
145    let mut set: BTreeSet<String> = BTreeSet::new();
146    let fs = StdFs;
147    for dir in [
148        project_schemas_dir(ctx),
149        user_schemas_dir(ctx),
150        Some(package_schemas_dir()),
151    ] {
152        let Some(dir) = dir else { continue };
153        let Ok(names) = ito_domain::discovery::list_dir_names(&fs, &dir) else {
154            continue;
155        };
156        for name in names {
157            let schema_dir = dir.join(&name);
158            if schema_dir.join("schema.yaml").exists() {
159                set.insert(name);
160            }
161        }
162    }
163
164    for name in embedded_schema_names() {
165        set.insert(name);
166    }
167
168    set.into_iter().collect()
169}
170
171/// Resolves a schema name into a [`ResolvedSchema`].
172///
173/// If `schema_name` is `None`, the default schema name is used. Resolution
174/// precedence is project-local -> user -> embedded -> package; the returned
175/// `ResolvedSchema` contains the loaded `SchemaYaml`, the directory or embedded
176/// path that contained `schema.yaml`, and a `SchemaSource` indicating where it
177/// was found.
178///
179/// # Parameters
180///
181/// - `schema_name`: Optional schema name to resolve; uses the module default when
182///   `None`.
183/// - `ctx`: Configuration context used to locate project and user schema paths.
184///
185/// # Errors
186///
187/// Returns `WorkflowError::SchemaNotFound(name)` when the schema cannot be
188/// located. Other `WorkflowError` variants may be returned for IO or YAML
189/// parsing failures encountered while loading `schema.yaml`.
190///
191/// # Examples
192///
193/// ```ignore
194/// // Resolves the default schema using `ctx`.
195/// let resolved = resolve_schema(None, &ctx).expect("schema not found");
196/// println!("Resolved {} from {}", resolved.schema.name, resolved.schema_dir.display());
197/// ```
198pub fn resolve_schema(
199    schema_name: Option<&str>,
200    ctx: &ConfigContext,
201) -> Result<ResolvedSchema, TemplatesError> {
202    let name = schema_name.unwrap_or(default_schema_name());
203    if !is_safe_schema_name(name) {
204        return Err(WorkflowError::SchemaNotFound(name.to_string()));
205    }
206
207    let project_dir = project_schemas_dir(ctx).map(|d| d.join(name));
208    if let Some(d) = project_dir
209        && d.join("schema.yaml").exists()
210    {
211        let schema = load_schema_yaml(&d)?;
212        return Ok(ResolvedSchema {
213            schema,
214            schema_dir: d,
215            source: SchemaSource::Project,
216        });
217    }
218
219    let user_dir = user_schemas_dir(ctx).map(|d| d.join(name));
220    if let Some(d) = user_dir
221        && d.join("schema.yaml").exists()
222    {
223        let schema = load_schema_yaml(&d)?;
224        return Ok(ResolvedSchema {
225            schema,
226            schema_dir: d,
227            source: SchemaSource::User,
228        });
229    }
230
231    if let Some(schema) = load_embedded_schema_yaml(name)? {
232        return Ok(ResolvedSchema {
233            schema,
234            schema_dir: PathBuf::from(format!("embedded://schemas/{name}")),
235            source: SchemaSource::Embedded,
236        });
237    }
238
239    let pkg = package_schemas_dir().join(name);
240    if pkg.join("schema.yaml").exists() {
241        let schema = load_schema_yaml(&pkg)?;
242        return Ok(ResolvedSchema {
243            schema,
244            schema_dir: pkg,
245            source: SchemaSource::Package,
246        });
247    }
248
249    Err(TemplatesError::SchemaNotFound(name.to_string()))
250}
251
252/// Compute the workflow status for every artifact in a change.
253///
254/// Validates the change name, resolves the effective schema (explicit or from the change metadata),
255/// verifies the change directory exists, and produces per-artifact statuses plus the list of
256/// artifacts required before an apply operation.
257///
258/// # Parameters
259///
260/// - `ito_path`: base repository path containing the `.ito` state directories.
261/// - `change`: change directory name to inspect (must be a validated change name).
262/// - `schema_name`: optional explicit schema name; when `None`, the change's metadata is consulted.
263/// - `ctx`: configuration/context used to locate and load schemas.
264///
265/// # Returns
266///
267/// `ChangeStatus` describing the change name, resolved schema, overall completion flag,
268/// the set of artifact ids required for apply, and a list of `ArtifactStatus` entries where each
269/// artifact is labeled `done`, `ready`, or `blocked` and includes any missing dependency ids.
270///
271/// # Errors
272///
273/// Returns a `WorkflowError` when the change name is invalid, the change directory is missing,
274/// or the schema cannot be resolved or loaded.
275///
276/// # Examples
277///
278/// ```ignore
279/// # use std::path::Path;
280/// # use ito_core::templates::{compute_change_status, ChangeStatus};
281/// # use ito_core::config::ConfigContext;
282/// let ctx = ConfigContext::default();
283/// let status = compute_change_status(Path::new("."), "my-change", None, &ctx).unwrap();
284/// assert_eq!(status.change_name, "my-change");
285/// ```
286pub fn compute_change_status(
287    ito_path: &Path,
288    change: &str,
289    schema_name: Option<&str>,
290    ctx: &ConfigContext,
291) -> Result<ChangeStatus, TemplatesError> {
292    if !validate_change_name_input(change) {
293        return Err(TemplatesError::InvalidChangeName);
294    }
295    let schema_name = schema_name
296        .map(|s| s.to_string())
297        .unwrap_or_else(|| read_change_schema(ito_path, change));
298    let resolved = resolve_schema(Some(&schema_name), ctx)?;
299
300    let change_dir = paths::change_dir(ito_path, change);
301    if !change_dir.exists() {
302        return Err(TemplatesError::ChangeNotFound(change.to_string()));
303    }
304
305    let mut artifacts_out: Vec<ArtifactStatus> = Vec::new();
306    let mut done_count: usize = 0;
307    let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
308
309    let order = build_order(&resolved.schema);
310    for id in order {
311        let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
312            continue;
313        };
314        let done = *done_by_id.get(&a.id).unwrap_or(&false);
315        let mut missing: Vec<String> = Vec::new();
316        if !done {
317            for r in &a.requires {
318                if !*done_by_id.get(r).unwrap_or(&false) {
319                    missing.push(r.clone());
320                }
321            }
322        }
323
324        let status = if done {
325            done_count += 1;
326            "done".to_string()
327        } else if missing.is_empty() {
328            "ready".to_string()
329        } else {
330            "blocked".to_string()
331        };
332        artifacts_out.push(ArtifactStatus {
333            id: a.id.clone(),
334            output_path: a.generates.clone(),
335            status,
336            missing_deps: missing,
337        });
338    }
339
340    let all_artifact_ids: Vec<String> = resolved
341        .schema
342        .artifacts
343        .iter()
344        .map(|a| a.id.clone())
345        .collect();
346    let apply_requires: Vec<String> = match resolved.schema.apply.as_ref() {
347        Some(apply) => apply
348            .requires
349            .clone()
350            .unwrap_or_else(|| all_artifact_ids.clone()),
351        None => all_artifact_ids.clone(),
352    };
353
354    let is_complete = done_count == resolved.schema.artifacts.len();
355    Ok(ChangeStatus {
356        change_name: change.to_string(),
357        schema_name: resolved.schema.name,
358        is_complete,
359        apply_requires,
360        artifacts: artifacts_out,
361    })
362}
363
364/// Computes a deterministic topological build order of artifact ids for the given schema.
365///
366/// The returned vector lists artifact ids in an order where each artifact appears after all of
367/// its declared `requires`. When multiple artifacts become ready at the same time, their ids
368/// are emitted in sorted order to ensure deterministic output.
369///
370/// # Examples
371///
372/// ```ignore
373/// // Construct a minimal schema with three artifacts:
374/// // - "a" has no requirements
375/// // - "b" requires "a"
376/// // - "c" requires "a"
377/// let schema = SchemaYaml {
378///     name: "example".to_string(),
379///     version: None,
380///     description: None,
381///     artifacts: vec![
382///         ArtifactYaml {
383///             id: "a".to_string(),
384///             generates: "a.out".to_string(),
385///             description: None,
386///             template: "a.tpl".to_string(),
387///             instruction: None,
388///             requires: vec![],
389///         },
390///         ArtifactYaml {
391///             id: "b".to_string(),
392///             generates: "b.out".to_string(),
393///             description: None,
394///             template: "b.tpl".to_string(),
395///             instruction: None,
396///             requires: vec!["a".to_string()],
397///         },
398///         ArtifactYaml {
399///             id: "c".to_string(),
400///             generates: "c.out".to_string(),
401///             description: None,
402///             template: "c.tpl".to_string(),
403///             instruction: None,
404///             requires: vec!["a".to_string()],
405///         },
406///     ],
407///     apply: None,
408/// };
409///
410/// let order = build_order(&schema);
411/// // "a" must come before both "b" and "c"; "b" and "c" are sorted deterministically
412/// assert_eq!(order, vec!["a".to_string(), "b".to_string(), "c".to_string()]);
413/// ```
414fn build_order(schema: &SchemaYaml) -> Vec<String> {
415    // Match TS ArtifactGraph.getBuildOrder (Kahn's algorithm with deterministic sorting
416    // of roots + newlyReady only).
417    let mut in_degree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
418    let mut dependents: std::collections::HashMap<String, Vec<String>> =
419        std::collections::HashMap::new();
420
421    for a in &schema.artifacts {
422        in_degree.insert(a.id.clone(), a.requires.len());
423        dependents.insert(a.id.clone(), Vec::new());
424    }
425    for a in &schema.artifacts {
426        for req in &a.requires {
427            dependents
428                .entry(req.clone())
429                .or_default()
430                .push(a.id.clone());
431        }
432    }
433
434    let mut queue: Vec<String> = schema
435        .artifacts
436        .iter()
437        .map(|a| a.id.clone())
438        .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
439        .collect();
440    queue.sort();
441
442    let mut result: Vec<String> = Vec::new();
443    while !queue.is_empty() {
444        let current = queue.remove(0);
445        result.push(current.clone());
446
447        let mut newly_ready: Vec<String> = Vec::new();
448        if let Some(deps) = dependents.get(&current) {
449            for dep in deps {
450                let new_degree = in_degree.get(dep).copied().unwrap_or(0).saturating_sub(1);
451                in_degree.insert(dep.clone(), new_degree);
452                if new_degree == 0 {
453                    newly_ready.push(dep.clone());
454                }
455            }
456        }
457        newly_ready.sort();
458        queue.extend(newly_ready);
459    }
460
461    result
462}
463
464/// Resolve template paths for every artifact in a schema.
465///
466/// If `schema_name` is `None`, the schema is resolved using project -> user -> embedded -> package
467/// precedence. For embedded schemas each template path is returned as an `embedded://schemas/{name}/templates/{file}`
468/// URI; for filesystem-backed schemas each template path is an absolute filesystem string.
469///
470/// Returns the resolved schema name and a map from artifact id to `TemplateInfo` (contains `source` and `path`).
471///
472/// # Examples
473///
474/// ```ignore
475/// // Obtain a ConfigContext from your application environment.
476/// let ctx = /* obtain ConfigContext */ unimplemented!();
477/// let (schema_name, templates) = resolve_templates(None, &ctx).unwrap();
478/// // `templates` maps artifact ids to TemplateInfo with `source` and `path`.
479/// ```
480pub fn resolve_templates(
481    schema_name: Option<&str>,
482    ctx: &ConfigContext,
483) -> Result<(String, BTreeMap<String, TemplateInfo>), TemplatesError> {
484    let resolved = resolve_schema(schema_name, ctx)?;
485
486    let mut templates: BTreeMap<String, TemplateInfo> = BTreeMap::new();
487    for a in &resolved.schema.artifacts {
488        if !is_safe_relative_path(&a.template) {
489            return Err(WorkflowError::Io(std::io::Error::new(
490                std::io::ErrorKind::InvalidInput,
491                format!("invalid template path: {}", a.template),
492            )));
493        }
494
495        let path = if resolved.source == SchemaSource::Embedded {
496            format!(
497                "embedded://schemas/{}/templates/{}",
498                resolved.schema.name, a.template
499            )
500        } else {
501            resolved
502                .schema_dir
503                .join("templates")
504                .join(&a.template)
505                .to_string_lossy()
506                .to_string()
507        };
508        templates.insert(
509            a.id.clone(),
510            TemplateInfo {
511                source: resolved.source.as_str().to_string(),
512                path,
513            },
514        );
515    }
516    Ok((resolved.schema.name, templates))
517}
518
519/// Produce user-facing instructions and metadata for performing a single artifact in a change.
520///
521/// Resolves the effective schema for the change, verifies the change directory and artifact exist,
522/// computes the artifact's declared dependencies and which artifacts it will unlock, loads the
523/// artifact's template and instruction text, and returns an InstructionsResponse containing the
524/// fields required by CLI/API layers.
525///
526/// # Errors
527///
528/// Returns a `WorkflowError` when the change name is invalid, the change directory or schema cannot be found,
529/// the requested artifact is not defined in the schema, or when underlying I/O/YAML/template reads fail
530/// (for example: `InvalidChangeName`, `ChangeNotFound`, `SchemaNotFound`, `ArtifactNotFound`, `Io`, `Yaml`).
531///
532/// # Examples
533///
534/// ```ignore
535/// use std::path::Path;
536/// // `config_ctx` should be a prepared ConfigContext in real usage.
537/// let resp = resolve_instructions(
538///     Path::new("/project/ito"),
539///     "0001-add-feature",
540///     Some("spec-driven"),
541///     "service-config",
542///     &config_ctx,
543/// ).unwrap();
544/// assert_eq!(resp.artifact_id, "service-config");
545/// ```
546pub fn resolve_instructions(
547    ito_path: &Path,
548    change: &str,
549    schema_name: Option<&str>,
550    artifact_id: &str,
551    ctx: &ConfigContext,
552) -> Result<InstructionsResponse, TemplatesError> {
553    if !validate_change_name_input(change) {
554        return Err(TemplatesError::InvalidChangeName);
555    }
556    let schema_name = schema_name
557        .map(|s| s.to_string())
558        .unwrap_or_else(|| read_change_schema(ito_path, change));
559    let resolved = resolve_schema(Some(&schema_name), ctx)?;
560
561    let change_dir = paths::change_dir(ito_path, change);
562    if !change_dir.exists() {
563        return Err(TemplatesError::ChangeNotFound(change.to_string()));
564    }
565
566    let a = resolved
567        .schema
568        .artifacts
569        .iter()
570        .find(|a| a.id == artifact_id)
571        .ok_or_else(|| TemplatesError::ArtifactNotFound(artifact_id.to_string()))?;
572
573    let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
574
575    let deps: Vec<DependencyInfo> = a
576        .requires
577        .iter()
578        .map(|id| {
579            let dep = resolved.schema.artifacts.iter().find(|d| d.id == *id);
580            DependencyInfo {
581                id: id.clone(),
582                done: *done_by_id.get(id).unwrap_or(&false),
583                path: dep
584                    .map(|d| d.generates.clone())
585                    .unwrap_or_else(|| id.clone()),
586                description: dep.and_then(|d| d.description.clone()).unwrap_or_default(),
587            }
588        })
589        .collect();
590
591    let mut unlocks: Vec<String> = resolved
592        .schema
593        .artifacts
594        .iter()
595        .filter(|other| other.requires.iter().any(|r| r == artifact_id))
596        .map(|a| a.id.clone())
597        .collect();
598    unlocks.sort();
599
600    let template = read_schema_template(&resolved, &a.template)?;
601
602    Ok(InstructionsResponse {
603        change_name: change.to_string(),
604        artifact_id: a.id.clone(),
605        schema_name: resolved.schema.name,
606        change_dir: change_dir.to_string_lossy().to_string(),
607        output_path: a.generates.clone(),
608        description: a.description.clone().unwrap_or_default(),
609        instruction: a.instruction.clone(),
610        template,
611        dependencies: deps,
612        unlocks,
613    })
614}
615
616/// Compute apply-stage instructions and progress for a change.
617pub fn compute_apply_instructions(
618    ito_path: &Path,
619    change: &str,
620    schema_name: Option<&str>,
621    ctx: &ConfigContext,
622) -> Result<ApplyInstructionsResponse, TemplatesError> {
623    if !validate_change_name_input(change) {
624        return Err(TemplatesError::InvalidChangeName);
625    }
626    let schema_name = schema_name
627        .map(|s| s.to_string())
628        .unwrap_or_else(|| read_change_schema(ito_path, change));
629    let resolved = resolve_schema(Some(&schema_name), ctx)?;
630    let change_dir = paths::change_dir(ito_path, change);
631    if !change_dir.exists() {
632        return Err(TemplatesError::ChangeNotFound(change.to_string()));
633    }
634
635    let schema = &resolved.schema;
636    let apply = schema.apply.as_ref();
637    let all_artifact_ids: Vec<String> = schema.artifacts.iter().map(|a| a.id.clone()).collect();
638
639    // Determine required artifacts and tracking file from schema.
640    // Match TS: apply.requires ?? allArtifacts (nullish coalescing).
641    let required_artifact_ids: Vec<String> = apply
642        .and_then(|a| a.requires.clone())
643        .unwrap_or_else(|| all_artifact_ids.clone());
644    let tracks_file: Option<String> = apply.and_then(|a| a.tracks.clone());
645    let schema_instruction: Option<String> = apply.and_then(|a| a.instruction.clone());
646
647    // Check which required artifacts are missing.
648    let mut missing_artifacts: Vec<String> = Vec::new();
649    for artifact_id in &required_artifact_ids {
650        let Some(artifact) = schema.artifacts.iter().find(|a| a.id == *artifact_id) else {
651            continue;
652        };
653        if !artifact_done(&change_dir, &artifact.generates) {
654            missing_artifacts.push(artifact_id.clone());
655        }
656    }
657
658    // Build context files from all existing artifacts in schema.
659    let mut context_files: BTreeMap<String, String> = BTreeMap::new();
660    for artifact in &schema.artifacts {
661        if artifact_done(&change_dir, &artifact.generates) {
662            context_files.insert(
663                artifact.id.clone(),
664                change_dir
665                    .join(&artifact.generates)
666                    .to_string_lossy()
667                    .to_string(),
668            );
669        }
670    }
671
672    // Parse tasks if tracking file exists.
673    let mut tasks: Vec<TaskItem> = Vec::new();
674    let mut tracks_file_exists = false;
675    let mut tracks_path: Option<String> = None;
676    let mut tracks_format: Option<String> = None;
677    let tracks_diagnostics: Option<Vec<TaskDiagnostic>> = None;
678
679    if let Some(tf) = &tracks_file {
680        let p = change_dir.join(tf);
681        tracks_path = Some(p.to_string_lossy().to_string());
682        tracks_file_exists = p.exists();
683        if tracks_file_exists {
684            let content = ito_common::io::read_to_string_std(&p)?;
685            let checkbox = parse_checkbox_tasks(&content);
686            if !checkbox.is_empty() {
687                tracks_format = Some("checkbox".to_string());
688                tasks = checkbox;
689            } else {
690                let enhanced = parse_enhanced_tasks(&content);
691                if !enhanced.is_empty() {
692                    tracks_format = Some("enhanced".to_string());
693                    tasks = enhanced;
694                } else if looks_like_enhanced_tasks(&content) {
695                    tracks_format = Some("enhanced".to_string());
696                } else {
697                    tracks_format = Some("unknown".to_string());
698                }
699            }
700        }
701    }
702
703    // Calculate progress.
704    let total = tasks.len();
705    let complete = tasks.iter().filter(|t| t.done).count();
706    let remaining = total.saturating_sub(complete);
707    let mut in_progress: Option<usize> = None;
708    let mut pending: Option<usize> = None;
709    if tracks_format.as_deref() == Some("enhanced") {
710        let mut in_progress_count = 0;
711        let mut pending_count = 0;
712        for task in &tasks {
713            let Some(status) = task.status.as_deref() else {
714                continue;
715            };
716            let status = status.trim();
717            match status {
718                "in-progress" | "in_progress" | "in progress" => in_progress_count += 1,
719                "pending" => pending_count += 1,
720                _ => {}
721            }
722        }
723        in_progress = Some(in_progress_count);
724        pending = Some(pending_count);
725    }
726    if tracks_format.as_deref() == Some("checkbox") {
727        let mut in_progress_count = 0;
728        for task in &tasks {
729            let Some(status) = task.status.as_deref() else {
730                continue;
731            };
732            if status.trim() == "in-progress" {
733                in_progress_count += 1;
734            }
735        }
736        in_progress = Some(in_progress_count);
737        pending = Some(total.saturating_sub(complete + in_progress_count));
738    }
739    let progress = ProgressInfo {
740        total,
741        complete,
742        remaining,
743        in_progress,
744        pending,
745    };
746
747    // Determine state and instruction.
748    let (state, instruction) = if !missing_artifacts.is_empty() {
749        (
750            "blocked".to_string(),
751            format!(
752                "Cannot apply this change yet. Missing artifacts: {}.\nUse the ito-continue-change skill to create the missing artifacts first.",
753                missing_artifacts.join(", ")
754            ),
755        )
756    } else if tracks_file.is_some() && !tracks_file_exists {
757        let tracks_filename = tracks_file
758            .as_deref()
759            .and_then(|p| Path::new(p).file_name())
760            .map(|s| s.to_string_lossy().to_string())
761            .unwrap_or_else(|| "tasks.md".to_string());
762        (
763            "blocked".to_string(),
764            format!(
765                "The {tracks_filename} file is missing and must be created.\nUse ito-continue-change to generate the tracking file."
766            ),
767        )
768    } else if tracks_file.is_some() && tracks_file_exists && total == 0 {
769        let tracks_filename = tracks_file
770            .as_deref()
771            .and_then(|p| Path::new(p).file_name())
772            .map(|s| s.to_string_lossy().to_string())
773            .unwrap_or_else(|| "tasks.md".to_string());
774        (
775            "blocked".to_string(),
776            format!(
777                "The {tracks_filename} file exists but contains no tasks.\nAdd tasks to {tracks_filename} or regenerate it with ito-continue-change."
778            ),
779        )
780    } else if tracks_file.is_some() && remaining == 0 && total > 0 {
781        (
782            "all_done".to_string(),
783            "All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving."
784                .to_string(),
785        )
786    } else if tracks_file.is_none() {
787        (
788            "ready".to_string(),
789            schema_instruction
790                .as_deref()
791                .map(|s| s.trim().to_string())
792                .unwrap_or_else(|| {
793                    "All required artifacts complete. Proceed with implementation.".to_string()
794                }),
795        )
796    } else {
797        (
798            "ready".to_string(),
799            schema_instruction
800                .as_deref()
801                .map(|s| s.trim().to_string())
802                .unwrap_or_else(|| {
803                    "Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.".to_string()
804                }),
805        )
806    };
807
808    Ok(ApplyInstructionsResponse {
809        change_name: change.to_string(),
810        change_dir: change_dir.to_string_lossy().to_string(),
811        schema_name: schema.name.clone(),
812        tracks_path,
813        tracks_file,
814        tracks_format,
815        tracks_diagnostics,
816        context_files,
817        progress,
818        tasks,
819        state,
820        missing_artifacts: if missing_artifacts.is_empty() {
821            None
822        } else {
823            Some(missing_artifacts)
824        },
825        instruction,
826    })
827}
828
829fn load_schema_yaml(schema_dir: &Path) -> Result<SchemaYaml, WorkflowError> {
830    let s = ito_common::io::read_to_string_std(&schema_dir.join("schema.yaml"))?;
831    Ok(serde_yaml::from_str(&s)?)
832}
833
834fn load_validation_yaml(schema_dir: &Path) -> Result<Option<ValidationYaml>, WorkflowError> {
835    let path = schema_dir.join("validation.yaml");
836    if !path.exists() {
837        return Ok(None);
838    }
839    let s = ito_common::io::read_to_string_std(&path)?;
840    Ok(Some(serde_yaml::from_str(&s)?))
841}
842
843/// Load schema validation configuration when present.
844pub fn load_schema_validation(
845    resolved: &ResolvedSchema,
846) -> Result<Option<ValidationYaml>, WorkflowError> {
847    if resolved.source == SchemaSource::Embedded {
848        return load_embedded_validation_yaml(&resolved.schema.name);
849    }
850    load_validation_yaml(&resolved.schema_dir)
851}
852
853fn compute_done_by_id(change_dir: &Path, schema: &SchemaYaml) -> BTreeMap<String, bool> {
854    let mut out = BTreeMap::new();
855    for a in &schema.artifacts {
856        out.insert(a.id.clone(), artifact_done(change_dir, &a.generates));
857    }
858    out
859}
860
861/// Returns whether an artifact output is present for the given `generates` pattern.
862///
863/// This is used outside the templates module (for example, schema-aware validation) to
864/// reuse the same minimal glob semantics as schema artifact completion.
865pub(crate) fn artifact_done(change_dir: &Path, generates: &str) -> bool {
866    if !generates.contains('*') {
867        return change_dir.join(generates).exists();
868    }
869
870    // Minimal glob support for patterns used by schemas:
871    //   dir/**/*.ext
872    //   dir/*.suffix
873    //   **/*.ext
874    let (base, suffix) = match split_glob_pattern(generates) {
875        Some(v) => v,
876        None => return false,
877    };
878    let base_dir = change_dir.join(base);
879    dir_contains_filename_suffix(&base_dir, &suffix)
880}
881
882fn split_glob_pattern(pattern: &str) -> Option<(String, String)> {
883    let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
884
885    let (dir_part, file_pat) = match pattern.rsplit_once('/') {
886        Some((d, f)) => (d, f),
887        None => ("", pattern),
888    };
889    if !file_pat.starts_with('*') {
890        return None;
891    }
892    let suffix = file_pat[1..].to_string();
893
894    let base = dir_part
895        .strip_suffix("/**")
896        .or_else(|| dir_part.strip_suffix("**"))
897        .unwrap_or(dir_part);
898
899    // If the directory still contains wildcards (e.g. "**"), search from change_dir.
900    let base = if base.contains('*') { "" } else { base };
901    Some((base.to_string(), suffix))
902}
903
904fn dir_contains_filename_suffix(dir: &Path, suffix: &str) -> bool {
905    let Ok(entries) = fs::read_dir(dir) else {
906        return false;
907    };
908    for e in entries.flatten() {
909        let path = e.path();
910        if e.file_type().ok().is_some_and(|t| t.is_dir()) {
911            if dir_contains_filename_suffix(&path, suffix) {
912                return true;
913            }
914            continue;
915        }
916        let name = e.file_name().to_string_lossy().to_string();
917        if name.ends_with(suffix) {
918            return true;
919        }
920    }
921    false
922}
923
924// (intentionally no checkbox counting helpers here; checkbox tasks are parsed into TaskItems)