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