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