use super::{ResolvedSchema, SchemaSource, SchemaYaml, ValidationYaml, WorkflowError};
use ito_config::ConfigContext;
use ito_templates::{get_schema_file, schema_files};
use std::collections::BTreeSet;
use std::env;
use std::fs;
use std::path::{Component, Path, PathBuf};
pub(super) fn package_schemas_dir() -> PathBuf {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let root = find_repository_root(&manifest_dir).unwrap_or(manifest_dir);
root.join("schemas")
}
fn find_repository_root(start: &Path) -> Option<PathBuf> {
for ancestor in start.ancestors() {
if ancestor.join(".git").exists() {
return Some(ancestor.to_path_buf());
}
let cargo_toml = ancestor.join("Cargo.toml");
if cargo_toml.exists()
&& fs::read_to_string(&cargo_toml)
.map(|s| s.contains("[workspace]"))
.unwrap_or(false)
{
return Some(ancestor.to_path_buf());
}
}
None
}
pub(super) fn project_schemas_dir(ctx: &ConfigContext) -> Option<PathBuf> {
Some(
ctx.project_dir
.as_ref()?
.join(".ito")
.join("templates")
.join("schemas"),
)
}
pub(super) fn user_schemas_dir(ctx: &ConfigContext) -> Option<PathBuf> {
let data_home = match env::var("XDG_DATA_HOME") {
Ok(v) if !v.trim().is_empty() => Some(PathBuf::from(v)),
_ => ctx
.home_dir
.as_ref()
.map(|h| h.join(".local").join("share")),
}?;
Some(data_home.join("ito").join("schemas"))
}
pub(super) fn embedded_schema_names() -> Vec<String> {
let mut names: BTreeSet<String> = BTreeSet::new();
for file in schema_files() {
let mut parts = file.relative_path.split('/');
let Some(name) = parts.next() else {
continue;
};
if !name.is_empty() {
names.insert(name.to_string());
}
}
names.into_iter().collect()
}
pub(super) fn load_embedded_schema_yaml(name: &str) -> Result<Option<SchemaYaml>, WorkflowError> {
let path = format!("{name}/schema.yaml");
let Some(bytes) = get_schema_file(&path) else {
return Ok(None);
};
let s = std::str::from_utf8(bytes).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("embedded schema is not utf-8 ({path}): {e}"),
)
})?;
let schema = serde_yaml::from_str(s)?;
Ok(Some(schema))
}
pub(super) fn load_embedded_validation_yaml(
name: &str,
) -> Result<Option<ValidationYaml>, WorkflowError> {
let path = format!("{name}/validation.yaml");
let Some(bytes) = get_schema_file(&path) else {
return Ok(None);
};
let s = std::str::from_utf8(bytes).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("embedded validation is not utf-8 ({path}): {e}"),
)
})?;
let parsed = serde_yaml::from_str(s)?;
Ok(Some(parsed))
}
pub(super) fn read_schema_template(
resolved: &ResolvedSchema,
template: &str,
) -> Result<String, WorkflowError> {
if !is_safe_relative_path(template) {
return Err(WorkflowError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid template path: {template}"),
)));
}
if resolved.source == SchemaSource::Embedded {
let path = format!("{}/templates/{template}", resolved.schema.name);
let bytes = get_schema_file(&path).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("embedded template not found: {path}"),
)
})?;
let text = std::str::from_utf8(bytes).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("embedded template is not utf-8 ({path}): {e}"),
)
})?;
return Ok(text.to_string());
}
let path = resolved.schema_dir.join("templates").join(template);
ito_common::io::read_to_string_std(&path).map_err(WorkflowError::from)
}
pub(super) fn is_safe_relative_path(path: &str) -> bool {
if path.is_empty() {
return false;
}
if path.contains('\\') {
return false;
}
let p = Path::new(path);
if p.is_absolute() {
return false;
}
for component in p.components() {
match component {
Component::Normal(_) => {}
Component::CurDir
| Component::ParentDir
| Component::RootDir
| Component::Prefix(_) => {
return false;
}
}
}
true
}
pub(super) fn is_safe_schema_name(name: &str) -> bool {
is_safe_relative_path(name) && !name.contains('.')
}
#[derive(Debug, Clone)]
pub struct ExportSchemasResult {
pub written: usize,
pub skipped: usize,
}
pub fn export_embedded_schemas(
to_dir: &Path,
force: bool,
) -> Result<ExportSchemasResult, WorkflowError> {
let mut written = 0usize;
let mut skipped = 0usize;
for file in schema_files() {
let dest = to_dir.join(file.relative_path);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
if dest.exists() && !force {
skipped += 1;
continue;
}
fs::write(dest, file.contents)?;
written += 1;
}
Ok(ExportSchemasResult { written, skipped })
}
#[cfg(test)]
#[path = "schema_assets_tests.rs"]
mod schema_assets_tests;