use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
mod guidance;
mod review;
mod schema_assets;
mod task_parsing;
mod types;
pub use guidance::{
load_composed_user_guidance, load_user_guidance, load_user_guidance_for_artifact,
};
pub use review::compute_review_context;
pub use schema_assets::{ExportSchemasResult, export_embedded_schemas};
use schema_assets::{
embedded_schema_names, is_safe_relative_path, is_safe_schema_name, load_embedded_schema_yaml,
load_embedded_validation_yaml, package_schemas_dir, project_schemas_dir, read_schema_template,
user_schemas_dir,
};
use task_parsing::{looks_like_enhanced_tasks, parse_checkbox_tasks, parse_enhanced_tasks};
pub use types::{
AgentInstructionResponse, ApplyInstructionsResponse, ApplyYaml, ArtifactStatus, ArtifactYaml,
ChangeStatus, DependencyInfo, InstructionsResponse, PeerReviewContext, ProgressInfo,
ResolvedSchema, ReviewAffectedSpecInfo, ReviewArtifactInfo, ReviewCoveredRequirement,
ReviewTaskSummaryInfo, ReviewTestingPolicy, ReviewTraceabilityInfo, ReviewUnresolvedReference,
ReviewValidationIssueInfo, SchemaSource, SchemaYaml, TaskDiagnostic, TaskItem, TemplateInfo,
ValidationArtifactYaml, ValidationDefaultsYaml, ValidationLevelYaml,
ValidationTrackingSourceYaml, ValidationTrackingYaml, ValidationYaml, ValidatorId,
WorkflowError,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SchemaListEntry {
pub name: String,
pub description: String,
pub artifacts: Vec<String>,
pub source: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SchemaListResponse {
pub schemas: Vec<SchemaListEntry>,
pub recommended_default: String,
}
use ito_common::fs::StdFs;
use ito_common::paths;
use ito_config::ConfigContext;
pub type TemplatesError = WorkflowError;
pub fn default_schema_name() -> &'static str {
"spec-driven"
}
pub fn validate_change_name_input(name: &str) -> bool {
if name.is_empty() {
return false;
}
if name.starts_with('/') || name.starts_with('\\') {
return false;
}
if name.contains('/') || name.contains('\\') {
return false;
}
if name.contains("..") {
return false;
}
true
}
pub fn read_change_schema(ito_path: &Path, change: &str) -> String {
let meta = paths::change_meta_path(ito_path, change);
if let Ok(Some(s)) = ito_common::io::read_to_string_optional(&meta) {
let parsed = crate::change_meta::parse_change_meta_best_effort(&s);
if let Some(schema) = parsed.schema {
return schema;
}
if let Some(schema) = read_legacy_schema_line(&s) {
return schema;
}
}
default_schema_name().to_string()
}
fn read_legacy_schema_line(contents: &str) -> Option<String> {
for line in contents.lines() {
let line = line.trim();
let Some(rest) = line.strip_prefix("schema:") else {
continue;
};
let schema = rest.trim();
if !schema.is_empty() {
return Some(schema.to_string());
}
}
None
}
pub fn list_available_changes(ito_path: &Path) -> Vec<String> {
let fs = StdFs;
ito_domain::discovery::list_change_dir_names(&fs, ito_path).unwrap_or_default()
}
pub fn list_available_schemas(ctx: &ConfigContext) -> Vec<String> {
let mut set: BTreeSet<String> = BTreeSet::new();
let fs = StdFs;
for dir in [
project_schemas_dir(ctx),
user_schemas_dir(ctx),
Some(package_schemas_dir()),
] {
let Some(dir) = dir else { continue };
let Ok(names) = ito_domain::discovery::list_dir_names(&fs, &dir) else {
continue;
};
for name in names {
let schema_dir = dir.join(&name);
if schema_dir.join("schema.yaml").exists() {
set.insert(name);
}
}
}
for name in embedded_schema_names() {
set.insert(name);
}
set.into_iter().collect()
}
pub fn list_schemas_detail(ctx: &ConfigContext) -> SchemaListResponse {
let names = list_available_schemas(ctx);
let mut schemas = Vec::new();
for name in &names {
let Ok(resolved) = resolve_schema(Some(name), ctx) else {
continue;
};
let description = resolved.schema.description.clone().unwrap_or_default();
let artifacts: Vec<String> = resolved
.schema
.artifacts
.iter()
.map(|a| a.id.clone())
.collect();
let source = match resolved.source {
SchemaSource::Project => "project",
SchemaSource::User => "user",
SchemaSource::Embedded => "embedded",
SchemaSource::Package => "package",
}
.to_string();
schemas.push(SchemaListEntry {
name: name.clone(),
description,
artifacts,
source,
});
}
SchemaListResponse {
schemas,
recommended_default: default_schema_name().to_string(),
}
}
pub fn resolve_schema(
schema_name: Option<&str>,
ctx: &ConfigContext,
) -> Result<ResolvedSchema, TemplatesError> {
let name = schema_name.unwrap_or(default_schema_name());
if !is_safe_schema_name(name) {
return Err(WorkflowError::SchemaNotFound(name.to_string()));
}
let project_dir = project_schemas_dir(ctx).map(|d| d.join(name));
if let Some(d) = project_dir
&& d.join("schema.yaml").exists()
{
let schema = load_schema_yaml(&d)?;
return Ok(ResolvedSchema {
schema,
schema_dir: d,
source: SchemaSource::Project,
});
}
let user_dir = user_schemas_dir(ctx).map(|d| d.join(name));
if let Some(d) = user_dir
&& d.join("schema.yaml").exists()
{
let schema = load_schema_yaml(&d)?;
return Ok(ResolvedSchema {
schema,
schema_dir: d,
source: SchemaSource::User,
});
}
if let Some(schema) = load_embedded_schema_yaml(name)? {
return Ok(ResolvedSchema {
schema,
schema_dir: PathBuf::from(format!("embedded://schemas/{name}")),
source: SchemaSource::Embedded,
});
}
let pkg = package_schemas_dir().join(name);
if pkg.join("schema.yaml").exists() {
let schema = load_schema_yaml(&pkg)?;
return Ok(ResolvedSchema {
schema,
schema_dir: pkg,
source: SchemaSource::Package,
});
}
Err(TemplatesError::SchemaNotFound(name.to_string()))
}
pub fn compute_change_status(
ito_path: &Path,
change: &str,
schema_name: Option<&str>,
ctx: &ConfigContext,
) -> Result<ChangeStatus, TemplatesError> {
if !validate_change_name_input(change) {
return Err(TemplatesError::InvalidChangeName);
}
let schema_name = schema_name
.map(|s| s.to_string())
.unwrap_or_else(|| read_change_schema(ito_path, change));
let resolved = resolve_schema(Some(&schema_name), ctx)?;
let change_dir = paths::change_dir(ito_path, change);
if !change_dir.exists() {
return Err(TemplatesError::ChangeNotFound(change.to_string()));
}
let mut artifacts_out: Vec<ArtifactStatus> = Vec::new();
let mut done_count: usize = 0;
let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
let order = build_order(&resolved.schema);
for id in order {
let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
continue;
};
let done = *done_by_id.get(&a.id).unwrap_or(&false);
let mut missing: Vec<String> = Vec::new();
if !done {
for r in &a.requires {
if !*done_by_id.get(r).unwrap_or(&false) {
missing.push(r.clone());
}
}
}
let status = if done {
done_count += 1;
"done".to_string()
} else if missing.is_empty() {
"ready".to_string()
} else {
"blocked".to_string()
};
artifacts_out.push(ArtifactStatus {
id: a.id.clone(),
output_path: a.generates.clone(),
status,
missing_deps: missing,
});
}
let all_artifact_ids: Vec<String> = resolved
.schema
.artifacts
.iter()
.map(|a| a.id.clone())
.collect();
let apply_requires: Vec<String> = match resolved.schema.apply.as_ref() {
Some(apply) => apply
.requires
.clone()
.unwrap_or_else(|| all_artifact_ids.clone()),
None => all_artifact_ids.clone(),
};
let is_complete = done_count == resolved.schema.artifacts.len();
Ok(ChangeStatus {
change_name: change.to_string(),
schema_name: resolved.schema.name,
is_complete,
apply_requires,
artifacts: artifacts_out,
})
}
fn build_order(schema: &SchemaYaml) -> Vec<String> {
let mut in_degree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut dependents: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for a in &schema.artifacts {
in_degree.insert(a.id.clone(), a.requires.len());
dependents.insert(a.id.clone(), Vec::new());
}
for a in &schema.artifacts {
for req in &a.requires {
dependents
.entry(req.clone())
.or_default()
.push(a.id.clone());
}
}
let mut queue: Vec<String> = schema
.artifacts
.iter()
.map(|a| a.id.clone())
.filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
.collect();
queue.sort();
let mut result: Vec<String> = Vec::new();
while !queue.is_empty() {
let current = queue.remove(0);
result.push(current.clone());
let mut newly_ready: Vec<String> = Vec::new();
if let Some(deps) = dependents.get(¤t) {
for dep in deps {
let new_degree = in_degree.get(dep).copied().unwrap_or(0).saturating_sub(1);
in_degree.insert(dep.clone(), new_degree);
if new_degree == 0 {
newly_ready.push(dep.clone());
}
}
}
newly_ready.sort();
queue.extend(newly_ready);
}
result
}
pub fn resolve_templates(
schema_name: Option<&str>,
ctx: &ConfigContext,
) -> Result<(String, BTreeMap<String, TemplateInfo>), TemplatesError> {
let resolved = resolve_schema(schema_name, ctx)?;
let mut templates: BTreeMap<String, TemplateInfo> = BTreeMap::new();
for a in &resolved.schema.artifacts {
if !is_safe_relative_path(&a.template) {
return Err(WorkflowError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid template path: {}", a.template),
)));
}
let path = if resolved.source == SchemaSource::Embedded {
format!(
"embedded://schemas/{}/templates/{}",
resolved.schema.name, a.template
)
} else {
resolved
.schema_dir
.join("templates")
.join(&a.template)
.to_string_lossy()
.to_string()
};
templates.insert(
a.id.clone(),
TemplateInfo {
source: resolved.source.as_str().to_string(),
path,
},
);
}
Ok((resolved.schema.name, templates))
}
pub fn resolve_instructions(
ito_path: &Path,
change: &str,
schema_name: Option<&str>,
artifact_id: &str,
ctx: &ConfigContext,
) -> Result<InstructionsResponse, TemplatesError> {
if !validate_change_name_input(change) {
return Err(TemplatesError::InvalidChangeName);
}
let schema_name = schema_name
.map(|s| s.to_string())
.unwrap_or_else(|| read_change_schema(ito_path, change));
let resolved = resolve_schema(Some(&schema_name), ctx)?;
let change_dir = paths::change_dir(ito_path, change);
if !change_dir.exists() {
return Err(TemplatesError::ChangeNotFound(change.to_string()));
}
let a = resolved
.schema
.artifacts
.iter()
.find(|a| a.id == artifact_id)
.ok_or_else(|| TemplatesError::ArtifactNotFound(artifact_id.to_string()))?;
let done_by_id = compute_done_by_id(&change_dir, &resolved.schema);
let deps: Vec<DependencyInfo> = a
.requires
.iter()
.map(|id| {
let dep = resolved.schema.artifacts.iter().find(|d| d.id == *id);
DependencyInfo {
id: id.clone(),
done: *done_by_id.get(id).unwrap_or(&false),
path: dep
.map(|d| d.generates.clone())
.unwrap_or_else(|| id.clone()),
description: dep.and_then(|d| d.description.clone()).unwrap_or_default(),
}
})
.collect();
let mut unlocks: Vec<String> = resolved
.schema
.artifacts
.iter()
.filter(|other| other.requires.iter().any(|r| r == artifact_id))
.map(|a| a.id.clone())
.collect();
unlocks.sort();
let template = read_schema_template(&resolved, &a.template)?;
Ok(InstructionsResponse {
change_name: change.to_string(),
artifact_id: a.id.clone(),
schema_name: resolved.schema.name,
change_dir: change_dir.to_string_lossy().to_string(),
output_path: a.generates.clone(),
description: a.description.clone().unwrap_or_default(),
instruction: a.instruction.clone(),
template,
dependencies: deps,
unlocks,
})
}
pub fn compute_apply_instructions(
ito_path: &Path,
change: &str,
schema_name: Option<&str>,
ctx: &ConfigContext,
) -> Result<ApplyInstructionsResponse, TemplatesError> {
if !validate_change_name_input(change) {
return Err(TemplatesError::InvalidChangeName);
}
let schema_name = schema_name
.map(|s| s.to_string())
.unwrap_or_else(|| read_change_schema(ito_path, change));
let resolved = resolve_schema(Some(&schema_name), ctx)?;
let change_dir = paths::change_dir(ito_path, change);
if !change_dir.exists() {
return Err(TemplatesError::ChangeNotFound(change.to_string()));
}
let schema = &resolved.schema;
let apply = schema.apply.as_ref();
let all_artifact_ids: Vec<String> = schema.artifacts.iter().map(|a| a.id.clone()).collect();
let required_artifact_ids: Vec<String> = apply
.and_then(|a| a.requires.clone())
.unwrap_or_else(|| all_artifact_ids.clone());
let tracks_file: Option<String> = apply.and_then(|a| a.tracks.clone());
let schema_instruction: Option<String> = apply.and_then(|a| a.instruction.clone());
let mut missing_artifacts: Vec<String> = Vec::new();
for artifact_id in &required_artifact_ids {
let Some(artifact) = schema.artifacts.iter().find(|a| a.id == *artifact_id) else {
continue;
};
if !artifact_done(&change_dir, &artifact.generates) {
missing_artifacts.push(artifact_id.clone());
}
}
let mut context_files: BTreeMap<String, String> = BTreeMap::new();
for artifact in &schema.artifacts {
if artifact_done(&change_dir, &artifact.generates) {
context_files.insert(
artifact.id.clone(),
change_dir
.join(&artifact.generates)
.to_string_lossy()
.to_string(),
);
}
}
let mut tasks: Vec<TaskItem> = Vec::new();
let mut tracks_file_exists = false;
let mut tracks_path: Option<String> = None;
let mut tracks_format: Option<String> = None;
let tracks_diagnostics: Option<Vec<TaskDiagnostic>> = None;
if let Some(tf) = &tracks_file {
let p = change_dir.join(tf);
tracks_path = Some(p.to_string_lossy().to_string());
tracks_file_exists = p.exists();
if tracks_file_exists {
let content = ito_common::io::read_to_string_std(&p)?;
let checkbox = parse_checkbox_tasks(&content);
if !checkbox.is_empty() {
tracks_format = Some("checkbox".to_string());
tasks = checkbox;
} else {
let enhanced = parse_enhanced_tasks(&content);
if !enhanced.is_empty() {
tracks_format = Some("enhanced".to_string());
tasks = enhanced;
} else if looks_like_enhanced_tasks(&content) {
tracks_format = Some("enhanced".to_string());
} else {
tracks_format = Some("unknown".to_string());
}
}
}
}
let total = tasks.len();
let complete = tasks.iter().filter(|t| t.done).count();
let remaining = total.saturating_sub(complete);
let mut in_progress: Option<usize> = None;
let mut pending: Option<usize> = None;
if tracks_format.as_deref() == Some("enhanced") {
let mut in_progress_count = 0;
let mut pending_count = 0;
for task in &tasks {
let Some(status) = task.status.as_deref() else {
continue;
};
let status = status.trim();
match status {
"in-progress" | "in_progress" | "in progress" => in_progress_count += 1,
"pending" => pending_count += 1,
_ => {}
}
}
in_progress = Some(in_progress_count);
pending = Some(pending_count);
}
if tracks_format.as_deref() == Some("checkbox") {
let mut in_progress_count = 0;
for task in &tasks {
let Some(status) = task.status.as_deref() else {
continue;
};
if status.trim() == "in-progress" {
in_progress_count += 1;
}
}
in_progress = Some(in_progress_count);
pending = Some(total.saturating_sub(complete + in_progress_count));
}
let progress = ProgressInfo {
total,
complete,
remaining,
in_progress,
pending,
};
let (state, instruction) = if !missing_artifacts.is_empty() {
(
"blocked".to_string(),
format!(
"Cannot apply this change yet. Missing artifacts: {}.\nUse the ito-continue-change skill to create the missing artifacts first.",
missing_artifacts.join(", ")
),
)
} else if tracks_file.is_some() && !tracks_file_exists {
let tracks_filename = tracks_file
.as_deref()
.and_then(|p| Path::new(p).file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "tasks.md".to_string());
(
"blocked".to_string(),
format!(
"The {tracks_filename} file is missing and must be created.\nUse ito-continue-change to generate the tracking file."
),
)
} else if tracks_file.is_some() && tracks_file_exists && total == 0 {
let tracks_filename = tracks_file
.as_deref()
.and_then(|p| Path::new(p).file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "tasks.md".to_string());
(
"blocked".to_string(),
format!(
"The {tracks_filename} file exists but contains no tasks.\nAdd tasks to {tracks_filename} or regenerate it with ito-continue-change."
),
)
} else if tracks_file.is_some() && remaining == 0 && total > 0 {
(
"all_done".to_string(),
"All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving."
.to_string(),
)
} else if tracks_file.is_none() {
(
"ready".to_string(),
schema_instruction
.as_deref()
.map(|s| s.trim().to_string())
.unwrap_or_else(|| {
"All required artifacts complete. Proceed with implementation.".to_string()
}),
)
} else {
(
"ready".to_string(),
schema_instruction
.as_deref()
.map(|s| s.trim().to_string())
.unwrap_or_else(|| {
"Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.".to_string()
}),
)
};
Ok(ApplyInstructionsResponse {
change_name: change.to_string(),
change_dir: change_dir.to_string_lossy().to_string(),
schema_name: schema.name.clone(),
tracks_path,
tracks_file,
tracks_format,
tracks_diagnostics,
context_files,
progress,
tasks,
state,
missing_artifacts: if missing_artifacts.is_empty() {
None
} else {
Some(missing_artifacts)
},
instruction,
})
}
fn load_schema_yaml(schema_dir: &Path) -> Result<SchemaYaml, WorkflowError> {
let s = ito_common::io::read_to_string_std(&schema_dir.join("schema.yaml"))?;
Ok(serde_yaml::from_str(&s)?)
}
fn load_validation_yaml(schema_dir: &Path) -> Result<Option<ValidationYaml>, WorkflowError> {
let path = schema_dir.join("validation.yaml");
if !path.exists() {
return Ok(None);
}
let s = ito_common::io::read_to_string_std(&path)?;
Ok(Some(serde_yaml::from_str(&s)?))
}
pub fn load_schema_validation(
resolved: &ResolvedSchema,
) -> Result<Option<ValidationYaml>, WorkflowError> {
if resolved.source == SchemaSource::Embedded {
return load_embedded_validation_yaml(&resolved.schema.name);
}
load_validation_yaml(&resolved.schema_dir)
}
fn compute_done_by_id(change_dir: &Path, schema: &SchemaYaml) -> BTreeMap<String, bool> {
let mut out = BTreeMap::new();
for a in &schema.artifacts {
out.insert(a.id.clone(), artifact_done(change_dir, &a.generates));
}
out
}
pub(crate) fn artifact_done(change_dir: &Path, generates: &str) -> bool {
if !generates.contains('*') {
return change_dir.join(generates).exists();
}
let (base, suffix) = match split_glob_pattern(generates) {
Some(v) => v,
None => return false,
};
let base_dir = change_dir.join(base);
dir_contains_filename_suffix(&base_dir, &suffix)
}
fn split_glob_pattern(pattern: &str) -> Option<(String, String)> {
let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
let (dir_part, file_pat) = match pattern.rsplit_once('/') {
Some((d, f)) => (d, f),
None => ("", pattern),
};
if !file_pat.starts_with('*') {
return None;
}
let suffix = file_pat[1..].to_string();
let base = dir_part
.strip_suffix("/**")
.or_else(|| dir_part.strip_suffix("**"))
.unwrap_or(dir_part);
let base = if base.contains('*') { "" } else { base };
Some((base.to_string(), suffix))
}
fn dir_contains_filename_suffix(dir: &Path, suffix: &str) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
for e in entries.flatten() {
let path = e.path();
if e.file_type().ok().is_some_and(|t| t.is_dir()) {
if dir_contains_filename_suffix(&path, suffix) {
return true;
}
continue;
}
let name = e.file_name().to_string_lossy().to_string();
if name.ends_with(suffix) {
return true;
}
}
false
}