Skip to main content

ito_core/templates/
schema_assets.rs

1use super::{ResolvedSchema, SchemaSource, SchemaYaml, ValidationYaml, WorkflowError};
2use ito_config::ConfigContext;
3use ito_templates::{get_schema_file, schema_files};
4use std::collections::BTreeSet;
5use std::env;
6use std::fs;
7use std::path::{Component, Path, PathBuf};
8
9/// Repository root's `schemas` directory path.
10///
11/// Returns a `PathBuf` pointing to the repository root's `schemas` subdirectory.
12/// The repository root is discovered by searching ancestors of `CARGO_MANIFEST_DIR`
13/// for a `.git` marker or a workspace `Cargo.toml`. If no marker is found,
14/// the manifest directory itself is used as the fallback root.
15///
16/// # Examples
17///
18/// ```ignore
19/// let dir = package_schemas_dir();
20/// assert!(dir.ends_with("schemas"));
21/// ```
22pub(super) fn package_schemas_dir() -> PathBuf {
23    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
24    let root = find_repository_root(&manifest_dir).unwrap_or(manifest_dir);
25    root.join("schemas")
26}
27
28fn find_repository_root(start: &Path) -> Option<PathBuf> {
29    for ancestor in start.ancestors() {
30        if ancestor.join(".git").exists() {
31            return Some(ancestor.to_path_buf());
32        }
33
34        let cargo_toml = ancestor.join("Cargo.toml");
35        if cargo_toml.exists()
36            && fs::read_to_string(&cargo_toml)
37                .map(|s| s.contains("[workspace]"))
38                .unwrap_or(false)
39        {
40            return Some(ancestor.to_path_buf());
41        }
42    }
43
44    None
45}
46
47/// Compute the project-specific schemas directory when a project directory is configured.
48///
49/// If `ctx.project_dir` is present, returns `Some(path)` where `path` is `project_dir/.ito/templates/schemas`;
50/// returns `None` when no project directory is set.
51///
52/// # Examples
53///
54/// ```ignore
55/// # use std::path::PathBuf;
56/// # use crate::templates::schema_assets::project_schemas_dir;
57/// # use crate::ConfigContext;
58/// // Construct a ConfigContext with a project_dir for the example.
59/// let ctx = ConfigContext { project_dir: Some(PathBuf::from("/repo")), ..Default::default() };
60/// let dir = project_schemas_dir(&ctx);
61/// assert_eq!(dir.unwrap(), PathBuf::from("/repo/.ito/templates/schemas"));
62/// ```
63pub(super) fn project_schemas_dir(ctx: &ConfigContext) -> Option<PathBuf> {
64    Some(
65        ctx.project_dir
66            .as_ref()?
67            .join(".ito")
68            .join("templates")
69            .join("schemas"),
70    )
71}
72
73/// Resolves the per-user schemas directory using XDG conventions.
74///
75/// If the `XDG_DATA_HOME` environment variable is set and not empty, its value is used;
76/// otherwise the function falls back to `ctx.home_dir` joined with `.local/share`.
77/// When a data home can be determined, the function returns that path with `ito/schemas` appended.
78/// Returns `None` if neither `XDG_DATA_HOME` nor `ctx.home_dir` are available.
79///
80/// # Examples
81///
82/// ```ignore
83/// use std::path::PathBuf;
84/// // Construct a minimal ConfigContext with a home_dir for the example.
85/// let ctx = ConfigContext { home_dir: Some(PathBuf::from("/home/alice")), ..Default::default() };
86/// let dir = user_schemas_dir(&ctx).unwrap();
87/// assert!(dir.ends_with("ito/schemas"));
88/// ```
89pub(super) fn user_schemas_dir(ctx: &ConfigContext) -> Option<PathBuf> {
90    let data_home = match env::var("XDG_DATA_HOME") {
91        Ok(v) if !v.trim().is_empty() => Some(PathBuf::from(v)),
92        _ => ctx
93            .home_dir
94            .as_ref()
95            .map(|h| h.join(".local").join("share")),
96    }?;
97    Some(data_home.join("ito").join("schemas"))
98}
99
100/// Lists top-level embedded schema names included in the binary.
101///
102/// Each entry is the first path segment of an embedded file's relative path (the directory containing a schema's files).
103///
104/// # Returns
105///
106/// `Vec<String>` of unique, non-empty top-level schema names sorted in ascending order.
107///
108/// # Examples
109///
110/// ```ignore
111/// let names = embedded_schema_names();
112/// assert!(names.iter().all(|n| !n.is_empty()));
113/// for w in names.windows(2) {
114///     assert!(w[0] <= w[1]);
115/// }
116/// ```
117pub(super) fn embedded_schema_names() -> Vec<String> {
118    let mut names: BTreeSet<String> = BTreeSet::new();
119    for file in schema_files() {
120        let mut parts = file.relative_path.split('/');
121        let Some(name) = parts.next() else {
122            continue;
123        };
124        if !name.is_empty() {
125            names.insert(name.to_string());
126        }
127    }
128    names.into_iter().collect()
129}
130
131/// Load an embedded schema's `schema.yaml` by schema name.
132///
133/// Attempts to read `{name}/schema.yaml` from the embedded assets and deserialize it into
134/// `SchemaYaml`.
135///
136/// Returns `Ok(Some(schema))` when the file exists and parses successfully, `Ok(None)` when the
137/// embedded file is not present, and `Err(WorkflowError)` if the embedded bytes are not valid UTF-8
138/// or if YAML deserialization (or other I/O) fails.
139///
140/// # Examples
141///
142/// ```ignore
143/// let res = load_embedded_schema_yaml("example-schema").unwrap();
144/// if let Some(schema) = res {
145///     // use `schema`
146/// }
147/// ```
148pub(super) fn load_embedded_schema_yaml(name: &str) -> Result<Option<SchemaYaml>, WorkflowError> {
149    let path = format!("{name}/schema.yaml");
150    let Some(bytes) = get_schema_file(&path) else {
151        return Ok(None);
152    };
153
154    let s = std::str::from_utf8(bytes).map_err(|e| {
155        std::io::Error::new(
156            std::io::ErrorKind::InvalidData,
157            format!("embedded schema is not utf-8 ({path}): {e}"),
158        )
159    })?;
160    let schema = serde_yaml::from_str(s)?;
161    Ok(Some(schema))
162}
163
164/// Load an embedded schema's `validation.yaml` by schema name.
165///
166/// Returns `Ok(None)` when the embedded file is not present.
167pub(super) fn load_embedded_validation_yaml(
168    name: &str,
169) -> Result<Option<ValidationYaml>, WorkflowError> {
170    let path = format!("{name}/validation.yaml");
171    let Some(bytes) = get_schema_file(&path) else {
172        return Ok(None);
173    };
174
175    let s = std::str::from_utf8(bytes).map_err(|e| {
176        std::io::Error::new(
177            std::io::ErrorKind::InvalidData,
178            format!("embedded validation is not utf-8 ({path}): {e}"),
179        )
180    })?;
181    let parsed = serde_yaml::from_str(s)?;
182    Ok(Some(parsed))
183}
184
185/// Load a schema template string for a resolved schema.
186///
187/// Loads the template from the embedded asset bundle at `{schema}/templates/{template}` when
188/// the resolved schema's source is `SchemaSource::Embedded`; otherwise reads
189/// `<schema_dir>/templates/<template>` from the filesystem.
190///
191/// Returns the template contents as a `String`. Returns a `WorkflowError` if the embedded
192/// template is missing, the embedded bytes are not valid UTF-8, or a filesystem I/O error
193/// occurs when reading a non-embedded template.
194///
195/// # Examples
196///
197/// ```ignore
198/// // `resolved` is a ResolvedSchema obtained from your configuration or discovery logic.
199/// // let content = read_schema_template(&resolved, "main.tpl")?;
200/// ```
201pub(super) fn read_schema_template(
202    resolved: &ResolvedSchema,
203    template: &str,
204) -> Result<String, WorkflowError> {
205    if !is_safe_relative_path(template) {
206        return Err(WorkflowError::Io(std::io::Error::new(
207            std::io::ErrorKind::InvalidInput,
208            format!("invalid template path: {template}"),
209        )));
210    }
211
212    if resolved.source == SchemaSource::Embedded {
213        let path = format!("{}/templates/{template}", resolved.schema.name);
214        let bytes = get_schema_file(&path).ok_or_else(|| {
215            std::io::Error::new(
216                std::io::ErrorKind::NotFound,
217                format!("embedded template not found: {path}"),
218            )
219        })?;
220        let text = std::str::from_utf8(bytes).map_err(|e| {
221            std::io::Error::new(
222                std::io::ErrorKind::InvalidData,
223                format!("embedded template is not utf-8 ({path}): {e}"),
224            )
225        })?;
226        return Ok(text.to_string());
227    }
228
229    let path = resolved.schema_dir.join("templates").join(template);
230    ito_common::io::read_to_string_std(&path).map_err(WorkflowError::from)
231}
232
233pub(super) fn is_safe_relative_path(path: &str) -> bool {
234    if path.is_empty() {
235        return false;
236    }
237
238    if path.contains('\\') {
239        return false;
240    }
241
242    let p = Path::new(path);
243    if p.is_absolute() {
244        return false;
245    }
246
247    for component in p.components() {
248        match component {
249            Component::Normal(_) => {}
250            Component::CurDir
251            | Component::ParentDir
252            | Component::RootDir
253            | Component::Prefix(_) => {
254                return false;
255            }
256        }
257    }
258
259    true
260}
261
262pub(super) fn is_safe_schema_name(name: &str) -> bool {
263    is_safe_relative_path(name) && !name.contains('.')
264}
265
266#[derive(Debug, Clone)]
267/// Summary of exported schema files.
268pub struct ExportSchemasResult {
269    /// Number of files written.
270    pub written: usize,
271    /// Number of existing files skipped because force was false.
272    pub skipped: usize,
273}
274
275/// Export all embedded schema files into a target directory.
276///
277/// Existing destination files are not overwritten unless `force` is `true`.
278pub fn export_embedded_schemas(
279    to_dir: &Path,
280    force: bool,
281) -> Result<ExportSchemasResult, WorkflowError> {
282    let mut written = 0usize;
283    let mut skipped = 0usize;
284
285    for file in schema_files() {
286        let dest = to_dir.join(file.relative_path);
287        if let Some(parent) = dest.parent() {
288            fs::create_dir_all(parent)?;
289        }
290
291        if dest.exists() && !force {
292            skipped += 1;
293            continue;
294        }
295
296        fs::write(dest, file.contents)?;
297        written += 1;
298    }
299
300    Ok(ExportSchemasResult { written, skipped })
301}
302
303#[cfg(test)]
304mod tests {
305    use super::{is_safe_relative_path, is_safe_schema_name};
306
307    #[test]
308    fn safe_relative_path_validation_blocks_traversal_and_absolute_paths() {
309        assert!(is_safe_relative_path("proposal.md"));
310        assert!(is_safe_relative_path("nested/template.md"));
311
312        assert!(!is_safe_relative_path(""));
313        assert!(!is_safe_relative_path("../escape.md"));
314        assert!(!is_safe_relative_path("./relative.md"));
315        assert!(!is_safe_relative_path("/abs/path.md"));
316        assert!(!is_safe_relative_path("nested\\windows.md"));
317    }
318
319    #[test]
320    fn safe_schema_name_rejects_dot_segments_and_periods() {
321        assert!(is_safe_schema_name("spec-driven"));
322
323        assert!(!is_safe_schema_name("../spec-driven"));
324        assert!(!is_safe_schema_name("spec.driven"));
325        assert!(!is_safe_schema_name(""));
326    }
327}