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 {
75 Self {
76 tools,
77 force,
78 update,
79 upgrade: false,
80 }
81 }
82
83 pub fn new_upgrade(tools: BTreeSet<String>) -> Self {
91 Self {
92 tools,
93 force: false,
94 update: true,
95 upgrade: true,
96 }
97 }
98
99 pub fn with_upgrade(mut self) -> Self {
106 self.upgrade = true;
107 self.update = true;
108 self.force = false;
109 self
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum InstallMode {
116 Init,
118 Update,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123enum FileOwnership {
124 ItoManaged,
125 UserOwned,
126}
127
128pub fn install_default_templates(
134 project_root: &Path,
135 ctx: &ConfigContext,
136 mode: InstallMode,
137 opts: &InitOptions,
138 worktree_ctx: Option<&WorktreeTemplateContext>,
139) -> CoreResult<()> {
140 let ito_dir_name = get_ito_dir_name(project_root, ctx);
141 let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
142
143 install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
144
145 if mode == InstallMode::Init {
148 ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
149 ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
150 remove_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
151 }
152
153 ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
155
156 install_adapter_files(project_root, mode, opts, worktree_ctx)?;
157 install_agent_templates(project_root, mode, opts)?;
158 Ok(())
159}
160
161fn ensure_repo_gitignore_ignores_local_configs(
162 project_root: &Path,
163 ito_dir: &str,
164) -> CoreResult<()> {
165 let entry = format!("{ito_dir}/config.local.json");
168 ensure_gitignore_contains_line(project_root, &entry)?;
169
170 let entry = ".local/ito/config.json";
172 ensure_gitignore_contains_line(project_root, entry)?;
173 Ok(())
174}
175
176fn ensure_repo_gitignore_ignores_session_json(
177 project_root: &Path,
178 ito_dir: &str,
179) -> CoreResult<()> {
180 let entry = format!("{ito_dir}/session.json");
181 ensure_gitignore_contains_line(project_root, &entry)
182}
183
184fn ensure_repo_gitignore_ignores_audit_session(
186 project_root: &Path,
187 ito_dir: &str,
188) -> CoreResult<()> {
189 let entry = format!("{ito_dir}/.state/audit/.session");
190 ensure_gitignore_contains_line(project_root, &entry)
191}
192
193fn remove_repo_gitignore_unignores_audit_events(
195 project_root: &Path,
196 ito_dir: &str,
197) -> CoreResult<()> {
198 let entry = format!("!{ito_dir}/.state/audit/");
199 remove_gitignore_exact_line(project_root, &entry)
200}
201
202fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
203 let path = project_root.join(".gitignore");
204 let existing = match ito_common::io::read_to_string_std(&path) {
205 Ok(s) => Some(s),
206 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
207 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
208 };
209
210 let Some(mut s) = existing else {
211 ito_common::io::write_std(&path, format!("{entry}\n"))
212 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
213 return Ok(());
214 };
215
216 if gitignore_has_exact_line(&s, entry) {
217 return Ok(());
218 }
219
220 if !s.ends_with('\n') {
221 s.push('\n');
222 }
223 s.push_str(entry);
224 s.push('\n');
225
226 ito_common::io::write_std(&path, s)
227 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
228 Ok(())
229}
230
231fn remove_gitignore_exact_line(project_root: &Path, entry: &str) -> CoreResult<()> {
232 let path = project_root.join(".gitignore");
233 let existing = match ito_common::io::read_to_string_std(&path) {
234 Ok(s) => s,
235 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
236 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
237 };
238
239 let mut filtered = Vec::new();
240 let mut removed = false;
241 for line in existing.lines() {
242 if line.trim() == entry {
243 removed = true;
244 continue;
245 }
246 filtered.push(line);
247 }
248 if !removed {
249 return Ok(());
250 }
251
252 let mut updated = filtered.join("\n");
253 if !updated.is_empty() {
254 updated.push('\n');
255 }
256
257 ito_common::io::write_std(&path, updated)
258 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
259 Ok(())
260}
261
262fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
263 contents.lines().map(|l| l.trim()).any(|l| l == entry)
264}
265
266fn install_project_templates(
267 project_root: &Path,
268 ito_dir: &str,
269 mode: InstallMode,
270 opts: &InitOptions,
271 worktree_ctx: Option<&WorktreeTemplateContext>,
272) -> CoreResult<()> {
273 use ito_templates::project_templates::render_project_template;
274
275 let selected = &opts.tools;
276 let current_date = Utc::now().format("%Y-%m-%d").to_string();
277 let state_rel = format!("{ito_dir}/planning/STATE.md");
278 let config_json_rel = format!("{ito_dir}/config.json");
279 let release_tag = release_tag();
280 let default_ctx = WorktreeTemplateContext::default();
281 let ctx = worktree_ctx.unwrap_or(&default_ctx);
282
283 for f in ito_templates::default_project_files() {
284 let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
285 let rel = rel.as_ref();
286
287 if !should_install_project_rel(rel, selected) {
288 continue;
289 }
290
291 let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
292 if let Ok(s) = std::str::from_utf8(&bytes) {
293 if rel == state_rel {
294 bytes = s.replace("__CURRENT_DATE__", ¤t_date).into_bytes();
295 } else if rel == config_json_rel {
296 bytes = s
297 .replace(CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER, &release_tag)
298 .into_bytes();
299 }
300 }
301
302 if rel == "AGENTS.md" {
307 bytes = render_project_template(&bytes, ctx).map_err(|e| {
308 CoreError::Validation(format!("Failed to render template {rel}: {e}"))
309 })?;
310 }
311
312 let ownership = classify_project_file_ownership(rel, ito_dir);
313
314 let target = project_root.join(rel);
315 if rel == ".claude/settings.json" {
316 write_claude_settings(&target, &bytes, mode, opts)?;
317 continue;
318 }
319 write_one(&target, &bytes, mode, opts, ownership)?;
320 }
321
322 Ok(())
323}
324
325fn release_tag() -> String {
326 let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
327 if version.starts_with('v') {
328 return version.to_string();
329 }
330
331 format!("v{version}")
332}
333
334fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
335 if rel == "AGENTS.md" {
337 return true;
338 }
339 if rel.starts_with(".ito/") {
340 return true;
341 }
342
343 if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
345 return tools.contains(TOOL_CLAUDE);
346 }
347 if rel.starts_with(".opencode/") {
348 return tools.contains(TOOL_OPENCODE);
349 }
350 if rel.starts_with(".github/") {
351 return tools.contains(TOOL_GITHUB_COPILOT);
352 }
353 if rel.starts_with(".codex/") {
354 return tools.contains(TOOL_CODEX);
355 }
356 if rel.starts_with(".pi/") {
357 return tools.contains(TOOL_PI);
358 }
359
360 false
362}
363
364fn classify_project_file_ownership(rel: &str, ito_dir: &str) -> FileOwnership {
365 let project_md_rel = format!("{ito_dir}/project.md");
366 if rel == project_md_rel {
367 return FileOwnership::UserOwned;
368 }
369
370 let config_json_rel = format!("{ito_dir}/config.json");
371 if rel == config_json_rel {
372 return FileOwnership::UserOwned;
373 }
374
375 let user_guidance_rel = format!("{ito_dir}/user-guidance.md");
376 if rel == user_guidance_rel {
377 return FileOwnership::UserOwned;
378 }
379
380 let user_prompts_prefix = format!("{ito_dir}/user-prompts/");
381 if rel.starts_with(&user_prompts_prefix) {
382 return FileOwnership::UserOwned;
383 }
384
385 FileOwnership::ItoManaged
386}
387
388fn write_one(
404 target: &Path,
405 rendered_bytes: &[u8],
406 mode: InstallMode,
407 opts: &InitOptions,
408 ownership: FileOwnership,
409) -> CoreResult<()> {
410 if let Some(parent) = target.parent() {
411 ito_common::io::create_dir_all_std(parent)
412 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
413 }
414
415 if let Ok(text) = std::str::from_utf8(rendered_bytes)
417 && let Some(block) = ito_templates::extract_managed_block(text)
418 {
419 if target.exists() {
420 if mode == InstallMode::Init && opts.force {
422 ito_common::io::write_std(target, rendered_bytes)
423 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
424 return Ok(());
425 }
426
427 let existing = ito_common::io::read_to_string_std(target)
429 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
430 let has_markers = existing.contains(ito_templates::ITO_START_MARKER)
431 && existing.contains(ito_templates::ITO_END_MARKER);
432
433 if !has_markers {
434 if opts.upgrade {
435 eprintln!(
439 "warning: skipping upgrade of {} — Ito markers not found.\n\
440 To restore managed upgrade support, re-add the markers manually:\n\
441 \n\
442 {start}\n\
443 <ito-managed content>\n\
444 {end}\n\
445 \n\
446 Then re-run `ito init --upgrade`.",
447 target.display(),
448 start = ito_templates::ITO_START_MARKER,
449 end = ito_templates::ITO_END_MARKER,
450 );
451 return Ok(());
452 }
453
454 if mode == InstallMode::Init && !opts.update {
455 return Err(CoreError::Validation(format!(
457 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
458 target.display()
459 )));
460 }
461
462 }
465
466 update_file_with_markers(
467 target,
468 block,
469 ito_templates::ITO_START_MARKER,
470 ito_templates::ITO_END_MARKER,
471 )
472 .map_err(|e| match e {
473 markers::FsEditError::Io(io_err) => {
474 CoreError::io(format!("updating markers in {}", target.display()), io_err)
475 }
476 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
477 "Failed to update markers in {}: {}",
478 target.display(),
479 marker_err
480 )),
481 })?;
482 } else {
483 ito_common::io::write_std(target, rendered_bytes)
485 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
486 }
487
488 return Ok(());
489 }
490
491 if target.exists() {
492 match mode {
493 InstallMode::Init => {
494 if opts.force {
495 } else if opts.update {
497 if ownership == FileOwnership::UserOwned {
498 return Ok(());
499 }
500 } else {
501 return Err(CoreError::Validation(format!(
502 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
503 target.display()
504 )));
505 }
506 }
507 InstallMode::Update => {
508 if ownership == FileOwnership::UserOwned {
509 return Ok(());
510 }
511 }
512 }
513 }
514
515 ito_common::io::write_std(target, rendered_bytes)
516 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
517 Ok(())
518}
519
520fn write_claude_settings(
521 target: &Path,
522 rendered_bytes: &[u8],
523 mode: InstallMode,
524 opts: &InitOptions,
525) -> CoreResult<()> {
526 if let Some(parent) = target.parent() {
527 ito_common::io::create_dir_all_std(parent)
528 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
529 }
530
531 if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
532 return Err(CoreError::Validation(format!(
533 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
534 target.display()
535 )));
536 }
537
538 let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
539 CoreError::Validation(format!(
540 "Failed to parse Claude settings template {}: {}",
541 target.display(),
542 e
543 ))
544 })?;
545
546 if !target.exists() || (mode == InstallMode::Init && opts.force) {
547 let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
548 CoreError::Validation(format!(
549 "Failed to render Claude settings template {}: {}",
550 target.display(),
551 e
552 ))
553 })?;
554 bytes.push(b'\n');
555 ito_common::io::write_std(target, bytes)
556 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
557 return Ok(());
558 }
559
560 let existing_raw = ito_common::io::read_to_string_std(target)
561 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
562 let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
563 return Ok(());
565 };
566
567 merge_json_objects(&mut existing_value, &template_value);
568 let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
569 CoreError::Validation(format!(
570 "Failed to render merged Claude settings {}: {}",
571 target.display(),
572 e
573 ))
574 })?;
575 merged.push(b'\n');
576 ito_common::io::write_std(target, merged)
577 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
578 Ok(())
579}
580
581fn merge_json_objects(existing: &mut Value, template: &Value) {
582 let Value::Object(template_map) = template else {
583 *existing = template.clone();
584 return;
585 };
586 if !existing.is_object() {
587 *existing = Value::Object(Map::new());
588 }
589
590 let Some(existing_map) = existing.as_object_mut() else {
591 return;
592 };
593
594 for (key, template_value) in template_map {
595 if let Some(existing_value) = existing_map.get_mut(key) {
596 merge_json_values(existing_value, template_value);
597 } else {
598 existing_map.insert(key.clone(), template_value.clone());
599 }
600 }
601}
602
603fn merge_json_values(existing: &mut Value, template: &Value) {
604 match (existing, template) {
605 (Value::Object(existing_map), Value::Object(template_map)) => {
606 for (key, template_value) in template_map {
607 if let Some(existing_value) = existing_map.get_mut(key) {
608 merge_json_values(existing_value, template_value);
609 } else {
610 existing_map.insert(key.clone(), template_value.clone());
611 }
612 }
613 }
614 (Value::Array(existing_items), Value::Array(template_items)) => {
615 for template_item in template_items {
616 if !existing_items.contains(template_item) {
617 existing_items.push(template_item.clone());
618 }
619 }
620 }
621 (existing_value, template_value) => *existing_value = template_value.clone(),
622 }
623}
624
625fn install_adapter_files(
626 project_root: &Path,
627 _mode: InstallMode,
628 opts: &InitOptions,
629 worktree_ctx: Option<&WorktreeTemplateContext>,
630) -> CoreResult<()> {
631 for tool in &opts.tools {
632 match tool.as_str() {
633 TOOL_OPENCODE => {
634 let config_dir = project_root.join(".opencode");
635 let manifests = crate::distribution::opencode_manifests(&config_dir);
636 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
637 }
638 TOOL_CLAUDE => {
639 let manifests = crate::distribution::claude_manifests(project_root);
640 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
641 }
642 TOOL_CODEX => {
643 let manifests = crate::distribution::codex_manifests(project_root);
644 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
645 }
646 TOOL_GITHUB_COPILOT => {
647 let manifests = crate::distribution::github_manifests(project_root);
648 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
649 }
650 TOOL_PI => {
651 let manifests = crate::distribution::pi_manifests(project_root);
652 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
653 }
654 _ => {}
655 }
656 }
657
658 Ok(())
659}
660
661fn install_agent_templates(
663 project_root: &Path,
664 mode: InstallMode,
665 opts: &InitOptions,
666) -> CoreResult<()> {
667 use ito_templates::agents::{
668 AgentTier, Harness, default_agent_configs, get_agent_files, render_agent_template,
669 };
670
671 let configs = default_agent_configs();
672
673 let tool_harness_map = [
675 (TOOL_OPENCODE, Harness::OpenCode),
676 (TOOL_CLAUDE, Harness::ClaudeCode),
677 (TOOL_CODEX, Harness::Codex),
678 (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
679 (TOOL_PI, Harness::Pi),
680 ];
681
682 for (tool_id, harness) in tool_harness_map {
683 if !opts.tools.contains(tool_id) {
684 continue;
685 }
686
687 let agent_dir = project_root.join(harness.project_agent_path());
688
689 let files = get_agent_files(harness);
691
692 for (rel_path, contents) in files {
693 let target = agent_dir.join(rel_path);
694
695 let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
697 Some(AgentTier::Quick)
698 } else if rel_path.contains("ito-general") || rel_path.contains("general") {
699 Some(AgentTier::General)
700 } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
701 Some(AgentTier::Thinking)
702 } else {
703 None
704 };
705
706 let mut config = tier.and_then(|t| configs.get(&(harness, t)));
712 if config.is_none()
713 && let Ok(s) = std::str::from_utf8(contents)
714 && s.contains("{{model}}")
715 {
716 config = configs.get(&(harness, AgentTier::General));
717 }
718
719 match mode {
720 InstallMode::Init => {
721 if target.exists() {
722 if opts.update {
723 if let Some(cfg) = config {
725 update_agent_model_field(&target, &cfg.model)?;
726 }
727 continue;
728 }
729 if !opts.force {
730 continue;
732 }
733 }
734
735 let rendered = if let Some(cfg) = config {
737 if let Ok(template_str) = std::str::from_utf8(contents) {
738 render_agent_template(template_str, cfg).into_bytes()
739 } else {
740 contents.to_vec()
741 }
742 } else {
743 contents.to_vec()
744 };
745
746 if let Some(parent) = target.parent() {
748 ito_common::io::create_dir_all_std(parent).map_err(|e| {
749 CoreError::io(format!("creating directory {}", parent.display()), e)
750 })?;
751 }
752
753 ito_common::io::write_std(&target, rendered)
754 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
755 }
756 InstallMode::Update => {
757 if !target.exists() {
759 let rendered = if let Some(cfg) = config {
761 if let Ok(template_str) = std::str::from_utf8(contents) {
762 render_agent_template(template_str, cfg).into_bytes()
763 } else {
764 contents.to_vec()
765 }
766 } else {
767 contents.to_vec()
768 };
769
770 if let Some(parent) = target.parent() {
771 ito_common::io::create_dir_all_std(parent).map_err(|e| {
772 CoreError::io(format!("creating directory {}", parent.display()), e)
773 })?;
774 }
775 ito_common::io::write_std(&target, rendered).map_err(|e| {
776 CoreError::io(format!("writing {}", target.display()), e)
777 })?;
778 } else if let Some(cfg) = config {
779 update_agent_model_field(&target, &cfg.model)?;
781 }
782 }
783 }
784 }
785 }
786
787 Ok(())
788}
789
790fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
792 let content = ito_common::io::read_to_string_or_default(path);
793
794 if !content.starts_with("---") {
796 return Ok(());
797 }
798
799 let rest = &content[3..];
801 let Some(end_idx) = rest.find("\n---") else {
802 return Ok(());
803 };
804
805 let frontmatter = &rest[..end_idx];
806 let body = &rest[end_idx + 4..]; let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
810
811 let updated = format!("---{}\n---{}", updated_frontmatter, body);
813 ito_common::io::write_std(path, updated)
814 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
815
816 Ok(())
817}
818
819fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
821 let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
822 let mut found = false;
823
824 for line in &mut lines {
825 if line.trim_start().starts_with("model:") {
826 *line = format!("model: \"{}\"", new_model);
827 found = true;
828 break;
829 }
830 }
831
832 if !found {
834 lines.push(format!("model: \"{}\"", new_model));
835 }
836
837 lines.join("\n")
838}
839
840#[cfg(test)]
841mod json_tests;
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846
847 #[test]
848 fn gitignore_created_when_missing() {
849 let td = tempfile::tempdir().unwrap();
850 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
851 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
852 assert_eq!(s, ".ito/session.json\n");
853 }
854
855 #[test]
856 fn gitignore_noop_when_already_present() {
857 let td = tempfile::tempdir().unwrap();
858 std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
859 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
860 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
861 assert_eq!(s, ".ito/session.json\n");
862 }
863
864 #[test]
865 fn gitignore_does_not_duplicate_on_repeated_calls() {
866 let td = tempfile::tempdir().unwrap();
867 std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
868 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
869 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
870 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
871 assert_eq!(s, "node_modules\n.ito/session.json\n");
872 }
873
874 #[test]
875 fn gitignore_audit_session_added() {
876 let td = tempfile::tempdir().unwrap();
877 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
878 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
879 assert!(s.contains(".ito/.state/audit/.session"));
880 }
881
882 #[test]
883 fn gitignore_both_session_entries() {
884 let td = tempfile::tempdir().unwrap();
885 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
886 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
887 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
888 assert!(s.contains(".ito/session.json"));
889 assert!(s.contains(".ito/.state/audit/.session"));
890 }
891
892 #[test]
893 fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
894 let td = tempfile::tempdir().unwrap();
895 std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
896 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
897 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
898 assert_eq!(s, "node_modules\n.ito/session.json\n");
899 }
900
901 #[test]
902 fn gitignore_legacy_audit_events_unignore_removed() {
903 let td = tempfile::tempdir().unwrap();
904 std::fs::write(
905 td.path().join(".gitignore"),
906 ".ito/.state/\n!.ito/.state/audit/\n",
907 )
908 .unwrap();
909 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
910 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
911 assert_eq!(s, ".ito/.state/\n");
912 }
913
914 #[test]
915 fn gitignore_legacy_audit_events_unignore_noop_when_absent() {
916 let td = tempfile::tempdir().unwrap();
917 std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
918 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
919 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
920 assert_eq!(s, ".ito/.state/\n");
921 }
922
923 #[test]
924 fn gitignore_full_audit_setup() {
925 let td = tempfile::tempdir().unwrap();
926 std::fs::write(
928 td.path().join(".gitignore"),
929 ".ito/.state/\n!.ito/.state/audit/\n",
930 )
931 .unwrap();
932 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
933 remove_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}