Skip to main content

ito_core/workflow/
mod.rs

1//! Schema-driven change workflow helpers.
2//!
3//! This module reads a change directory and a workflow schema (`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 ito_templates::ITO_END_MARKER;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::env;
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use ito_common::fs::StdFs;
18use ito_common::paths;
19use ito_config::ConfigContext;
20
21#[derive(Debug, thiserror::Error)]
22/// Errors returned by workflow helpers.
23pub enum WorkflowError {
24    #[error("Invalid change name")]
25    /// Change name failed basic sanitization.
26    InvalidChangeName,
27
28    #[error("Missing required option --change")]
29    /// The caller did not provide a required change id.
30    MissingChange,
31
32    #[error("Change '{0}' not found")]
33    /// The requested change directory does not exist.
34    ChangeNotFound(String),
35
36    #[error("Schema '{0}' not found")]
37    /// The requested schema name did not resolve to a schema directory.
38    SchemaNotFound(String),
39
40    #[error("Artifact '{0}' not found")]
41    /// The requested artifact id does not exist in the resolved schema.
42    ArtifactNotFound(String),
43
44    #[error(transparent)]
45    /// IO error while reading or writing workflow files.
46    Io(#[from] std::io::Error),
47
48    #[error(transparent)]
49    /// YAML parsing error.
50    Yaml(#[from] serde_yaml::Error),
51}
52
53#[derive(Debug, Clone, Serialize)]
54/// Status for one schema artifact for a given change directory.
55pub struct ArtifactStatus {
56    /// Artifact id from the schema.
57    pub id: String,
58    #[serde(rename = "outputPath")]
59    /// Path (relative to the change directory) the artifact should generate.
60    pub output_path: String,
61
62    /// Computed state: `done`, `ready`, or `blocked`.
63    pub status: String,
64    #[serde(rename = "missingDeps", skip_serializing_if = "Vec::is_empty")]
65    /// Artifact ids that are required but not yet complete.
66    pub missing_deps: Vec<String>,
67}
68
69#[derive(Debug, Clone, Serialize)]
70/// High-level status for a change against a resolved schema.
71pub struct ChangeStatus {
72    #[serde(rename = "changeName")]
73    /// Change directory name.
74    pub change_name: String,
75    #[serde(rename = "schemaName")]
76    /// Resolved schema name.
77    pub schema_name: String,
78    #[serde(rename = "isComplete")]
79    /// Whether all schema artifacts are complete.
80    pub is_complete: bool,
81    #[serde(rename = "applyRequires")]
82    /// Artifacts required before "apply" is allowed.
83    pub apply_requires: Vec<String>,
84
85    /// Per-artifact status entries.
86    pub artifacts: Vec<ArtifactStatus>,
87}
88
89#[derive(Debug, Clone, Serialize)]
90/// Information about an artifact template resolved from a schema.
91pub struct TemplateInfo {
92    /// Template source (`package` or `user`).
93    pub source: String,
94    /// Full path to the template file.
95    pub path: String,
96}
97
98#[derive(Debug, Clone, Serialize)]
99/// One dependency entry shown alongside artifact instructions.
100pub struct DependencyInfo {
101    /// Dependency artifact id.
102    pub id: String,
103    /// Whether the dependency is complete.
104    pub done: bool,
105    /// Dependency output path.
106    pub path: String,
107    /// Optional schema description for the dependency.
108    pub description: String,
109}
110
111#[derive(Debug, Clone, Serialize)]
112/// Instruction payload for building a single artifact.
113pub struct InstructionsResponse {
114    #[serde(rename = "changeName")]
115    /// Change directory name.
116    pub change_name: String,
117    #[serde(rename = "artifactId")]
118    /// Artifact id.
119    pub artifact_id: String,
120    #[serde(rename = "schemaName")]
121    /// Schema name.
122    pub schema_name: String,
123    #[serde(rename = "changeDir")]
124    /// Full path to the change directory.
125    pub change_dir: String,
126    #[serde(rename = "outputPath")]
127    /// Artifact output path (relative to the change directory).
128    pub output_path: String,
129
130    /// Human-readable artifact description.
131    pub description: String,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    /// Optional per-artifact instruction text.
134    pub instruction: Option<String>,
135
136    /// Template contents used to generate the artifact.
137    pub template: String,
138
139    /// Dependency details shown to the caller.
140    pub dependencies: Vec<DependencyInfo>,
141
142    /// Artifact ids that become unblocked once this artifact is complete.
143    pub unlocks: Vec<String>,
144}
145
146#[derive(Debug, Clone, Serialize)]
147/// One task parsed from a tracking file (e.g. `tasks.md`).
148pub struct TaskItem {
149    /// Task id.
150    pub id: String,
151    /// Task description.
152    pub description: String,
153    /// Whether the task is complete.
154    pub done: bool,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    /// Optional workflow status string (format-dependent).
157    pub status: Option<String>,
158}
159
160#[derive(Debug, Clone, Serialize)]
161/// Progress totals derived from parsed tasks.
162pub struct ProgressInfo {
163    /// Total tasks.
164    pub total: usize,
165    /// Completed tasks.
166    pub complete: usize,
167    /// Remaining tasks.
168    pub remaining: usize,
169    #[serde(rename = "inProgress", skip_serializing_if = "Option::is_none")]
170    /// Count of tasks marked in-progress (when known).
171    pub in_progress: Option<usize>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    /// Count of tasks pending (when known).
174    pub pending: Option<usize>,
175}
176
177#[derive(Debug, Clone, Serialize)]
178/// Diagnostic message associated with a task file.
179pub struct TaskDiagnostic {
180    /// Severity level.
181    pub level: String,
182    /// Human-readable message.
183    pub message: String,
184    #[serde(rename = "taskId", skip_serializing_if = "Option::is_none")]
185    /// Optional task id this diagnostic refers to.
186    pub task_id: Option<String>,
187}
188
189#[derive(Debug, Clone, Serialize)]
190/// Instruction payload for applying a change.
191pub struct ApplyInstructionsResponse {
192    #[serde(rename = "changeName")]
193    /// Change directory name.
194    pub change_name: String,
195    #[serde(rename = "changeDir")]
196    /// Full path to the change directory.
197    pub change_dir: String,
198    #[serde(rename = "schemaName")]
199    /// Schema name.
200    pub schema_name: String,
201    #[serde(rename = "tracksPath")]
202    /// Full path to the tracking file if configured.
203    pub tracks_path: Option<String>,
204    #[serde(rename = "tracksFile")]
205    /// Tracking filename relative to the change directory.
206    pub tracks_file: Option<String>,
207    #[serde(rename = "tracksFormat")]
208    /// Detected tracking file format.
209    pub tracks_format: Option<String>,
210    #[serde(rename = "tracksDiagnostics", skip_serializing_if = "Option::is_none")]
211    /// Optional diagnostics produced while parsing the tracking file.
212    pub tracks_diagnostics: Option<Vec<TaskDiagnostic>>,
213
214    /// Machine-readable state label.
215    pub state: String,
216    #[serde(rename = "contextFiles")]
217    /// Map of artifact id to full path for context files.
218    pub context_files: BTreeMap<String, String>,
219
220    /// Task progress totals.
221    pub progress: ProgressInfo,
222
223    /// Parsed tasks.
224    pub tasks: Vec<TaskItem>,
225    #[serde(rename = "missingArtifacts", skip_serializing_if = "Option::is_none")]
226    /// Missing artifacts that block applying the change.
227    pub missing_artifacts: Option<Vec<String>>,
228
229    /// Human-readable instruction to display to the user.
230    pub instruction: String,
231}
232
233#[derive(Debug, Clone, Serialize)]
234/// Instruction payload for agent-oriented endpoints.
235pub struct AgentInstructionResponse {
236    #[serde(rename = "artifactId")]
237    /// Artifact id.
238    pub artifact_id: String,
239
240    /// Instruction text.
241    pub instruction: String,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245/// Where a schema was resolved from.
246pub enum SchemaSource {
247    /// Schema provided by the Ito package/repository.
248    Package,
249    /// Schema provided by the user (XDG data dir).
250    User,
251}
252
253impl SchemaSource {
254    /// Return a stable string identifier for serialization.
255    pub fn as_str(self) -> &'static str {
256        match self {
257            SchemaSource::Package => "package",
258            SchemaSource::User => "user",
259        }
260    }
261}
262
263#[derive(Debug, Clone)]
264/// A fully-resolved schema (yaml + directory + source).
265pub struct ResolvedSchema {
266    /// Parsed schema yaml.
267    pub schema: SchemaYaml,
268    /// Directory containing `schema.yaml`.
269    pub schema_dir: PathBuf,
270    /// Where the schema was found.
271    pub source: SchemaSource,
272}
273
274#[derive(Debug, Clone, Deserialize)]
275/// Schema file model (`schema.yaml`).
276pub struct SchemaYaml {
277    /// Schema name.
278    pub name: String,
279    #[serde(default)]
280    /// Optional schema version.
281    pub version: Option<u32>,
282    #[serde(default)]
283    /// Optional schema description.
284    pub description: Option<String>,
285
286    /// Artifact definitions.
287    pub artifacts: Vec<ArtifactYaml>,
288    #[serde(default)]
289    /// Optional apply-stage configuration.
290    pub apply: Option<ApplyYaml>,
291}
292
293#[derive(Debug, Clone, Deserialize)]
294/// One artifact definition from a schema.
295pub struct ArtifactYaml {
296    /// Artifact id.
297    pub id: String,
298    /// Output path pattern relative to the change dir.
299    pub generates: String,
300    #[serde(default)]
301    /// Optional human-readable description.
302    pub description: Option<String>,
303    /// Template filename within the schema templates directory.
304    pub template: String,
305    #[serde(default)]
306    /// Optional additional instruction text.
307    pub instruction: Option<String>,
308    #[serde(default)]
309    /// Artifact ids that must be completed first.
310    pub requires: Vec<String>,
311}
312
313#[derive(Debug, Clone, Deserialize)]
314/// Apply-stage configuration from a schema.
315pub struct ApplyYaml {
316    #[serde(default)]
317    /// Artifacts required to consider the change ready to apply.
318    pub requires: Option<Vec<String>>,
319    #[serde(default)]
320    /// Optional task tracking filename (relative to change dir).
321    pub tracks: Option<String>,
322    #[serde(default)]
323    /// Optional instruction text displayed during apply.
324    pub instruction: Option<String>,
325}
326
327/// Default schema name used when a change does not specify one.
328pub fn default_schema_name() -> &'static str {
329    "spec-driven"
330}
331
332/// Validate a user-provided change id for safe filesystem access.
333pub fn validate_change_name_input(name: &str) -> bool {
334    if name.is_empty() {
335        return false;
336    }
337    if name.starts_with('/') || name.starts_with('\\') {
338        return false;
339    }
340    if name.contains('/') || name.contains('\\') {
341        return false;
342    }
343    if name.contains("..") {
344        return false;
345    }
346    true
347}
348
349/// Read a change's configured schema name from its metadata.
350pub fn read_change_schema(ito_path: &Path, change: &str) -> String {
351    let meta = paths::change_meta_path(ito_path, change);
352    if let Ok(Some(s)) = ito_common::io::read_to_string_optional(&meta) {
353        for line in s.lines() {
354            let l = line.trim();
355            if let Some(rest) = l.strip_prefix("schema:") {
356                let v = rest.trim();
357                if !v.is_empty() {
358                    return v.to_string();
359                }
360            }
361        }
362    }
363    default_schema_name().to_string()
364}
365
366/// List available change directory names under `.ito/changes/`.
367pub fn list_available_changes(ito_path: &Path) -> Vec<String> {
368    let fs = StdFs;
369    ito_domain::discovery::list_change_dir_names(&fs, ito_path).unwrap_or_default()
370}
371
372/// List available schema names from package and user schema directories.
373pub fn list_available_schemas(ctx: &ConfigContext) -> Vec<String> {
374    let mut set: BTreeSet<String> = BTreeSet::new();
375    let fs = StdFs;
376    for dir in [Some(package_schemas_dir()), user_schemas_dir(ctx)] {
377        let Some(dir) = dir else { continue };
378        let Ok(names) = ito_domain::discovery::list_dir_names(&fs, &dir) else {
379            continue;
380        };
381        for name in names {
382            let schema_dir = dir.join(&name);
383            if schema_dir.join("schema.yaml").exists() {
384                set.insert(name);
385            }
386        }
387    }
388    set.into_iter().collect()
389}
390
391/// Resolve a schema name into a [`ResolvedSchema`].
392///
393/// User schemas take precedence over package schemas.
394pub fn resolve_schema(
395    schema_name: Option<&str>,
396    ctx: &ConfigContext,
397) -> Result<ResolvedSchema, WorkflowError> {
398    let name = schema_name.unwrap_or(default_schema_name());
399    let user_dir = user_schemas_dir(ctx).map(|d| d.join(name));
400    if let Some(d) = user_dir
401        && d.join("schema.yaml").exists()
402    {
403        let schema = load_schema_yaml(&d)?;
404        return Ok(ResolvedSchema {
405            schema,
406            schema_dir: d,
407            source: SchemaSource::User,
408        });
409    }
410
411    let pkg = package_schemas_dir().join(name);
412    if pkg.join("schema.yaml").exists() {
413        let schema = load_schema_yaml(&pkg)?;
414        return Ok(ResolvedSchema {
415            schema,
416            schema_dir: pkg,
417            source: SchemaSource::Package,
418        });
419    }
420
421    Err(WorkflowError::SchemaNotFound(name.to_string()))
422}
423
424/// Compute per-artifact status for a change.
425pub fn compute_change_status(
426    ito_path: &Path,
427    change: &str,
428    schema_name: Option<&str>,
429    ctx: &ConfigContext,
430) -> Result<ChangeStatus, WorkflowError> {
431    if !validate_change_name_input(change) {
432        return Err(WorkflowError::InvalidChangeName);
433    }
434    let schema_name = schema_name
435        .map(|s| s.to_string())
436        .unwrap_or_else(|| read_change_schema(ito_path, change));
437    let resolved = resolve_schema(Some(&schema_name), ctx)?;
438
439    let change_dir = paths::change_dir(ito_path, change);
440    if !change_dir.exists() {
441        return Err(WorkflowError::ChangeNotFound(change.to_string()));
442    }
443
444    let mut artifacts_out: Vec<ArtifactStatus> = Vec::new();
445    let mut done_count: usize = 0;
446    let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
447
448    let order = build_order(&resolved.schema);
449    for id in order {
450        let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
451            continue;
452        };
453        let done = *done_by_id.get(&a.id).unwrap_or(&false);
454        let mut missing: Vec<String> = Vec::new();
455        if !done {
456            for r in &a.requires {
457                if !*done_by_id.get(r).unwrap_or(&false) {
458                    missing.push(r.clone());
459                }
460            }
461        }
462
463        let status = if done {
464            done_count += 1;
465            "done".to_string()
466        } else if missing.is_empty() {
467            "ready".to_string()
468        } else {
469            "blocked".to_string()
470        };
471        artifacts_out.push(ArtifactStatus {
472            id: a.id.clone(),
473            output_path: a.generates.clone(),
474            status,
475            missing_deps: missing,
476        });
477    }
478
479    let all_artifact_ids: Vec<String> = resolved
480        .schema
481        .artifacts
482        .iter()
483        .map(|a| a.id.clone())
484        .collect();
485    let apply_requires: Vec<String> = match resolved.schema.apply.as_ref() {
486        Some(apply) => apply
487            .requires
488            .clone()
489            .unwrap_or_else(|| all_artifact_ids.clone()),
490        None => all_artifact_ids.clone(),
491    };
492
493    let is_complete = done_count == resolved.schema.artifacts.len();
494    Ok(ChangeStatus {
495        change_name: change.to_string(),
496        schema_name: resolved.schema.name,
497        is_complete,
498        apply_requires,
499        artifacts: artifacts_out,
500    })
501}
502
503fn build_order(schema: &SchemaYaml) -> Vec<String> {
504    // Match TS ArtifactGraph.getBuildOrder (Kahn's algorithm with deterministic sorting
505    // of roots + newlyReady only).
506    let mut in_degree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
507    let mut dependents: std::collections::HashMap<String, Vec<String>> =
508        std::collections::HashMap::new();
509
510    for a in &schema.artifacts {
511        in_degree.insert(a.id.clone(), a.requires.len());
512        dependents.insert(a.id.clone(), Vec::new());
513    }
514    for a in &schema.artifacts {
515        for req in &a.requires {
516            dependents
517                .entry(req.clone())
518                .or_default()
519                .push(a.id.clone());
520        }
521    }
522
523    let mut queue: Vec<String> = schema
524        .artifacts
525        .iter()
526        .map(|a| a.id.clone())
527        .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
528        .collect();
529    queue.sort();
530
531    let mut result: Vec<String> = Vec::new();
532    while !queue.is_empty() {
533        let current = queue.remove(0);
534        result.push(current.clone());
535
536        let mut newly_ready: Vec<String> = Vec::new();
537        if let Some(deps) = dependents.get(&current) {
538            for dep in deps {
539                let new_degree = in_degree.get(dep).copied().unwrap_or(0).saturating_sub(1);
540                in_degree.insert(dep.clone(), new_degree);
541                if new_degree == 0 {
542                    newly_ready.push(dep.clone());
543                }
544            }
545        }
546        newly_ready.sort();
547        queue.extend(newly_ready);
548    }
549
550    result
551}
552
553/// Resolve template file paths for all artifacts in a schema.
554pub fn resolve_templates(
555    schema_name: Option<&str>,
556    ctx: &ConfigContext,
557) -> Result<(String, BTreeMap<String, TemplateInfo>), WorkflowError> {
558    let resolved = resolve_schema(schema_name, ctx)?;
559    let templates_dir = resolved.schema_dir.join("templates");
560
561    let mut templates: BTreeMap<String, TemplateInfo> = BTreeMap::new();
562    for a in &resolved.schema.artifacts {
563        templates.insert(
564            a.id.clone(),
565            TemplateInfo {
566                source: resolved.source.as_str().to_string(),
567                path: templates_dir
568                    .join(&a.template)
569                    .to_string_lossy()
570                    .to_string(),
571            },
572        );
573    }
574    Ok((resolved.schema.name, templates))
575}
576
577/// Resolve build instructions for a single artifact.
578pub fn resolve_instructions(
579    ito_path: &Path,
580    change: &str,
581    schema_name: Option<&str>,
582    artifact_id: &str,
583    ctx: &ConfigContext,
584) -> Result<InstructionsResponse, WorkflowError> {
585    if !validate_change_name_input(change) {
586        return Err(WorkflowError::InvalidChangeName);
587    }
588    let schema_name = schema_name
589        .map(|s| s.to_string())
590        .unwrap_or_else(|| read_change_schema(ito_path, change));
591    let resolved = resolve_schema(Some(&schema_name), ctx)?;
592
593    let change_dir = paths::change_dir(ito_path, change);
594    if !change_dir.exists() {
595        return Err(WorkflowError::ChangeNotFound(change.to_string()));
596    }
597
598    let a = resolved
599        .schema
600        .artifacts
601        .iter()
602        .find(|a| a.id == artifact_id)
603        .ok_or_else(|| WorkflowError::ArtifactNotFound(artifact_id.to_string()))?;
604
605    let templates_dir = resolved.schema_dir.join("templates");
606    let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
607
608    let deps: Vec<DependencyInfo> = a
609        .requires
610        .iter()
611        .map(|id| {
612            let dep = resolved.schema.artifacts.iter().find(|d| d.id == *id);
613            DependencyInfo {
614                id: id.clone(),
615                done: *done_by_id.get(id).unwrap_or(&false),
616                path: dep
617                    .map(|d| d.generates.clone())
618                    .unwrap_or_else(|| id.clone()),
619                description: dep.and_then(|d| d.description.clone()).unwrap_or_default(),
620            }
621        })
622        .collect();
623
624    let mut unlocks: Vec<String> = resolved
625        .schema
626        .artifacts
627        .iter()
628        .filter(|other| other.requires.iter().any(|r| r == artifact_id))
629        .map(|a| a.id.clone())
630        .collect();
631    unlocks.sort();
632
633    let template = ito_common::io::read_to_string_std(&templates_dir.join(&a.template))?;
634
635    Ok(InstructionsResponse {
636        change_name: change.to_string(),
637        artifact_id: a.id.clone(),
638        schema_name: resolved.schema.name,
639        change_dir: change_dir.to_string_lossy().to_string(),
640        output_path: a.generates.clone(),
641        description: a.description.clone().unwrap_or_default(),
642        instruction: a.instruction.clone(),
643        template,
644        dependencies: deps,
645        unlocks,
646    })
647}
648
649/// Compute apply-stage instructions and progress for a change.
650pub fn compute_apply_instructions(
651    ito_path: &Path,
652    change: &str,
653    schema_name: Option<&str>,
654    ctx: &ConfigContext,
655) -> Result<ApplyInstructionsResponse, WorkflowError> {
656    if !validate_change_name_input(change) {
657        return Err(WorkflowError::InvalidChangeName);
658    }
659    let schema_name = schema_name
660        .map(|s| s.to_string())
661        .unwrap_or_else(|| read_change_schema(ito_path, change));
662    let resolved = resolve_schema(Some(&schema_name), ctx)?;
663    let change_dir = paths::change_dir(ito_path, change);
664    if !change_dir.exists() {
665        return Err(WorkflowError::ChangeNotFound(change.to_string()));
666    }
667
668    let schema = &resolved.schema;
669    let apply = schema.apply.as_ref();
670    let all_artifact_ids: Vec<String> = schema.artifacts.iter().map(|a| a.id.clone()).collect();
671
672    // Determine required artifacts and tracking file from schema.
673    // Match TS: apply.requires ?? allArtifacts (nullish coalescing).
674    let required_artifact_ids: Vec<String> = apply
675        .and_then(|a| a.requires.clone())
676        .unwrap_or_else(|| all_artifact_ids.clone());
677    let tracks_file: Option<String> = apply.and_then(|a| a.tracks.clone());
678    let schema_instruction: Option<String> = apply.and_then(|a| a.instruction.clone());
679
680    // Check which required artifacts are missing.
681    let mut missing_artifacts: Vec<String> = Vec::new();
682    for artifact_id in &required_artifact_ids {
683        let Some(artifact) = schema.artifacts.iter().find(|a| a.id == *artifact_id) else {
684            continue;
685        };
686        if !artifact_done(&change_dir, &artifact.generates) {
687            missing_artifacts.push(artifact_id.clone());
688        }
689    }
690
691    // Build context files from all existing artifacts in schema.
692    let mut context_files: BTreeMap<String, String> = BTreeMap::new();
693    for artifact in &schema.artifacts {
694        if artifact_done(&change_dir, &artifact.generates) {
695            context_files.insert(
696                artifact.id.clone(),
697                change_dir
698                    .join(&artifact.generates)
699                    .to_string_lossy()
700                    .to_string(),
701            );
702        }
703    }
704
705    // Parse tasks if tracking file exists.
706    let mut tasks: Vec<TaskItem> = Vec::new();
707    let mut tracks_file_exists = false;
708    let mut tracks_path: Option<String> = None;
709    let mut tracks_format: Option<String> = None;
710    let tracks_diagnostics: Option<Vec<TaskDiagnostic>> = None;
711
712    if let Some(tf) = &tracks_file {
713        let p = change_dir.join(tf);
714        tracks_path = Some(p.to_string_lossy().to_string());
715        tracks_file_exists = p.exists();
716        if tracks_file_exists {
717            let content = ito_common::io::read_to_string_std(&p)?;
718            let checkbox = parse_checkbox_tasks(&content);
719            if !checkbox.is_empty() {
720                tracks_format = Some("checkbox".to_string());
721                tasks = checkbox;
722            } else {
723                let enhanced = parse_enhanced_tasks(&content);
724                if !enhanced.is_empty() {
725                    tracks_format = Some("enhanced".to_string());
726                    tasks = enhanced;
727                } else if looks_like_enhanced_tasks(&content) {
728                    tracks_format = Some("enhanced".to_string());
729                } else {
730                    tracks_format = Some("unknown".to_string());
731                }
732            }
733        }
734    }
735
736    // Calculate progress.
737    let total = tasks.len();
738    let complete = tasks.iter().filter(|t| t.done).count();
739    let remaining = total.saturating_sub(complete);
740    let mut in_progress: Option<usize> = None;
741    let mut pending: Option<usize> = None;
742    if tracks_format.as_deref() == Some("enhanced") {
743        let mut in_progress_count = 0;
744        let mut pending_count = 0;
745        for task in &tasks {
746            let Some(status) = task.status.as_deref() else {
747                continue;
748            };
749            let status = status.trim();
750            match status {
751                "in-progress" | "in_progress" | "in progress" => in_progress_count += 1,
752                "pending" => pending_count += 1,
753                _ => {}
754            }
755        }
756        in_progress = Some(in_progress_count);
757        pending = Some(pending_count);
758    }
759    if tracks_format.as_deref() == Some("checkbox") {
760        let mut in_progress_count = 0;
761        for task in &tasks {
762            let Some(status) = task.status.as_deref() else {
763                continue;
764            };
765            if status.trim() == "in-progress" {
766                in_progress_count += 1;
767            }
768        }
769        in_progress = Some(in_progress_count);
770        pending = Some(total.saturating_sub(complete + in_progress_count));
771    }
772    let progress = ProgressInfo {
773        total,
774        complete,
775        remaining,
776        in_progress,
777        pending,
778    };
779
780    // Determine state and instruction.
781    let (state, instruction) = if !missing_artifacts.is_empty() {
782        (
783            "blocked".to_string(),
784            format!(
785                "Cannot apply this change yet. Missing artifacts: {}.\nUse the ito-continue-change skill to create the missing artifacts first.",
786                missing_artifacts.join(", ")
787            ),
788        )
789    } else if tracks_file.is_some() && !tracks_file_exists {
790        let tracks_filename = tracks_file
791            .as_deref()
792            .and_then(|p| Path::new(p).file_name())
793            .map(|s| s.to_string_lossy().to_string())
794            .unwrap_or_else(|| "tasks.md".to_string());
795        (
796            "blocked".to_string(),
797            format!(
798                "The {tracks_filename} file is missing and must be created.\nUse ito-continue-change to generate the tracking file."
799            ),
800        )
801    } else if tracks_file.is_some() && tracks_file_exists && total == 0 {
802        let tracks_filename = tracks_file
803            .as_deref()
804            .and_then(|p| Path::new(p).file_name())
805            .map(|s| s.to_string_lossy().to_string())
806            .unwrap_or_else(|| "tasks.md".to_string());
807        (
808            "blocked".to_string(),
809            format!(
810                "The {tracks_filename} file exists but contains no tasks.\nAdd tasks to {tracks_filename} or regenerate it with ito-continue-change."
811            ),
812        )
813    } else if tracks_file.is_some() && remaining == 0 && total > 0 {
814        (
815            "all_done".to_string(),
816            "All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving."
817                .to_string(),
818        )
819    } else if tracks_file.is_none() {
820        (
821            "ready".to_string(),
822            schema_instruction
823                .as_deref()
824                .map(|s| s.trim().to_string())
825                .unwrap_or_else(|| {
826                    "All required artifacts complete. Proceed with implementation.".to_string()
827                }),
828        )
829    } else {
830        (
831            "ready".to_string(),
832            schema_instruction
833                .as_deref()
834                .map(|s| s.trim().to_string())
835                .unwrap_or_else(|| {
836                    "Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.".to_string()
837                }),
838        )
839    };
840
841    Ok(ApplyInstructionsResponse {
842        change_name: change.to_string(),
843        change_dir: change_dir.to_string_lossy().to_string(),
844        schema_name: schema.name.clone(),
845        tracks_path,
846        tracks_file,
847        tracks_format,
848        tracks_diagnostics,
849        context_files,
850        progress,
851        tasks,
852        state,
853        missing_artifacts: if missing_artifacts.is_empty() {
854            None
855        } else {
856            Some(missing_artifacts)
857        },
858        instruction,
859    })
860}
861
862fn parse_checkbox_tasks(contents: &str) -> Vec<TaskItem> {
863    let mut tasks: Vec<TaskItem> = Vec::new();
864    for line in contents.lines() {
865        let l = line.trim_start();
866        let bytes = l.as_bytes();
867        if bytes.len() < 6 {
868            continue;
869        }
870        let bullet = bytes[0] as char;
871        if bullet != '-' && bullet != '*' {
872            continue;
873        }
874        if bytes[1] != b' ' || bytes[2] != b'[' || bytes[4] != b']' || bytes[5] != b' ' {
875            continue;
876        }
877
878        let marker = bytes[3] as char;
879        let (done, rest, status) = match marker {
880            'x' | 'X' => (true, &l[6..], None),
881            ' ' => (false, &l[6..], None),
882            '~' | '>' => (false, &l[6..], Some("in-progress".to_string())),
883            _ => continue,
884        };
885        tasks.push(TaskItem {
886            id: (tasks.len() + 1).to_string(),
887            description: rest.trim().to_string(),
888            done,
889            status,
890        });
891    }
892    tasks
893}
894
895fn looks_like_enhanced_tasks(contents: &str) -> bool {
896    for line in contents.lines() {
897        let l = line.trim_start();
898        if l.starts_with("### Task ") {
899            return true;
900        }
901    }
902    false
903}
904
905fn parse_enhanced_tasks(contents: &str) -> Vec<TaskItem> {
906    let mut tasks: Vec<TaskItem> = Vec::new();
907    let mut current_id: Option<String> = None;
908    let mut current_desc: Option<String> = None;
909    let mut current_done = false;
910    let mut current_status: Option<String> = None;
911
912    fn push_current(
913        tasks: &mut Vec<TaskItem>,
914        current_id: &mut Option<String>,
915        current_desc: &mut Option<String>,
916        current_done: &mut bool,
917        current_status: &mut Option<String>,
918    ) {
919        let Some(desc) = current_desc.take() else {
920            current_id.take();
921            *current_done = false;
922            *current_status = None;
923            return;
924        };
925        let id = current_id
926            .take()
927            .filter(|s| !s.trim().is_empty())
928            .unwrap_or_else(|| (tasks.len() + 1).to_string());
929        tasks.push(TaskItem {
930            id,
931            description: desc,
932            done: *current_done,
933            status: current_status.take(),
934        });
935        *current_done = false;
936    }
937
938    for line in contents.lines() {
939        let l = line.trim_start();
940
941        if let Some(rest) = l.strip_prefix("### Task ") {
942            push_current(
943                &mut tasks,
944                &mut current_id,
945                &mut current_desc,
946                &mut current_done,
947                &mut current_status,
948            );
949
950            let (id, desc) = rest.split_once(':').unwrap_or((rest, ""));
951            let id = id.trim();
952            let desc = if desc.trim().is_empty() {
953                rest.trim()
954            } else {
955                desc.trim()
956            };
957
958            current_id = Some(id.to_string());
959            current_desc = Some(desc.to_string());
960            current_done = false;
961            current_status = Some("pending".to_string());
962            continue;
963        }
964
965        if let Some(rest) = l.strip_prefix("- **Status**:") {
966            let status = rest.trim();
967            if let Some(status) = status
968                .strip_prefix("[x]")
969                .or_else(|| status.strip_prefix("[X]"))
970            {
971                current_done = true;
972                current_status = Some(status.trim().to_string());
973                continue;
974            }
975            if let Some(status) = status.strip_prefix("[ ]") {
976                current_done = false;
977                current_status = Some(status.trim().to_string());
978                continue;
979            }
980        }
981    }
982
983    push_current(
984        &mut tasks,
985        &mut current_id,
986        &mut current_desc,
987        &mut current_done,
988        &mut current_status,
989    );
990
991    tasks
992}
993
994/// Load user guidance text from `user-guidance.md`.
995///
996/// When a file contains an Ito-managed header block, only the content after the
997/// `ITO_END_MARKER` is returned.
998pub fn load_user_guidance(ito_path: &Path) -> Result<Option<String>, WorkflowError> {
999    let path = ito_path.join("user-guidance.md");
1000    if !path.exists() {
1001        return Ok(None);
1002    }
1003
1004    let content = ito_common::io::read_to_string_std(&path)?;
1005    let content = content.replace("\r\n", "\n");
1006    let content = match content.find(ITO_END_MARKER) {
1007        Some(i) => &content[i + ITO_END_MARKER.len()..],
1008        None => content.as_str(),
1009    };
1010    let content = content.trim();
1011    if content.is_empty() {
1012        return Ok(None);
1013    }
1014
1015    Ok(Some(content.to_string()))
1016}
1017
1018fn package_schemas_dir() -> PathBuf {
1019    // In this repo, schemas live at the repository root.
1020    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1021    let root = manifest_dir
1022        .ancestors()
1023        .nth(3)
1024        .unwrap_or(manifest_dir.as_path());
1025    root.join("schemas")
1026}
1027
1028fn user_schemas_dir(ctx: &ConfigContext) -> Option<PathBuf> {
1029    let data_home = match env::var("XDG_DATA_HOME") {
1030        Ok(v) if !v.trim().is_empty() => Some(PathBuf::from(v)),
1031        _ => ctx
1032            .home_dir
1033            .as_ref()
1034            .map(|h| h.join(".local").join("share")),
1035    }?;
1036    Some(data_home.join("ito").join("schemas"))
1037}
1038
1039fn load_schema_yaml(schema_dir: &Path) -> Result<SchemaYaml, WorkflowError> {
1040    let s = ito_common::io::read_to_string_std(&schema_dir.join("schema.yaml"))?;
1041    Ok(serde_yaml::from_str(&s)?)
1042}
1043
1044fn compute_done_by_id(change_dir: &Path, schema: &SchemaYaml) -> BTreeMap<String, bool> {
1045    let mut out = BTreeMap::new();
1046    for a in &schema.artifacts {
1047        out.insert(a.id.clone(), artifact_done(change_dir, &a.generates));
1048    }
1049    out
1050}
1051
1052fn artifact_done(change_dir: &Path, generates: &str) -> bool {
1053    if !generates.contains('*') {
1054        return change_dir.join(generates).exists();
1055    }
1056
1057    // Minimal glob support for patterns used by schemas:
1058    //   dir/**/*.ext
1059    //   dir/*.suffix
1060    //   **/*.ext
1061    let (base, suffix) = match split_glob_pattern(generates) {
1062        Some(v) => v,
1063        None => return false,
1064    };
1065    let base_dir = change_dir.join(base);
1066    dir_contains_filename_suffix(&base_dir, &suffix)
1067}
1068
1069fn split_glob_pattern(pattern: &str) -> Option<(String, String)> {
1070    let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
1071
1072    let (dir_part, file_pat) = match pattern.rsplit_once('/') {
1073        Some((d, f)) => (d, f),
1074        None => ("", pattern),
1075    };
1076    if !file_pat.starts_with('*') {
1077        return None;
1078    }
1079    let suffix = file_pat[1..].to_string();
1080
1081    let base = dir_part
1082        .strip_suffix("/**")
1083        .or_else(|| dir_part.strip_suffix("**"))
1084        .unwrap_or(dir_part);
1085
1086    // If the directory still contains wildcards (e.g. "**"), search from change_dir.
1087    let base = if base.contains('*') { "" } else { base };
1088    Some((base.to_string(), suffix))
1089}
1090
1091fn dir_contains_filename_suffix(dir: &Path, suffix: &str) -> bool {
1092    let Ok(entries) = fs::read_dir(dir) else {
1093        return false;
1094    };
1095    for e in entries.flatten() {
1096        let path = e.path();
1097        if e.file_type().ok().is_some_and(|t| t.is_dir()) {
1098            if dir_contains_filename_suffix(&path, suffix) {
1099                return true;
1100            }
1101            continue;
1102        }
1103        let name = e.file_name().to_string_lossy().to_string();
1104        if name.ends_with(suffix) {
1105            return true;
1106        }
1107    }
1108    false
1109}
1110
1111// (intentionally no checkbox counting helpers here; checkbox tasks are parsed into TaskItems)
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116
1117    #[test]
1118    fn load_user_guidance_returns_trimmed_content_after_marker() {
1119        let dir = tempfile::tempdir().expect("tempdir should succeed");
1120        let ito_path = dir.path();
1121
1122        let content = "<!-- ITO:START -->\nheader\n<!-- ITO:END -->\n\nPrefer BDD.\n";
1123        std::fs::write(ito_path.join("user-guidance.md"), content).expect("write should succeed");
1124
1125        let guidance = load_user_guidance(ito_path)
1126            .expect("load should succeed")
1127            .expect("should be present");
1128
1129        assert_eq!(guidance, "Prefer BDD.");
1130    }
1131
1132    #[test]
1133    fn parse_enhanced_tasks_extracts_ids_status_and_done() {
1134        let contents = r#"### Task 1.1: First
1135 - **Status**: [x] complete
1136
1137 ### Task 1.2: Second
1138 - **Status**: [ ] in-progress
1139 "#;
1140
1141        let tasks = parse_enhanced_tasks(contents);
1142        assert_eq!(tasks.len(), 2);
1143        assert_eq!(tasks[0].id, "1.1");
1144        assert_eq!(tasks[0].description, "First");
1145        assert!(tasks[0].done);
1146        assert_eq!(tasks[0].status.as_deref(), Some("complete"));
1147
1148        assert_eq!(tasks[1].id, "1.2");
1149        assert_eq!(tasks[1].description, "Second");
1150        assert!(!tasks[1].done);
1151        assert_eq!(tasks[1].status.as_deref(), Some("in-progress"));
1152    }
1153}