1#![warn(missing_docs)]
10
11use std::borrow::Cow;
12
13use include_dir::{Dir, include_dir};
14
15pub mod agents;
17
18pub mod instructions;
20
21pub 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)]
34pub struct EmbeddedFile {
36 pub relative_path: &'static str,
38 pub contents: &'static [u8],
40}
41
42pub fn default_project_files() -> Vec<EmbeddedFile> {
44 dir_files(&DEFAULT_PROJECT_DIR)
45}
46
47pub fn default_home_files() -> Vec<EmbeddedFile> {
49 dir_files(&DEFAULT_HOME_DIR)
50}
51
52pub fn skills_files() -> Vec<EmbeddedFile> {
54 dir_files(&SKILLS_DIR)
55}
56
57pub fn adapters_files() -> Vec<EmbeddedFile> {
59 dir_files(&ADAPTERS_DIR)
60}
61
62pub fn get_skill_file(path: &str) -> Option<&'static [u8]> {
81 SKILLS_DIR.get_file(path).map(|f| f.contents())
82}
83
84pub fn get_adapter_file(path: &str) -> Option<&'static [u8]> {
96 ADAPTERS_DIR.get_file(path).map(|f| f.contents())
97}
98
99pub fn commands_files() -> Vec<EmbeddedFile> {
113 dir_files(&COMMANDS_DIR)
114}
115
116pub fn schema_files() -> Vec<EmbeddedFile> {
128 dir_files(&SCHEMAS_DIR)
129}
130
131pub fn get_schema_file(path: &str) -> Option<&'static [u8]> {
147 SCHEMAS_DIR.get_file(path).map(|f| f.contents())
148}
149
150pub 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
188pub 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
226pub 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
239pub 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 let out = s.replace(".ito/", &format!("{ito_dir}/"));
253 Cow::Owned(out.into_bytes())
254}
255
256pub const ITO_START_MARKER: &str = "<!-- ITO:START -->";
258
259pub const ITO_END_MARKER: &str = "<!-- ITO:END -->";
261
262pub 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 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 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 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}