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