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)]
304#[path = "schema_assets_tests.rs"]
305mod schema_assets_tests;