ito_templates/
lib.rs

1//! Embedded templates and assets installed by `ito init` / `ito update`.
2//!
3//! `ito-templates` packages the default project and home templates (plus shared
4//! skills/adapters/commands) as embedded assets.
5//!
6//! The Rust CLI writes these files to disk, optionally rewriting `.ito/` path
7//! prefixes when users configure a custom Ito directory name.
8
9#![warn(missing_docs)]
10
11use std::borrow::Cow;
12
13use include_dir::{Dir, include_dir};
14
15/// Embedded agent definitions.
16pub mod agents;
17
18/// Embedded instruction artifacts.
19pub mod instructions;
20
21/// Jinja2 rendering for project templates (AGENTS.md, skills).
22pub mod project_templates;
23
24static DEFAULT_PROJECT_DIR: Dir<'static> =
25    include_dir!("$CARGO_MANIFEST_DIR/assets/default/project");
26static DEFAULT_HOME_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/assets/default/home");
27static SKILLS_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/assets/skills");
28static ADAPTERS_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/assets/adapters");
29static COMMANDS_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/assets/commands");
30static AGENTS_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/assets/agents");
31static SCHEMAS_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/assets/schemas");
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34/// A file embedded in the `ito-templates` assets.
35pub struct EmbeddedFile {
36    /// Path relative to the template root directory.
37    pub relative_path: &'static str,
38    /// Raw file contents.
39    pub contents: &'static [u8],
40}
41
42/// Return all embedded files for the default project template.
43pub fn default_project_files() -> Vec<EmbeddedFile> {
44    dir_files(&DEFAULT_PROJECT_DIR)
45}
46
47/// Return all embedded files for the default home template.
48pub fn default_home_files() -> Vec<EmbeddedFile> {
49    dir_files(&DEFAULT_HOME_DIR)
50}
51
52/// Return all embedded shared skill files.
53pub fn skills_files() -> Vec<EmbeddedFile> {
54    dir_files(&SKILLS_DIR)
55}
56
57/// Return all embedded harness adapter files.
58pub fn adapters_files() -> Vec<EmbeddedFile> {
59    dir_files(&ADAPTERS_DIR)
60}
61
62/// Retrieves an embedded skill file by its path within the skills assets.
63///
64/// The `path` should be the file's path relative to the skills root (for example
65/// "brainstorming/SKILL.md").
66///
67/// # Returns
68///
69/// `Some(&[u8])` with the file contents if a file exists at `path`, `None` otherwise.
70///
71/// # Examples
72///
73/// ```
74/// use ito_templates::get_skill_file;
75/// let contents = get_skill_file("brainstorming/SKILL.md");
76/// if let Some(bytes) = contents {
77///     assert!(!bytes.is_empty());
78/// }
79/// ```
80pub fn get_skill_file(path: &str) -> Option<&'static [u8]> {
81    SKILLS_DIR.get_file(path).map(|f| f.contents())
82}
83
84/// Retrieves an embedded adapter file by its relative path within the adapters assets.
85///
86/// Returns `Some(&[u8])` with the file contents if the path exists, `None` otherwise.
87///
88/// # Examples
89///
90/// ```
91/// use ito_templates::get_adapter_file;
92/// let bytes = get_adapter_file("claude/session-start.sh").expect("adapter exists");
93/// assert!(!bytes.is_empty());
94/// ```
95pub fn get_adapter_file(path: &str) -> Option<&'static [u8]> {
96    ADAPTERS_DIR.get_file(path).map(|f| f.contents())
97}
98
99/// Lists embedded shared command files.
100///
101/// Returns a vector of `EmbeddedFile` entries for every file embedded under the commands asset directory,
102/// each with a `relative_path` (path relative to the commands root) and `contents`.
103///
104/// # Examples
105///
106/// ```
107/// use ito_templates::commands_files;
108/// let files = commands_files();
109/// // every entry has a non-empty relative path and contents
110/// assert!(files.iter().all(|f| !f.relative_path.is_empty() && !f.contents.is_empty()));
111/// ```
112pub fn commands_files() -> Vec<EmbeddedFile> {
113    dir_files(&COMMANDS_DIR)
114}
115
116/// Lists embedded workflow schema files.
117///
118/// Each entry contains the file's path relative to the schema root and its raw contents.
119///
120/// # Examples
121///
122/// ```
123/// use ito_templates::schema_files;
124/// let files = schema_files();
125/// assert!(files.iter().all(|f| !f.relative_path.is_empty() && !f.contents.is_empty()));
126/// ```
127pub fn schema_files() -> Vec<EmbeddedFile> {
128    dir_files(&SCHEMAS_DIR)
129}
130
131/// Returns the contents of an embedded schema file identified by its path relative to the schemas root.
132///
133/// The `path` is relative to the embedded schemas directory, for example `"spec-driven/schema.yaml"`.
134///
135/// # Returns
136///
137/// `Some(&[u8])` with the file contents if a matching embedded schema exists, `None` otherwise.
138///
139/// # Examples
140///
141/// ```
142/// use ito_templates::get_schema_file;
143/// let bytes = get_schema_file("spec-driven/schema.yaml").expect("schema should exist");
144/// assert!(!bytes.is_empty());
145/// ```
146pub fn get_schema_file(path: &str) -> Option<&'static [u8]> {
147    SCHEMAS_DIR.get_file(path).map(|f| f.contents())
148}
149
150/// Fetches the contents of an embedded command file by its path relative to the commands asset root.
151///
152/// # Returns
153///
154/// `Some(&[u8])` with the file contents if a file at `path` exists, `None` otherwise.
155///
156/// # Examples
157///
158/// ```rust
159/// use ito_templates::get_command_file;
160/// let contents = get_command_file("ito-apply.md");
161/// if let Some(bytes) = contents {
162///     assert!(!bytes.is_empty());
163/// }
164/// ```
165pub fn get_command_file(path: &str) -> Option<&'static [u8]> {
166    COMMANDS_DIR.get_file(path).map(|f| f.contents())
167}
168
169fn dir_files(dir: &'static Dir<'static>) -> Vec<EmbeddedFile> {
170    let mut out = Vec::new();
171    collect_dir_files(dir, &mut out);
172    out
173}
174
175fn collect_dir_files(dir: &'static Dir<'static>, out: &mut Vec<EmbeddedFile>) {
176    for f in dir.files() {
177        out.push(EmbeddedFile {
178            relative_path: f.path().to_str().unwrap_or_default(),
179            contents: f.contents(),
180        });
181    }
182
183    for d in dir.dirs() {
184        collect_dir_files(d, out);
185    }
186}
187
188/// Normalize an Ito directory name to the dotted form (e.g. `.ito`).
189///
190/// Empty inputs default to `.ito`. Non-dotted names are prefixed with `.`.
191pub fn normalize_ito_dir(ito_dir: &str) -> String {
192    let ito_dir = ito_dir.trim();
193    if ito_dir.is_empty() {
194        return ".ito".to_string();
195    }
196
197    if !is_safe_ito_dir_name(ito_dir) {
198        return ".ito".to_string();
199    }
200
201    if ito_dir.starts_with('.') {
202        ito_dir.to_string()
203    } else {
204        format!(".{ito_dir}")
205    }
206}
207
208fn is_safe_ito_dir_name(ito_dir: &str) -> bool {
209    if ito_dir.len() > 128 {
210        return false;
211    }
212    if ito_dir.contains('/') || ito_dir.contains('\\') || ito_dir.contains("..") {
213        return false;
214    }
215
216    for c in ito_dir.chars() {
217        if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
218            continue;
219        }
220        return false;
221    }
222
223    true
224}
225
226/// Rewrite a relative template path for a custom Ito directory.
227///
228/// When `ito_dir` is `.ito`, this returns `rel` unchanged.
229pub fn render_rel_path<'a>(rel: &'a str, ito_dir: &str) -> Cow<'a, str> {
230    if ito_dir == ".ito" {
231        return Cow::Borrowed(rel);
232    }
233    if let Some(rest) = rel.strip_prefix(".ito/") {
234        return Cow::Owned(format!("{ito_dir}/{rest}"));
235    }
236    Cow::Borrowed(rel)
237}
238
239/// Rewrite file bytes for a custom Ito directory.
240///
241/// This performs a best-effort UTF-8 rewrite of `.ito/` path occurrences.
242pub fn render_bytes<'a>(bytes: &'a [u8], ito_dir: &str) -> Cow<'a, [u8]> {
243    if ito_dir == ".ito" {
244        return Cow::Borrowed(bytes);
245    }
246
247    let Ok(s) = std::str::from_utf8(bytes) else {
248        return Cow::Borrowed(bytes);
249    };
250
251    // Match TS replaceHardcodedDotItoPaths: replace `.ito/` occurrences.
252    let out = s.replace(".ito/", &format!("{ito_dir}/"));
253    Cow::Owned(out.into_bytes())
254}
255
256/// Start marker for Ito-managed file blocks.
257pub const ITO_START_MARKER: &str = "<!-- ITO:START -->";
258
259/// End marker for Ito-managed file blocks.
260pub const ITO_END_MARKER: &str = "<!-- ITO:END -->";
261
262/// Extract the substring between [`ITO_START_MARKER`] and [`ITO_END_MARKER`].
263///
264/// Returns `None` if the markers are not present *on their own lines*.
265pub fn extract_managed_block(text: &str) -> Option<&str> {
266    let start = find_marker_index(text, ITO_START_MARKER, 0)?;
267    let end = find_marker_index(text, ITO_END_MARKER, start + ITO_START_MARKER.len())?;
268    let after_start = line_end(text, start + ITO_START_MARKER.len());
269    let before_end = line_start(text, end);
270    if before_end < after_start {
271        return Some("");
272    }
273
274    // TS `updateFileWithMarkers` writes:
275    //   start + "\n" + content + "\n" + end
276    // The substring between markers therefore always ends with the *separator* newline
277    // immediately before the end marker line. We want to recover the original `content`
278    // argument, so we drop exactly one trailing line break.
279    let mut inner = &text[after_start..before_end];
280    if inner.ends_with('\n') {
281        inner = &inner[..inner.len() - 1];
282        if inner.ends_with('\r') {
283            inner = &inner[..inner.len() - 1];
284        }
285    }
286    Some(inner)
287}
288
289fn line_start(text: &str, idx: usize) -> usize {
290    let bytes = text.as_bytes();
291    let mut i = idx;
292    while i > 0 {
293        if bytes[i - 1] == b'\n' {
294            break;
295        }
296        i -= 1;
297    }
298    i
299}
300
301fn line_end(text: &str, idx: usize) -> usize {
302    let bytes = text.as_bytes();
303    let mut i = idx;
304    while i < bytes.len() {
305        if bytes[i] == b'\n' {
306            return i + 1;
307        }
308        i += 1;
309    }
310    i
311}
312
313fn is_marker_on_own_line(content: &str, marker_index: usize, marker_len: usize) -> bool {
314    let bytes = content.as_bytes();
315
316    let mut i = marker_index;
317    while i > 0 {
318        let c = bytes[i - 1];
319        if c == b'\n' {
320            break;
321        }
322        if c != b' ' && c != b'\t' && c != b'\r' {
323            return false;
324        }
325        i -= 1;
326    }
327
328    let mut j = marker_index + marker_len;
329    while j < bytes.len() {
330        let c = bytes[j];
331        if c == b'\n' {
332            break;
333        }
334        if c != b' ' && c != b'\t' && c != b'\r' {
335            return false;
336        }
337        j += 1;
338    }
339
340    true
341}
342
343fn find_marker_index(content: &str, marker: &str, from_index: usize) -> Option<usize> {
344    let mut search_from = from_index;
345    while let Some(rel) = content.get(search_from..)?.find(marker) {
346        let idx = search_from + rel;
347        if is_marker_on_own_line(content, idx, marker.len()) {
348            return Some(idx);
349        }
350        search_from = idx + marker.len();
351    }
352    None
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn normalize_ito_dir_prefixes_dot() {
361        assert_eq!(normalize_ito_dir(".ito"), ".ito");
362        assert_eq!(normalize_ito_dir("ito"), ".ito");
363        assert_eq!(normalize_ito_dir(".x"), ".x");
364    }
365
366    #[test]
367    fn render_rel_path_rewrites_ito_prefix() {
368        assert_eq!(render_rel_path(".ito/AGENTS.md", ".ito"), ".ito/AGENTS.md");
369        assert_eq!(render_rel_path(".ito/AGENTS.md", ".x"), ".x/AGENTS.md");
370        assert_eq!(render_rel_path("AGENTS.md", ".x"), "AGENTS.md");
371    }
372
373    #[test]
374    fn render_bytes_rewrites_dot_ito_paths() {
375        let b = render_bytes(b"see .ito/AGENTS.md", ".x");
376        assert_eq!(std::str::from_utf8(&b).unwrap(), "see .x/AGENTS.md");
377    }
378
379    #[test]
380    fn extract_managed_block_returns_inner_content() {
381        let s = "pre\n<!-- ITO:START -->\nhello\nworld\n<!-- ITO:END -->\npost\n";
382        assert_eq!(extract_managed_block(s), Some("hello\nworld"));
383    }
384
385    #[test]
386    fn extract_managed_block_preserves_trailing_newline_from_content() {
387        // Content ends with a newline, plus the TS separator newline before the end marker.
388        let s = "pre\n<!-- ITO:START -->\nhello\nworld\n\n<!-- ITO:END -->\npost\n";
389        assert_eq!(extract_managed_block(s), Some("hello\nworld\n"));
390    }
391
392    #[test]
393    fn default_project_files_contains_expected_files() {
394        let files = default_project_files();
395        assert!(!files.is_empty());
396
397        let mut has_user_guidance = false;
398        for EmbeddedFile {
399            relative_path,
400            contents,
401        } in files
402        {
403            if relative_path == ".ito/user-guidance.md" {
404                has_user_guidance = true;
405                let contents = std::str::from_utf8(contents).expect("template should be UTF-8");
406                assert!(contents.contains(ITO_START_MARKER));
407                assert!(contents.contains(ITO_END_MARKER));
408            }
409        }
410
411        assert!(
412            has_user_guidance,
413            "expected .ito/user-guidance.md in templates"
414        );
415    }
416
417    #[test]
418    fn default_home_files_returns_a_vec() {
419        // The default home templates may be empty, but should still be loadable.
420        let _ = default_home_files();
421    }
422
423    #[test]
424    fn schema_files_contains_builtins() {
425        let files = schema_files();
426        assert!(!files.is_empty());
427        assert!(
428            files
429                .iter()
430                .any(|f| f.relative_path == "spec-driven/schema.yaml")
431        );
432        assert!(files.iter().any(|f| f.relative_path == "tdd/schema.yaml"));
433    }
434
435    #[test]
436    fn get_schema_file_returns_contents() {
437        let file = get_schema_file("spec-driven/schema.yaml").expect("schema should exist");
438        let text = std::str::from_utf8(file).expect("schema should be utf8");
439        assert!(text.contains("name: spec-driven"));
440    }
441
442    #[test]
443    fn normalize_ito_dir_empty_defaults_to_dot_ito() {
444        assert_eq!(normalize_ito_dir(""), ".ito");
445    }
446
447    #[test]
448    fn normalize_ito_dir_rejects_traversal_and_path_separators() {
449        assert_eq!(normalize_ito_dir("../escape"), ".ito");
450        assert_eq!(normalize_ito_dir("a/b"), ".ito");
451        assert_eq!(normalize_ito_dir("a\\b"), ".ito");
452    }
453
454    #[test]
455    fn render_bytes_returns_borrowed_when_no_rewrite_needed() {
456        let b = b"see .ito/AGENTS.md";
457        let out = render_bytes(b, ".ito");
458        assert_eq!(out.as_ref(), b);
459
460        let b = b"no ito path";
461        let out = render_bytes(b, ".x");
462        assert_eq!(out.as_ref(), b);
463    }
464
465    #[test]
466    fn render_bytes_preserves_non_utf8() {
467        let b = [0xff, 0x00, 0x41];
468        let out = render_bytes(&b, ".x");
469        assert_eq!(out.as_ref(), &b);
470    }
471
472    #[test]
473    fn extract_managed_block_rejects_inline_markers() {
474        let s = "pre <!-- ITO:START -->\nhello\n<!-- ITO:END -->\n";
475        assert_eq!(extract_managed_block(s), None);
476    }
477
478    #[test]
479    fn extract_managed_block_returns_empty_for_empty_inner() {
480        let s = "<!-- ITO:START -->\n<!-- ITO:END -->\n";
481        assert_eq!(extract_managed_block(s), Some(""));
482    }
483}