1use std::collections::BTreeSet;
2use std::path::Path;
3
4use chrono::Utc;
5use serde_json::{Map, Value};
6
7use crate::errors::{CoreError, CoreResult};
8
9use markers::update_file_with_markers;
10
11mod markers;
12
13use ito_config::ConfigContext;
14use ito_config::ito_dir::get_ito_dir_name;
15use ito_templates::project_templates::WorktreeTemplateContext;
16
17pub const TOOL_CLAUDE: &str = "claude";
19pub const TOOL_CODEX: &str = "codex";
21pub const TOOL_GITHUB_COPILOT: &str = "github-copilot";
23pub const TOOL_OPENCODE: &str = "opencode";
25
26const CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER: &str = "__ITO_RELEASE_TAG__";
27
28pub fn available_tool_ids() -> &'static [&'static str] {
30 &[TOOL_CLAUDE, TOOL_CODEX, TOOL_GITHUB_COPILOT, TOOL_OPENCODE]
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct InitOptions {
36 pub tools: BTreeSet<String>,
38 pub force: bool,
40 pub update: bool,
47}
48
49impl InitOptions {
50 pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
52 Self {
53 tools,
54 force,
55 update,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum InstallMode {
63 Init,
65 Update,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum FileOwnership {
71 ItoManaged,
72 UserOwned,
73}
74
75pub fn install_default_templates(
81 project_root: &Path,
82 ctx: &ConfigContext,
83 mode: InstallMode,
84 opts: &InitOptions,
85 worktree_ctx: Option<&WorktreeTemplateContext>,
86) -> CoreResult<()> {
87 let ito_dir_name = get_ito_dir_name(project_root, ctx);
88 let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
89
90 install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
91
92 if mode == InstallMode::Init {
95 ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
96 ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
97 ensure_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
99 }
100
101 ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
103
104 install_adapter_files(project_root, mode, opts, worktree_ctx)?;
105 install_agent_templates(project_root, mode, opts)?;
106 Ok(())
107}
108
109fn ensure_repo_gitignore_ignores_local_configs(
110 project_root: &Path,
111 ito_dir: &str,
112) -> CoreResult<()> {
113 let entry = format!("{ito_dir}/config.local.json");
116 ensure_gitignore_contains_line(project_root, &entry)?;
117
118 let entry = ".local/ito/config.json";
120 ensure_gitignore_contains_line(project_root, entry)?;
121 Ok(())
122}
123
124fn ensure_repo_gitignore_ignores_session_json(
125 project_root: &Path,
126 ito_dir: &str,
127) -> CoreResult<()> {
128 let entry = format!("{ito_dir}/session.json");
129 ensure_gitignore_contains_line(project_root, &entry)
130}
131
132fn ensure_repo_gitignore_ignores_audit_session(
134 project_root: &Path,
135 ito_dir: &str,
136) -> CoreResult<()> {
137 let entry = format!("{ito_dir}/.state/audit/.session");
138 ensure_gitignore_contains_line(project_root, &entry)
139}
140
141fn ensure_repo_gitignore_unignores_audit_events(
147 project_root: &Path,
148 ito_dir: &str,
149) -> CoreResult<()> {
150 let entry = format!("!{ito_dir}/.state/audit/");
151 ensure_gitignore_contains_line(project_root, &entry)
152}
153
154fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
155 let path = project_root.join(".gitignore");
156 let existing = match ito_common::io::read_to_string_std(&path) {
157 Ok(s) => Some(s),
158 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
159 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
160 };
161
162 let Some(mut s) = existing else {
163 ito_common::io::write_std(&path, format!("{entry}\n"))
164 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
165 return Ok(());
166 };
167
168 if gitignore_has_exact_line(&s, entry) {
169 return Ok(());
170 }
171
172 if !s.ends_with('\n') {
173 s.push('\n');
174 }
175 s.push_str(entry);
176 s.push('\n');
177
178 ito_common::io::write_std(&path, s)
179 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
180 Ok(())
181}
182
183fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
184 contents.lines().map(|l| l.trim()).any(|l| l == entry)
185}
186
187fn install_project_templates(
188 project_root: &Path,
189 ito_dir: &str,
190 mode: InstallMode,
191 opts: &InitOptions,
192 worktree_ctx: Option<&WorktreeTemplateContext>,
193) -> CoreResult<()> {
194 use ito_templates::project_templates::render_project_template;
195
196 let selected = &opts.tools;
197 let current_date = Utc::now().format("%Y-%m-%d").to_string();
198 let state_rel = format!("{ito_dir}/planning/STATE.md");
199 let config_json_rel = format!("{ito_dir}/config.json");
200 let release_tag = release_tag();
201 let default_ctx = WorktreeTemplateContext::default();
202 let ctx = worktree_ctx.unwrap_or(&default_ctx);
203
204 for f in ito_templates::default_project_files() {
205 let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
206 let rel = rel.as_ref();
207
208 if !should_install_project_rel(rel, selected) {
209 continue;
210 }
211
212 let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
213 if let Ok(s) = std::str::from_utf8(&bytes) {
214 if rel == state_rel {
215 bytes = s.replace("__CURRENT_DATE__", ¤t_date).into_bytes();
216 } else if rel == config_json_rel {
217 bytes = s
218 .replace(CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER, &release_tag)
219 .into_bytes();
220 }
221 }
222
223 if rel == "AGENTS.md" {
228 bytes = render_project_template(&bytes, ctx).map_err(|e| {
229 CoreError::Validation(format!("Failed to render template {rel}: {e}"))
230 })?;
231 }
232
233 let ownership = classify_project_file_ownership(rel, ito_dir);
234
235 let target = project_root.join(rel);
236 if rel == ".claude/settings.json" {
237 write_claude_settings(&target, &bytes, mode, opts)?;
238 continue;
239 }
240 write_one(&target, &bytes, mode, opts, ownership)?;
241 }
242
243 Ok(())
244}
245
246fn release_tag() -> String {
247 let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
248 if version.starts_with('v') {
249 return version.to_string();
250 }
251
252 format!("v{version}")
253}
254
255fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
256 if rel == "AGENTS.md" {
258 return true;
259 }
260 if rel.starts_with(".ito/") {
261 return true;
262 }
263
264 if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
266 return tools.contains(TOOL_CLAUDE);
267 }
268 if rel.starts_with(".opencode/") {
269 return tools.contains(TOOL_OPENCODE);
270 }
271 if rel.starts_with(".github/") {
272 return tools.contains(TOOL_GITHUB_COPILOT);
273 }
274 if rel.starts_with(".codex/") {
275 return tools.contains(TOOL_CODEX);
276 }
277
278 false
280}
281
282fn classify_project_file_ownership(rel: &str, ito_dir: &str) -> FileOwnership {
283 let project_md_rel = format!("{ito_dir}/project.md");
284 if rel == project_md_rel {
285 return FileOwnership::UserOwned;
286 }
287
288 let config_json_rel = format!("{ito_dir}/config.json");
289 if rel == config_json_rel {
290 return FileOwnership::UserOwned;
291 }
292
293 let user_guidance_rel = format!("{ito_dir}/user-guidance.md");
294 if rel == user_guidance_rel {
295 return FileOwnership::UserOwned;
296 }
297
298 let user_prompts_prefix = format!("{ito_dir}/user-prompts/");
299 if rel.starts_with(&user_prompts_prefix) {
300 return FileOwnership::UserOwned;
301 }
302
303 FileOwnership::ItoManaged
304}
305
306fn write_one(
307 target: &Path,
308 rendered_bytes: &[u8],
309 mode: InstallMode,
310 opts: &InitOptions,
311 ownership: FileOwnership,
312) -> CoreResult<()> {
313 if let Some(parent) = target.parent() {
314 ito_common::io::create_dir_all_std(parent)
315 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
316 }
317
318 if let Ok(text) = std::str::from_utf8(rendered_bytes)
320 && let Some(block) = ito_templates::extract_managed_block(text)
321 {
322 if target.exists() {
323 if mode == InstallMode::Init && opts.force {
324 ito_common::io::write_std(target, rendered_bytes)
325 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
326 return Ok(());
327 }
328
329 if mode == InstallMode::Init && !opts.force && !opts.update {
330 let existing = ito_common::io::read_to_string_or_default(target);
333 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
334 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
335 if !(has_start && has_end) {
336 return Err(CoreError::Validation(format!(
337 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
338 target.display()
339 )));
340 }
341 }
342
343 update_file_with_markers(
344 target,
345 block,
346 ito_templates::ITO_START_MARKER,
347 ito_templates::ITO_END_MARKER,
348 )
349 .map_err(|e| match e {
350 markers::FsEditError::Io(io_err) => {
351 CoreError::io(format!("updating markers in {}", target.display()), io_err)
352 }
353 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
354 "Failed to update markers in {}: {}",
355 target.display(),
356 marker_err
357 )),
358 })?;
359 } else {
360 ito_common::io::write_std(target, rendered_bytes)
362 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
363 }
364
365 return Ok(());
366 }
367
368 if target.exists() {
369 match mode {
370 InstallMode::Init => {
371 if opts.force {
372 } else if opts.update {
374 if ownership == FileOwnership::UserOwned {
375 return Ok(());
376 }
377 } else {
378 return Err(CoreError::Validation(format!(
379 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
380 target.display()
381 )));
382 }
383 }
384 InstallMode::Update => {
385 if ownership == FileOwnership::UserOwned {
386 return Ok(());
387 }
388 }
389 }
390 }
391
392 ito_common::io::write_std(target, rendered_bytes)
393 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
394 Ok(())
395}
396
397fn write_claude_settings(
398 target: &Path,
399 rendered_bytes: &[u8],
400 mode: InstallMode,
401 opts: &InitOptions,
402) -> CoreResult<()> {
403 if let Some(parent) = target.parent() {
404 ito_common::io::create_dir_all_std(parent)
405 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
406 }
407
408 if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
409 return Err(CoreError::Validation(format!(
410 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
411 target.display()
412 )));
413 }
414
415 let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
416 CoreError::Validation(format!(
417 "Failed to parse Claude settings template {}: {}",
418 target.display(),
419 e
420 ))
421 })?;
422
423 if !target.exists() || (mode == InstallMode::Init && opts.force) {
424 let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
425 CoreError::Validation(format!(
426 "Failed to render Claude settings template {}: {}",
427 target.display(),
428 e
429 ))
430 })?;
431 bytes.push(b'\n');
432 ito_common::io::write_std(target, bytes)
433 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
434 return Ok(());
435 }
436
437 let existing_raw = ito_common::io::read_to_string_std(target)
438 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
439 let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
440 return Ok(());
442 };
443
444 merge_json_objects(&mut existing_value, &template_value);
445 let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
446 CoreError::Validation(format!(
447 "Failed to render merged Claude settings {}: {}",
448 target.display(),
449 e
450 ))
451 })?;
452 merged.push(b'\n');
453 ito_common::io::write_std(target, merged)
454 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
455 Ok(())
456}
457
458fn merge_json_objects(existing: &mut Value, template: &Value) {
459 let Value::Object(template_map) = template else {
460 *existing = template.clone();
461 return;
462 };
463 if !existing.is_object() {
464 *existing = Value::Object(Map::new());
465 }
466
467 let Some(existing_map) = existing.as_object_mut() else {
468 return;
469 };
470
471 for (key, template_value) in template_map {
472 if let Some(existing_value) = existing_map.get_mut(key) {
473 merge_json_values(existing_value, template_value);
474 } else {
475 existing_map.insert(key.clone(), template_value.clone());
476 }
477 }
478}
479
480fn merge_json_values(existing: &mut Value, template: &Value) {
481 match (existing, template) {
482 (Value::Object(existing_map), Value::Object(template_map)) => {
483 for (key, template_value) in template_map {
484 if let Some(existing_value) = existing_map.get_mut(key) {
485 merge_json_values(existing_value, template_value);
486 } else {
487 existing_map.insert(key.clone(), template_value.clone());
488 }
489 }
490 }
491 (Value::Array(existing_items), Value::Array(template_items)) => {
492 for template_item in template_items {
493 if !existing_items.contains(template_item) {
494 existing_items.push(template_item.clone());
495 }
496 }
497 }
498 (existing_value, template_value) => *existing_value = template_value.clone(),
499 }
500}
501
502fn install_adapter_files(
503 project_root: &Path,
504 _mode: InstallMode,
505 opts: &InitOptions,
506 worktree_ctx: Option<&WorktreeTemplateContext>,
507) -> CoreResult<()> {
508 for tool in &opts.tools {
509 match tool.as_str() {
510 TOOL_OPENCODE => {
511 let config_dir = project_root.join(".opencode");
512 let manifests = crate::distribution::opencode_manifests(&config_dir);
513 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
514 }
515 TOOL_CLAUDE => {
516 let manifests = crate::distribution::claude_manifests(project_root);
517 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
518 }
519 TOOL_CODEX => {
520 let manifests = crate::distribution::codex_manifests(project_root);
521 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
522 }
523 TOOL_GITHUB_COPILOT => {
524 let manifests = crate::distribution::github_manifests(project_root);
525 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
526 }
527 _ => {}
528 }
529 }
530
531 Ok(())
532}
533
534fn install_agent_templates(
536 project_root: &Path,
537 mode: InstallMode,
538 opts: &InitOptions,
539) -> CoreResult<()> {
540 use ito_templates::agents::{
541 AgentTier, Harness, default_agent_configs, get_agent_files, render_agent_template,
542 };
543
544 let configs = default_agent_configs();
545
546 let tool_harness_map = [
548 (TOOL_OPENCODE, Harness::OpenCode),
549 (TOOL_CLAUDE, Harness::ClaudeCode),
550 (TOOL_CODEX, Harness::Codex),
551 (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
552 ];
553
554 for (tool_id, harness) in tool_harness_map {
555 if !opts.tools.contains(tool_id) {
556 continue;
557 }
558
559 let agent_dir = project_root.join(harness.project_agent_path());
560
561 let files = get_agent_files(harness);
563
564 for (rel_path, contents) in files {
565 let target = agent_dir.join(rel_path);
566
567 let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
569 Some(AgentTier::Quick)
570 } else if rel_path.contains("ito-general") || rel_path.contains("general") {
571 Some(AgentTier::General)
572 } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
573 Some(AgentTier::Thinking)
574 } else {
575 None
576 };
577
578 let config = tier.and_then(|t| configs.get(&(harness, t)));
580
581 match mode {
582 InstallMode::Init => {
583 if target.exists() {
584 if opts.update {
585 if let Some(cfg) = config {
587 update_agent_model_field(&target, &cfg.model)?;
588 }
589 continue;
590 }
591 if !opts.force {
592 continue;
594 }
595 }
596
597 let rendered = if let Some(cfg) = config {
599 if let Ok(template_str) = std::str::from_utf8(contents) {
600 render_agent_template(template_str, cfg).into_bytes()
601 } else {
602 contents.to_vec()
603 }
604 } else {
605 contents.to_vec()
606 };
607
608 if let Some(parent) = target.parent() {
610 ito_common::io::create_dir_all_std(parent).map_err(|e| {
611 CoreError::io(format!("creating directory {}", parent.display()), e)
612 })?;
613 }
614
615 ito_common::io::write_std(&target, rendered)
616 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
617 }
618 InstallMode::Update => {
619 if !target.exists() {
621 let rendered = if let Some(cfg) = config {
623 if let Ok(template_str) = std::str::from_utf8(contents) {
624 render_agent_template(template_str, cfg).into_bytes()
625 } else {
626 contents.to_vec()
627 }
628 } else {
629 contents.to_vec()
630 };
631
632 if let Some(parent) = target.parent() {
633 ito_common::io::create_dir_all_std(parent).map_err(|e| {
634 CoreError::io(format!("creating directory {}", parent.display()), e)
635 })?;
636 }
637 ito_common::io::write_std(&target, rendered).map_err(|e| {
638 CoreError::io(format!("writing {}", target.display()), e)
639 })?;
640 } else if let Some(cfg) = config {
641 update_agent_model_field(&target, &cfg.model)?;
643 }
644 }
645 }
646 }
647 }
648
649 Ok(())
650}
651
652fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
654 let content = ito_common::io::read_to_string_or_default(path);
655
656 if !content.starts_with("---") {
658 return Ok(());
659 }
660
661 let rest = &content[3..];
663 let Some(end_idx) = rest.find("\n---") else {
664 return Ok(());
665 };
666
667 let frontmatter = &rest[..end_idx];
668 let body = &rest[end_idx + 4..]; let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
672
673 let updated = format!("---{}\n---{}", updated_frontmatter, body);
675 ito_common::io::write_std(path, updated)
676 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
677
678 Ok(())
679}
680
681fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
683 let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
684 let mut found = false;
685
686 for line in &mut lines {
687 if line.trim_start().starts_with("model:") {
688 *line = format!("model: \"{}\"", new_model);
689 found = true;
690 break;
691 }
692 }
693
694 if !found {
696 lines.push(format!("model: \"{}\"", new_model));
697 }
698
699 lines.join("\n")
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 #[test]
707 fn gitignore_created_when_missing() {
708 let td = tempfile::tempdir().unwrap();
709 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
710 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
711 assert_eq!(s, ".ito/session.json\n");
712 }
713
714 #[test]
715 fn gitignore_noop_when_already_present() {
716 let td = tempfile::tempdir().unwrap();
717 std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
718 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
719 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
720 assert_eq!(s, ".ito/session.json\n");
721 }
722
723 #[test]
724 fn gitignore_does_not_duplicate_on_repeated_calls() {
725 let td = tempfile::tempdir().unwrap();
726 std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
727 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
728 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
729 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
730 assert_eq!(s, "node_modules\n.ito/session.json\n");
731 }
732
733 #[test]
734 fn gitignore_audit_session_added() {
735 let td = tempfile::tempdir().unwrap();
736 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
737 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
738 assert!(s.contains(".ito/.state/audit/.session"));
739 }
740
741 #[test]
742 fn gitignore_both_session_entries() {
743 let td = tempfile::tempdir().unwrap();
744 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
745 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
746 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
747 assert!(s.contains(".ito/session.json"));
748 assert!(s.contains(".ito/.state/audit/.session"));
749 }
750
751 #[test]
752 fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
753 let td = tempfile::tempdir().unwrap();
754 std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
755 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
756 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
757 assert_eq!(s, "node_modules\n.ito/session.json\n");
758 }
759
760 #[test]
761 fn gitignore_audit_events_unignored() {
762 let td = tempfile::tempdir().unwrap();
763 ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
764 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
765 assert!(s.contains("!.ito/.state/audit/"));
766 }
767
768 #[test]
769 fn gitignore_full_audit_setup() {
770 let td = tempfile::tempdir().unwrap();
771 std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
773 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
774 ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
775 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
776 assert!(s.contains(".ito/.state/audit/.session"));
777 assert!(s.contains("!.ito/.state/audit/"));
778 }
779
780 #[test]
781 fn gitignore_ignores_local_configs() {
782 let td = tempfile::tempdir().unwrap();
783 ensure_repo_gitignore_ignores_local_configs(td.path(), ".ito").unwrap();
784 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
785 assert!(s.contains(".ito/config.local.json"));
786 assert!(s.contains(".local/ito/config.json"));
787 }
788
789 #[test]
790 fn gitignore_exact_line_matching_trims_whitespace() {
791 assert!(gitignore_has_exact_line(" foo \nbar\n", "foo"));
792 assert!(!gitignore_has_exact_line("foo\n", "bar"));
793 }
794
795 #[test]
796 fn should_install_project_rel_filters_by_tool_id() {
797 let mut tools = BTreeSet::new();
798 tools.insert(TOOL_OPENCODE.to_string());
799
800 assert!(should_install_project_rel("AGENTS.md", &tools));
801 assert!(should_install_project_rel(".ito/config.json", &tools));
802 assert!(should_install_project_rel(".opencode/config.json", &tools));
803 assert!(!should_install_project_rel(".claude/settings.json", &tools));
804 assert!(!should_install_project_rel(".codex/config.json", &tools));
805 assert!(!should_install_project_rel(
806 ".github/workflows/x.yml",
807 &tools
808 ));
809 }
810
811 #[test]
812 fn release_tag_is_prefixed_with_v() {
813 let tag = release_tag();
814 assert!(tag.starts_with('v'));
815 }
816
817 #[test]
818 fn update_model_in_yaml_replaces_or_inserts() {
819 let yaml = "name: test\nmodel: \"old\"\n";
820 let updated = update_model_in_yaml(yaml, "new");
821 assert!(updated.contains("model: \"new\""));
822
823 let yaml = "name: test\n";
824 let updated = update_model_in_yaml(yaml, "new");
825 assert!(updated.contains("model: \"new\""));
826 }
827
828 #[test]
829 fn update_agent_model_field_updates_frontmatter_when_present() {
830 let td = tempfile::tempdir().unwrap();
831 let path = td.path().join("agent.md");
832 std::fs::write(&path, "---\nname: test\nmodel: \"old\"\n---\nbody\n").unwrap();
833 update_agent_model_field(&path, "new").unwrap();
834 let s = std::fs::read_to_string(&path).unwrap();
835 assert!(s.contains("model: \"new\""));
836
837 let path = td.path().join("no-frontmatter.md");
838 std::fs::write(&path, "no frontmatter\n").unwrap();
839 update_agent_model_field(&path, "newer").unwrap();
840 let s = std::fs::read_to_string(&path).unwrap();
841 assert_eq!(s, "no frontmatter\n");
842 }
843
844 #[test]
845 fn write_one_non_marker_files_skip_on_init_update_mode() {
846 let td = tempfile::tempdir().unwrap();
847 let target = td.path().join("plain.txt");
848 std::fs::write(&target, "existing").unwrap();
849
850 let opts = InitOptions::new(BTreeSet::new(), false, true);
851 write_one(
852 &target,
853 b"new",
854 InstallMode::Init,
855 &opts,
856 FileOwnership::UserOwned,
857 )
858 .unwrap();
859 let s = std::fs::read_to_string(&target).unwrap();
860 assert_eq!(s, "existing");
861 }
862
863 #[test]
864 fn write_one_non_marker_ito_managed_files_overwrite_on_init_update_mode() {
865 let td = tempfile::tempdir().unwrap();
866 let target = td.path().join("plain.txt");
867 std::fs::write(&target, "existing").unwrap();
868
869 let opts = InitOptions::new(BTreeSet::new(), false, true);
870 write_one(
871 &target,
872 b"new",
873 InstallMode::Init,
874 &opts,
875 FileOwnership::ItoManaged,
876 )
877 .unwrap();
878 let s = std::fs::read_to_string(&target).unwrap();
879 assert_eq!(s, "new");
880 }
881
882 #[test]
883 fn write_one_non_marker_user_owned_files_preserve_on_update_mode() {
884 let td = tempfile::tempdir().unwrap();
885 let target = td.path().join("plain.txt");
886 std::fs::write(&target, "existing").unwrap();
887
888 let opts = InitOptions::new(BTreeSet::new(), false, true);
889 write_one(
890 &target,
891 b"new",
892 InstallMode::Update,
893 &opts,
894 FileOwnership::UserOwned,
895 )
896 .unwrap();
897 let s = std::fs::read_to_string(&target).unwrap();
898 assert_eq!(s, "existing");
899 }
900
901 #[test]
902 fn write_one_marker_managed_files_refuse_overwrite_without_markers() {
903 let td = tempfile::tempdir().unwrap();
904 let target = td.path().join("managed.md");
905 std::fs::write(&target, "existing without markers\n").unwrap();
906
907 let template = format!(
908 "before\n{}\nmanaged\n{}\nafter\n",
909 ito_templates::ITO_START_MARKER,
910 ito_templates::ITO_END_MARKER
911 );
912 let opts = InitOptions::new(BTreeSet::new(), false, false);
913 let err = write_one(
914 &target,
915 template.as_bytes(),
916 InstallMode::Init,
917 &opts,
918 FileOwnership::ItoManaged,
919 )
920 .unwrap_err();
921 assert!(err.to_string().contains("Refusing to overwrite"));
922 }
923
924 #[test]
925 fn write_one_marker_managed_files_update_existing_markers() {
926 let td = tempfile::tempdir().unwrap();
927 let target = td.path().join("managed.md");
928 let existing = format!(
929 "before\n{}\nold\n{}\nafter\n",
930 ito_templates::ITO_START_MARKER,
931 ito_templates::ITO_END_MARKER
932 );
933 std::fs::write(&target, existing).unwrap();
934
935 let template = format!(
936 "before\n{}\nnew\n{}\nafter\n",
937 ito_templates::ITO_START_MARKER,
938 ito_templates::ITO_END_MARKER
939 );
940 let opts = InitOptions::new(BTreeSet::new(), false, false);
941 write_one(
942 &target,
943 template.as_bytes(),
944 InstallMode::Init,
945 &opts,
946 FileOwnership::ItoManaged,
947 )
948 .unwrap();
949 let s = std::fs::read_to_string(&target).unwrap();
950 assert!(s.contains("new"));
951 assert!(!s.contains("old"));
952 }
953
954 #[test]
955 fn write_one_marker_managed_files_error_when_markers_missing_in_update_mode() {
956 let td = tempfile::tempdir().unwrap();
957 let target = td.path().join("managed.md");
958 std::fs::write(
960 &target,
961 format!(
962 "{}\nexisting without end marker\n",
963 ito_templates::ITO_START_MARKER
964 ),
965 )
966 .unwrap();
967
968 let template = format!(
969 "before\n{}\nmanaged\n{}\nafter\n",
970 ito_templates::ITO_START_MARKER,
971 ito_templates::ITO_END_MARKER
972 );
973 let opts = InitOptions::new(BTreeSet::new(), false, true);
974 let err = write_one(
975 &target,
976 template.as_bytes(),
977 InstallMode::Init,
978 &opts,
979 FileOwnership::ItoManaged,
980 )
981 .unwrap_err();
982 assert!(err.to_string().contains("Failed to update markers"));
983 }
984
985 #[test]
986 fn merge_json_objects_keeps_existing_and_adds_template_keys() {
987 let mut existing = serde_json::json!({
988 "permissions": {
989 "allow": ["Bash(ls)"]
990 },
991 "hooks": {
992 "SessionStart": [
993 {
994 "matcher": "*"
995 }
996 ]
997 }
998 });
999 let template = serde_json::json!({
1000 "hooks": {
1001 "PreToolUse": [
1002 {
1003 "matcher": "Bash|Edit|Write",
1004 "hooks": [
1005 {
1006 "type": "command",
1007 "command": "bash .claude/hooks/ito-audit.sh"
1008 }
1009 ]
1010 }
1011 ]
1012 }
1013 });
1014
1015 merge_json_objects(&mut existing, &template);
1016
1017 assert_eq!(
1018 existing
1019 .pointer("/permissions/allow/0")
1020 .and_then(Value::as_str),
1021 Some("Bash(ls)")
1022 );
1023 assert!(existing.pointer("/hooks/SessionStart/0/matcher").is_some());
1024 assert!(
1025 existing
1026 .pointer("/hooks/PreToolUse/0/hooks/0/command")
1027 .is_some()
1028 );
1029 }
1030
1031 #[test]
1032 fn classify_project_file_ownership_handles_user_owned_paths() {
1033 let ito_dir = ".ito";
1034
1035 assert_eq!(
1036 classify_project_file_ownership(".ito/project.md", ito_dir),
1037 FileOwnership::UserOwned
1038 );
1039 assert_eq!(
1040 classify_project_file_ownership(".ito/config.json", ito_dir),
1041 FileOwnership::UserOwned
1042 );
1043 assert_eq!(
1044 classify_project_file_ownership(".ito/user-guidance.md", ito_dir),
1045 FileOwnership::UserOwned
1046 );
1047 assert_eq!(
1048 classify_project_file_ownership(".ito/user-prompts/tasks.md", ito_dir),
1049 FileOwnership::UserOwned
1050 );
1051 assert_eq!(
1052 classify_project_file_ownership(".ito/commands/review-edge.md", ito_dir),
1053 FileOwnership::ItoManaged
1054 );
1055 }
1056
1057 #[test]
1058 fn write_claude_settings_merges_existing_file_on_update() {
1059 let td = tempfile::tempdir().unwrap();
1060 let target = td.path().join(".claude/settings.json");
1061 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
1062 std::fs::write(
1063 &target,
1064 "{\n \"permissions\": {\n \"allow\": [\"Bash(ls)\"]\n }\n}\n",
1065 )
1066 .unwrap();
1067
1068 let template = br#"{
1069 "hooks": {
1070 "PreToolUse": [
1071 {
1072 "matcher": "Bash|Edit|Write",
1073 "hooks": [
1074 {
1075 "type": "command",
1076 "command": "bash .claude/hooks/ito-audit.sh"
1077 }
1078 ]
1079 }
1080 ]
1081 }
1082}
1083"#;
1084
1085 let opts = InitOptions::new(BTreeSet::new(), false, true);
1086 write_claude_settings(&target, template, InstallMode::Update, &opts).unwrap();
1087
1088 let updated = std::fs::read_to_string(&target).unwrap();
1089 let value: Value = serde_json::from_str(&updated).unwrap();
1090 assert!(value.pointer("/permissions/allow").is_some());
1091 assert!(
1092 value
1093 .pointer("/hooks/PreToolUse/0/hooks/0/command")
1094 .is_some()
1095 );
1096 }
1097
1098 #[test]
1099 fn merge_json_objects_appends_and_deduplicates_array_entries() {
1100 let mut existing = serde_json::json!({
1101 "permissions": {
1102 "allow": ["Bash(ls)"]
1103 },
1104 "hooks": {
1105 "PreToolUse": [
1106 {
1107 "matcher": "Bash",
1108 "hooks": [{"type": "command", "command": "echo existing"}]
1109 }
1110 ]
1111 }
1112 });
1113 let template = serde_json::json!({
1114 "permissions": {
1115 "allow": ["Bash(ls)", "Bash(git status)"]
1116 },
1117 "hooks": {
1118 "PreToolUse": [
1119 {
1120 "matcher": "Bash",
1121 "hooks": [{"type": "command", "command": "echo existing"}]
1122 },
1123 {
1124 "matcher": "Edit|Write",
1125 "hooks": [{"type": "command", "command": "echo template"}]
1126 }
1127 ]
1128 }
1129 });
1130
1131 merge_json_objects(&mut existing, &template);
1132
1133 let permissions = existing
1134 .pointer("/permissions/allow")
1135 .and_then(Value::as_array)
1136 .expect("permissions allow should remain an array");
1137 assert_eq!(permissions.len(), 2);
1138 assert_eq!(permissions[0].as_str(), Some("Bash(ls)"));
1139 assert_eq!(permissions[1].as_str(), Some("Bash(git status)"));
1140
1141 let hooks = existing
1142 .pointer("/hooks/PreToolUse")
1143 .and_then(Value::as_array)
1144 .expect("PreToolUse should remain an array");
1145 assert_eq!(hooks.len(), 2);
1146 assert_eq!(
1147 hooks[0].pointer("/hooks/0/command").and_then(Value::as_str),
1148 Some("echo existing")
1149 );
1150 assert_eq!(
1151 hooks[1].pointer("/hooks/0/command").and_then(Value::as_str),
1152 Some("echo template")
1153 );
1154 }
1155
1156 #[test]
1157 fn write_claude_settings_preserves_invalid_json_on_update() {
1158 let td = tempfile::tempdir().unwrap();
1159 let target = td.path().join(".claude/settings.json");
1160 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
1161 std::fs::write(&target, "not-json\n").unwrap();
1162
1163 let template = br#"{
1164 "hooks": {
1165 "PreToolUse": [
1166 {
1167 "matcher": "Bash|Edit|Write",
1168 "hooks": [
1169 {
1170 "type": "command",
1171 "command": "bash .claude/hooks/ito-audit.sh"
1172 }
1173 ]
1174 }
1175 ]
1176 }
1177}
1178"#;
1179
1180 let opts = InitOptions::new(BTreeSet::new(), false, true);
1181 write_claude_settings(&target, template, InstallMode::Update, &opts).unwrap();
1182
1183 let updated = std::fs::read_to_string(&target).unwrap();
1184 assert_eq!(updated, "not-json\n");
1185 }
1186}