Skip to main content

ito_core/templates/
schema_assets.rs

1use super::{ResolvedSchema, SchemaSource, SchemaYaml, 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 a schema template string for a resolved schema.
165///
166/// Loads the template from the embedded asset bundle at `{schema}/templates/{template}` when
167/// the resolved schema's source is `SchemaSource::Embedded`; otherwise reads
168/// `<schema_dir>/templates/<template>` from the filesystem.
169///
170/// Returns the template contents as a `String`. Returns a `WorkflowError` if the embedded
171/// template is missing, the embedded bytes are not valid UTF-8, or a filesystem I/O error
172/// occurs when reading a non-embedded template.
173///
174/// # Examples
175///
176/// ```ignore
177/// // `resolved` is a ResolvedSchema obtained from your configuration or discovery logic.
178/// // let content = read_schema_template(&resolved, "main.tpl")?;
179/// ```
180pub(super) fn read_schema_template(
181    resolved: &ResolvedSchema,
182    template: &str,
183) -> Result<String, WorkflowError> {
184    if !is_safe_relative_path(template) {
185        return Err(WorkflowError::Io(std::io::Error::new(
186            std::io::ErrorKind::InvalidInput,
187            format!("invalid template path: {template}"),
188        )));
189    }
190
191    if resolved.source == SchemaSource::Embedded {
192        let path = format!("{}/templates/{template}", resolved.schema.name);
193        let bytes = get_schema_file(&path).ok_or_else(|| {
194            std::io::Error::new(
195                std::io::ErrorKind::NotFound,
196                format!("embedded template not found: {path}"),
197            )
198        })?;
199        let text = std::str::from_utf8(bytes).map_err(|e| {
200            std::io::Error::new(
201                std::io::ErrorKind::InvalidData,
202                format!("embedded template is not utf-8 ({path}): {e}"),
203            )
204        })?;
205        return Ok(text.to_string());
206    }
207
208    let path = resolved.schema_dir.join("templates").join(template);
209    ito_common::io::read_to_string_std(&path).map_err(WorkflowError::from)
210}
211
212pub(super) fn is_safe_relative_path(path: &str) -> bool {
213    if path.is_empty() {
214        return false;
215    }
216
217    if path.contains('\\') {
218        return false;
219    }
220
221    let p = Path::new(path);
222    if p.is_absolute() {
223        return false;
224    }
225
226    for component in p.components() {
227        match component {
228            Component::Normal(_) => {}
229            Component::CurDir
230            | Component::ParentDir
231            | Component::RootDir
232            | Component::Prefix(_) => {
233                return false;
234            }
235        }
236    }
237
238    true
239}
240
241pub(super) fn is_safe_schema_name(name: &str) -> bool {
242    is_safe_relative_path(name) && !name.contains('.')
243}
244
245#[derive(Debug, Clone)]
246/// Summary of exported schema files.
247pub struct ExportSchemasResult {
248    /// Number of files written.
249    pub written: usize,
250    /// Number of existing files skipped because force was false.
251    pub skipped: usize,
252}
253
254/// Export all embedded schema files into a target directory.
255///
256/// Existing destination files are not overwritten unless `force` is `true`.
257pub fn export_embedded_schemas(
258    to_dir: &Path,
259    force: bool,
260) -> Result<ExportSchemasResult, WorkflowError> {
261    let mut written = 0usize;
262    let mut skipped = 0usize;
263
264    for file in schema_files() {
265        let dest = to_dir.join(file.relative_path);
266        if let Some(parent) = dest.parent() {
267            fs::create_dir_all(parent)?;
268        }
269
270        if dest.exists() && !force {
271            skipped += 1;
272            continue;
273        }
274
275        fs::write(dest, file.contents)?;
276        written += 1;
277    }
278
279    Ok(ExportSchemasResult { written, skipped })
280}
281
282#[cfg(test)]
283mod tests {
284    use super::{is_safe_relative_path, is_safe_schema_name};
285
286    #[test]
287    fn safe_relative_path_validation_blocks_traversal_and_absolute_paths() {
288        assert!(is_safe_relative_path("proposal.md"));
289        assert!(is_safe_relative_path("nested/template.md"));
290
291        assert!(!is_safe_relative_path(""));
292        assert!(!is_safe_relative_path("../escape.md"));
293        assert!(!is_safe_relative_path("./relative.md"));
294        assert!(!is_safe_relative_path("/abs/path.md"));
295        assert!(!is_safe_relative_path("nested\\windows.md"));
296    }
297
298    #[test]
299    fn safe_schema_name_rejects_dot_segments_and_periods() {
300        assert!(is_safe_schema_name("spec-driven"));
301
302        assert!(!is_safe_schema_name("../spec-driven"));
303        assert!(!is_safe_schema_name("spec.driven"));
304        assert!(!is_safe_schema_name(""));
305    }
306}