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 config = tier.and_then(|t| configs.get(&(harness, t)));
708
709 match mode {
710 InstallMode::Init => {
711 if target.exists() {
712 if opts.update {
713 if let Some(cfg) = config {
715 update_agent_model_field(&target, &cfg.model)?;
716 }
717 continue;
718 }
719 if !opts.force {
720 continue;
722 }
723 }
724
725 let rendered = if let Some(cfg) = config {
727 if let Ok(template_str) = std::str::from_utf8(contents) {
728 render_agent_template(template_str, cfg).into_bytes()
729 } else {
730 contents.to_vec()
731 }
732 } else {
733 contents.to_vec()
734 };
735
736 if let Some(parent) = target.parent() {
738 ito_common::io::create_dir_all_std(parent).map_err(|e| {
739 CoreError::io(format!("creating directory {}", parent.display()), e)
740 })?;
741 }
742
743 ito_common::io::write_std(&target, rendered)
744 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
745 }
746 InstallMode::Update => {
747 if !target.exists() {
749 let rendered = if let Some(cfg) = config {
751 if let Ok(template_str) = std::str::from_utf8(contents) {
752 render_agent_template(template_str, cfg).into_bytes()
753 } else {
754 contents.to_vec()
755 }
756 } else {
757 contents.to_vec()
758 };
759
760 if let Some(parent) = target.parent() {
761 ito_common::io::create_dir_all_std(parent).map_err(|e| {
762 CoreError::io(format!("creating directory {}", parent.display()), e)
763 })?;
764 }
765 ito_common::io::write_std(&target, rendered).map_err(|e| {
766 CoreError::io(format!("writing {}", target.display()), e)
767 })?;
768 } else if let Some(cfg) = config {
769 update_agent_model_field(&target, &cfg.model)?;
771 }
772 }
773 }
774 }
775 }
776
777 Ok(())
778}
779
780fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
782 let content = ito_common::io::read_to_string_or_default(path);
783
784 if !content.starts_with("---") {
786 return Ok(());
787 }
788
789 let rest = &content[3..];
791 let Some(end_idx) = rest.find("\n---") else {
792 return Ok(());
793 };
794
795 let frontmatter = &rest[..end_idx];
796 let body = &rest[end_idx + 4..]; let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
800
801 let updated = format!("---{}\n---{}", updated_frontmatter, body);
803 ito_common::io::write_std(path, updated)
804 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
805
806 Ok(())
807}
808
809fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
811 let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
812 let mut found = false;
813
814 for line in &mut lines {
815 if line.trim_start().starts_with("model:") {
816 *line = format!("model: \"{}\"", new_model);
817 found = true;
818 break;
819 }
820 }
821
822 if !found {
824 lines.push(format!("model: \"{}\"", new_model));
825 }
826
827 lines.join("\n")
828}
829
830#[cfg(test)]
831mod json_tests;
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 #[test]
838 fn gitignore_created_when_missing() {
839 let td = tempfile::tempdir().unwrap();
840 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
841 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
842 assert_eq!(s, ".ito/session.json\n");
843 }
844
845 #[test]
846 fn gitignore_noop_when_already_present() {
847 let td = tempfile::tempdir().unwrap();
848 std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
849 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
850 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
851 assert_eq!(s, ".ito/session.json\n");
852 }
853
854 #[test]
855 fn gitignore_does_not_duplicate_on_repeated_calls() {
856 let td = tempfile::tempdir().unwrap();
857 std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
858 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").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, "node_modules\n.ito/session.json\n");
862 }
863
864 #[test]
865 fn gitignore_audit_session_added() {
866 let td = tempfile::tempdir().unwrap();
867 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
868 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
869 assert!(s.contains(".ito/.state/audit/.session"));
870 }
871
872 #[test]
873 fn gitignore_both_session_entries() {
874 let td = tempfile::tempdir().unwrap();
875 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
876 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
877 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
878 assert!(s.contains(".ito/session.json"));
879 assert!(s.contains(".ito/.state/audit/.session"));
880 }
881
882 #[test]
883 fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
884 let td = tempfile::tempdir().unwrap();
885 std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
886 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
887 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
888 assert_eq!(s, "node_modules\n.ito/session.json\n");
889 }
890
891 #[test]
892 fn gitignore_legacy_audit_events_unignore_removed() {
893 let td = tempfile::tempdir().unwrap();
894 std::fs::write(
895 td.path().join(".gitignore"),
896 ".ito/.state/\n!.ito/.state/audit/\n",
897 )
898 .unwrap();
899 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
900 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
901 assert_eq!(s, ".ito/.state/\n");
902 }
903
904 #[test]
905 fn gitignore_legacy_audit_events_unignore_noop_when_absent() {
906 let td = tempfile::tempdir().unwrap();
907 std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
908 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
909 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
910 assert_eq!(s, ".ito/.state/\n");
911 }
912
913 #[test]
914 fn gitignore_full_audit_setup() {
915 let td = tempfile::tempdir().unwrap();
916 std::fs::write(
918 td.path().join(".gitignore"),
919 ".ito/.state/\n!.ito/.state/audit/\n",
920 )
921 .unwrap();
922 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
923 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
924 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
925 assert!(s.contains(".ito/.state/audit/.session"));
926 assert!(!s.contains("!.ito/.state/audit/"));
927 }
928
929 #[test]
930 fn gitignore_ignores_local_configs() {
931 let td = tempfile::tempdir().unwrap();
932 ensure_repo_gitignore_ignores_local_configs(td.path(), ".ito").unwrap();
933 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
934 assert!(s.contains(".ito/config.local.json"));
935 assert!(s.contains(".local/ito/config.json"));
936 }
937
938 #[test]
939 fn gitignore_exact_line_matching_trims_whitespace() {
940 assert!(gitignore_has_exact_line(" foo \nbar\n", "foo"));
941 assert!(!gitignore_has_exact_line("foo\n", "bar"));
942 }
943
944 #[test]
945 fn should_install_project_rel_filters_by_tool_id() {
946 let mut tools = BTreeSet::new();
947 tools.insert(TOOL_OPENCODE.to_string());
948
949 assert!(should_install_project_rel("AGENTS.md", &tools));
950 assert!(should_install_project_rel(".ito/config.json", &tools));
951 assert!(should_install_project_rel(".opencode/config.json", &tools));
952 assert!(!should_install_project_rel(".claude/settings.json", &tools));
953 assert!(!should_install_project_rel(".codex/config.json", &tools));
954 assert!(!should_install_project_rel(
955 ".github/workflows/x.yml",
956 &tools
957 ));
958 assert!(!should_install_project_rel(".pi/settings.json", &tools));
959 }
960
961 #[test]
962 fn should_install_project_rel_filters_pi() {
963 let mut tools = BTreeSet::new();
964 tools.insert(TOOL_PI.to_string());
965
966 assert!(should_install_project_rel(".pi/settings.json", &tools));
968 assert!(should_install_project_rel(
969 ".pi/extensions/ito-skills.ts",
970 &tools
971 ));
972
973 assert!(should_install_project_rel("AGENTS.md", &tools));
975 assert!(should_install_project_rel(".ito/config.json", &tools));
976
977 assert!(!should_install_project_rel(".opencode/config.json", &tools));
979 assert!(!should_install_project_rel(".claude/settings.json", &tools));
980 assert!(!should_install_project_rel(".codex/config.json", &tools));
981 }
982
983 #[test]
984 fn release_tag_is_prefixed_with_v() {
985 let tag = release_tag();
986 assert!(tag.starts_with('v'));
987 }
988
989 #[test]
990 fn update_model_in_yaml_replaces_or_inserts() {
991 let yaml = "name: test\nmodel: \"old\"\n";
992 let updated = update_model_in_yaml(yaml, "new");
993 assert!(updated.contains("model: \"new\""));
994
995 let yaml = "name: test\n";
996 let updated = update_model_in_yaml(yaml, "new");
997 assert!(updated.contains("model: \"new\""));
998 }
999
1000 #[test]
1001 fn update_agent_model_field_updates_frontmatter_when_present() {
1002 let td = tempfile::tempdir().unwrap();
1003 let path = td.path().join("agent.md");
1004 std::fs::write(&path, "---\nname: test\nmodel: \"old\"\n---\nbody\n").unwrap();
1005 update_agent_model_field(&path, "new").unwrap();
1006 let s = std::fs::read_to_string(&path).unwrap();
1007 assert!(s.contains("model: \"new\""));
1008
1009 let path = td.path().join("no-frontmatter.md");
1010 std::fs::write(&path, "no frontmatter\n").unwrap();
1011 update_agent_model_field(&path, "newer").unwrap();
1012 let s = std::fs::read_to_string(&path).unwrap();
1013 assert_eq!(s, "no frontmatter\n");
1014 }
1015
1016 #[test]
1017 fn write_one_non_marker_files_skip_on_init_update_mode() {
1018 let td = tempfile::tempdir().unwrap();
1019 let target = td.path().join("plain.txt");
1020 std::fs::write(&target, "existing").unwrap();
1021
1022 let opts = InitOptions::new(BTreeSet::new(), false, true);
1023 write_one(
1024 &target,
1025 b"new",
1026 InstallMode::Init,
1027 &opts,
1028 FileOwnership::UserOwned,
1029 )
1030 .unwrap();
1031 let s = std::fs::read_to_string(&target).unwrap();
1032 assert_eq!(s, "existing");
1033 }
1034
1035 #[test]
1036 fn write_one_non_marker_ito_managed_files_overwrite_on_init_update_mode() {
1037 let td = tempfile::tempdir().unwrap();
1038 let target = td.path().join("plain.txt");
1039 std::fs::write(&target, "existing").unwrap();
1040
1041 let opts = InitOptions::new(BTreeSet::new(), false, true);
1042 write_one(
1043 &target,
1044 b"new",
1045 InstallMode::Init,
1046 &opts,
1047 FileOwnership::ItoManaged,
1048 )
1049 .unwrap();
1050 let s = std::fs::read_to_string(&target).unwrap();
1051 assert_eq!(s, "new");
1052 }
1053
1054 #[test]
1055 fn write_one_non_marker_user_owned_files_preserve_on_update_mode() {
1056 let td = tempfile::tempdir().unwrap();
1057 let target = td.path().join("plain.txt");
1058 std::fs::write(&target, "existing").unwrap();
1059
1060 let opts = InitOptions::new(BTreeSet::new(), false, true);
1061 write_one(
1062 &target,
1063 b"new",
1064 InstallMode::Update,
1065 &opts,
1066 FileOwnership::UserOwned,
1067 )
1068 .unwrap();
1069 let s = std::fs::read_to_string(&target).unwrap();
1070 assert_eq!(s, "existing");
1071 }
1072
1073 #[test]
1074 fn write_one_marker_managed_files_refuse_overwrite_without_markers() {
1075 let td = tempfile::tempdir().unwrap();
1076 let target = td.path().join("managed.md");
1077 std::fs::write(&target, "existing without markers\n").unwrap();
1078
1079 let template = format!(
1080 "before\n{}\nmanaged\n{}\nafter\n",
1081 ito_templates::ITO_START_MARKER,
1082 ito_templates::ITO_END_MARKER
1083 );
1084 let opts = InitOptions::new(BTreeSet::new(), false, false);
1085 let err = write_one(
1086 &target,
1087 template.as_bytes(),
1088 InstallMode::Init,
1089 &opts,
1090 FileOwnership::ItoManaged,
1091 )
1092 .unwrap_err();
1093 assert!(err.to_string().contains("Refusing to overwrite"));
1094 }
1095
1096 #[test]
1097 fn write_one_marker_managed_files_update_existing_markers() {
1098 let td = tempfile::tempdir().unwrap();
1099 let target = td.path().join("managed.md");
1100 let existing = format!(
1101 "before\n{}\nold\n{}\nafter\n",
1102 ito_templates::ITO_START_MARKER,
1103 ito_templates::ITO_END_MARKER
1104 );
1105 std::fs::write(&target, existing).unwrap();
1106
1107 let template = format!(
1108 "before\n{}\nnew\n{}\nafter\n",
1109 ito_templates::ITO_START_MARKER,
1110 ito_templates::ITO_END_MARKER
1111 );
1112 let opts = InitOptions::new(BTreeSet::new(), false, false);
1113 write_one(
1114 &target,
1115 template.as_bytes(),
1116 InstallMode::Init,
1117 &opts,
1118 FileOwnership::ItoManaged,
1119 )
1120 .unwrap();
1121 let s = std::fs::read_to_string(&target).unwrap();
1122 assert!(s.contains("new"));
1123 assert!(!s.contains("old"));
1124 }
1125
1126 #[test]
1127 fn write_one_marker_managed_files_error_when_markers_missing_in_update_mode() {
1128 let td = tempfile::tempdir().unwrap();
1129 let target = td.path().join("managed.md");
1130 std::fs::write(
1132 &target,
1133 format!(
1134 "{}\nexisting without end marker\n",
1135 ito_templates::ITO_START_MARKER
1136 ),
1137 )
1138 .unwrap();
1139
1140 let template = format!(
1141 "before\n{}\nmanaged\n{}\nafter\n",
1142 ito_templates::ITO_START_MARKER,
1143 ito_templates::ITO_END_MARKER
1144 );
1145 let opts = InitOptions::new(BTreeSet::new(), false, true);
1146 let err = write_one(
1147 &target,
1148 template.as_bytes(),
1149 InstallMode::Init,
1150 &opts,
1151 FileOwnership::ItoManaged,
1152 )
1153 .unwrap_err();
1154 assert!(err.to_string().contains("Failed to update markers"));
1155 }
1156}