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 remove_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
178 }
179
180 ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
182
183 install_adapter_files(project_root, mode, opts, worktree_ctx)?;
184 install_agent_templates(project_root, mode, opts)?;
185 Ok(())
186}
187
188fn ensure_repo_gitignore_ignores_local_configs(
189 project_root: &Path,
190 ito_dir: &str,
191) -> CoreResult<()> {
192 let entry = format!("{ito_dir}/config.local.json");
195 ensure_gitignore_contains_line(project_root, &entry)?;
196
197 let entry = ".local/ito/config.json";
199 ensure_gitignore_contains_line(project_root, entry)?;
200 Ok(())
201}
202
203fn ensure_repo_gitignore_ignores_session_json(
204 project_root: &Path,
205 ito_dir: &str,
206) -> CoreResult<()> {
207 let entry = format!("{ito_dir}/session.json");
208 ensure_gitignore_contains_line(project_root, &entry)
209}
210
211fn ensure_repo_gitignore_ignores_audit_session(
213 project_root: &Path,
214 ito_dir: &str,
215) -> CoreResult<()> {
216 let entry = format!("{ito_dir}/.state/audit/.session");
217 ensure_gitignore_contains_line(project_root, &entry)
218}
219
220fn remove_repo_gitignore_unignores_audit_events(
222 project_root: &Path,
223 ito_dir: &str,
224) -> CoreResult<()> {
225 let entry = format!("!{ito_dir}/.state/audit/");
226 remove_gitignore_exact_line(project_root, &entry)
227}
228
229fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
230 let path = project_root.join(".gitignore");
231 let existing = match ito_common::io::read_to_string_std(&path) {
232 Ok(s) => Some(s),
233 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
234 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
235 };
236
237 let Some(mut s) = existing else {
238 ito_common::io::write_std(&path, format!("{entry}\n"))
239 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
240 return Ok(());
241 };
242
243 if gitignore_has_exact_line(&s, entry) {
244 return Ok(());
245 }
246
247 if !s.ends_with('\n') {
248 s.push('\n');
249 }
250 s.push_str(entry);
251 s.push('\n');
252
253 ito_common::io::write_std(&path, s)
254 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
255 Ok(())
256}
257
258fn remove_gitignore_exact_line(project_root: &Path, entry: &str) -> CoreResult<()> {
259 let path = project_root.join(".gitignore");
260 let existing = match ito_common::io::read_to_string_std(&path) {
261 Ok(s) => s,
262 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
263 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
264 };
265
266 let mut filtered = Vec::new();
267 let mut removed = false;
268 for line in existing.lines() {
269 if line.trim() == entry {
270 removed = true;
271 continue;
272 }
273 filtered.push(line);
274 }
275 if !removed {
276 return Ok(());
277 }
278
279 let mut updated = filtered.join("\n");
280 if !updated.is_empty() {
281 updated.push('\n');
282 }
283
284 ito_common::io::write_std(&path, updated)
285 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
286 Ok(())
287}
288
289fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
290 contents.lines().map(|l| l.trim()).any(|l| l == entry)
291}
292
293fn install_project_templates(
294 project_root: &Path,
295 ito_dir: &str,
296 mode: InstallMode,
297 opts: &InitOptions,
298 worktree_ctx: Option<&WorktreeTemplateContext>,
299) -> CoreResult<()> {
300 use ito_templates::project_templates::render_project_template;
301
302 let selected = &opts.tools;
303 let current_date = Utc::now().format("%Y-%m-%d").to_string();
304 let state_rel = format!("{ito_dir}/planning/STATE.md");
305 let config_json_rel = format!("{ito_dir}/config.json");
306 let release_tag = release_tag();
307 let default_ctx = WorktreeTemplateContext::default();
308 let ctx = worktree_ctx.unwrap_or(&default_ctx);
309
310 for f in ito_templates::default_project_files() {
311 let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
312 let rel = rel.as_ref();
313
314 if !should_install_project_rel(rel, selected) {
315 continue;
316 }
317
318 let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
319 if let Ok(s) = std::str::from_utf8(&bytes) {
320 if rel == state_rel {
321 bytes = s.replace("__CURRENT_DATE__", ¤t_date).into_bytes();
322 } else if rel == config_json_rel {
323 bytes = s
324 .replace(CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER, &release_tag)
325 .into_bytes();
326 }
327 }
328
329 if rel == "AGENTS.md" {
334 bytes = render_project_template(&bytes, ctx).map_err(|e| {
335 CoreError::Validation(format!("Failed to render template {rel}: {e}"))
336 })?;
337 }
338
339 let ownership = classify_project_file_ownership(rel, ito_dir);
340
341 let target = project_root.join(rel);
342 if rel == ".claude/settings.json" {
343 write_claude_settings(&target, &bytes, mode, opts)?;
344 continue;
345 }
346 write_one(&target, &bytes, mode, opts, ownership)?;
347 }
348
349 Ok(())
350}
351
352fn release_tag() -> String {
353 let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
354 if version.starts_with('v') {
355 return version.to_string();
356 }
357
358 format!("v{version}")
359}
360
361fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
362 if rel == "AGENTS.md" {
364 return true;
365 }
366 if rel.starts_with(".ito/") {
367 return true;
368 }
369
370 if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
372 return tools.contains(TOOL_CLAUDE);
373 }
374 if rel.starts_with(".opencode/") {
375 return tools.contains(TOOL_OPENCODE);
376 }
377 if rel.starts_with(".github/") {
378 return tools.contains(TOOL_GITHUB_COPILOT);
379 }
380 if rel.starts_with(".codex/") {
381 return tools.contains(TOOL_CODEX);
382 }
383 if rel.starts_with(".pi/") {
384 return tools.contains(TOOL_PI);
385 }
386
387 false
389}
390
391fn classify_project_file_ownership(rel: &str, ito_dir: &str) -> FileOwnership {
392 let project_md_rel = format!("{ito_dir}/project.md");
393 if rel == project_md_rel {
394 return FileOwnership::UserOwned;
395 }
396
397 let config_json_rel = format!("{ito_dir}/config.json");
398 if rel == config_json_rel {
399 return FileOwnership::UserOwned;
400 }
401
402 let user_guidance_rel = format!("{ito_dir}/user-guidance.md");
403 if rel == user_guidance_rel {
404 return FileOwnership::UserOwned;
405 }
406
407 let user_prompts_prefix = format!("{ito_dir}/user-prompts/");
408 if rel.starts_with(&user_prompts_prefix) {
409 return FileOwnership::UserOwned;
410 }
411
412 FileOwnership::ItoManaged
413}
414
415fn write_one(
443 target: &Path,
444 rendered_bytes: &[u8],
445 mode: InstallMode,
446 opts: &InitOptions,
447 ownership: FileOwnership,
448) -> CoreResult<()> {
449 if let Some(parent) = target.parent() {
450 ito_common::io::create_dir_all_std(parent)
451 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
452 }
453
454 if let Ok(text) = std::str::from_utf8(rendered_bytes)
456 && let Some(block) = ito_templates::extract_managed_block(text)
457 {
458 if target.exists() {
459 if mode == InstallMode::Init && opts.force {
461 ito_common::io::write_std(target, rendered_bytes)
462 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
463 return Ok(());
464 }
465
466 let existing = ito_common::io::read_to_string_std(target)
468 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
469 let has_markers = existing.contains(ito_templates::ITO_START_MARKER)
470 && existing.contains(ito_templates::ITO_END_MARKER);
471
472 if !has_markers {
473 if opts.upgrade {
474 eprintln!(
478 "warning: skipping upgrade of {} — Ito markers not found.\n\
479 To restore managed upgrade support, re-add the markers manually:\n\
480 \n\
481 {start}\n\
482 <ito-managed content>\n\
483 {end}\n\
484 \n\
485 Then re-run `ito init --upgrade`.",
486 target.display(),
487 start = ito_templates::ITO_START_MARKER,
488 end = ito_templates::ITO_END_MARKER,
489 );
490 return Ok(());
491 }
492
493 if mode == InstallMode::Init && !opts.update {
494 return Err(CoreError::Validation(format!(
496 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
497 target.display()
498 )));
499 }
500
501 }
504
505 update_file_with_markers(
506 target,
507 block,
508 ito_templates::ITO_START_MARKER,
509 ito_templates::ITO_END_MARKER,
510 )
511 .map_err(|e| match e {
512 markers::FsEditError::Io(io_err) => {
513 CoreError::io(format!("updating markers in {}", target.display()), io_err)
514 }
515 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
516 "Failed to update markers in {}: {}",
517 target.display(),
518 marker_err
519 )),
520 })?;
521 } else {
522 ito_common::io::write_std(target, rendered_bytes)
524 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
525 }
526
527 return Ok(());
528 }
529
530 if target.exists() {
531 match mode {
532 InstallMode::Init => {
533 if opts.force {
534 } else if opts.update {
536 if ownership == FileOwnership::UserOwned {
537 return Ok(());
538 }
539 } else {
540 return Err(CoreError::Validation(format!(
541 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
542 target.display()
543 )));
544 }
545 }
546 InstallMode::Update => {
547 if ownership == FileOwnership::UserOwned {
548 return Ok(());
549 }
550 }
551 }
552 }
553
554 ito_common::io::write_std(target, rendered_bytes)
555 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
556 Ok(())
557}
558
559fn write_claude_settings(
560 target: &Path,
561 rendered_bytes: &[u8],
562 mode: InstallMode,
563 opts: &InitOptions,
564) -> CoreResult<()> {
565 if let Some(parent) = target.parent() {
566 ito_common::io::create_dir_all_std(parent)
567 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
568 }
569
570 if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
571 return Err(CoreError::Validation(format!(
572 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
573 target.display()
574 )));
575 }
576
577 let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
578 CoreError::Validation(format!(
579 "Failed to parse Claude settings template {}: {}",
580 target.display(),
581 e
582 ))
583 })?;
584
585 if !target.exists() || (mode == InstallMode::Init && opts.force) {
586 let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
587 CoreError::Validation(format!(
588 "Failed to render Claude settings template {}: {}",
589 target.display(),
590 e
591 ))
592 })?;
593 bytes.push(b'\n');
594 ito_common::io::write_std(target, bytes)
595 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
596 return Ok(());
597 }
598
599 let existing_raw = ito_common::io::read_to_string_std(target)
600 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
601 let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
602 return Ok(());
604 };
605
606 merge_json_objects(&mut existing_value, &template_value);
607 let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
608 CoreError::Validation(format!(
609 "Failed to render merged Claude settings {}: {}",
610 target.display(),
611 e
612 ))
613 })?;
614 merged.push(b'\n');
615 ito_common::io::write_std(target, merged)
616 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
617 Ok(())
618}
619
620fn merge_json_objects(existing: &mut Value, template: &Value) {
621 let Value::Object(template_map) = template else {
622 *existing = template.clone();
623 return;
624 };
625 if !existing.is_object() {
626 *existing = Value::Object(Map::new());
627 }
628
629 let Some(existing_map) = existing.as_object_mut() else {
630 return;
631 };
632
633 for (key, template_value) in template_map {
634 if let Some(existing_value) = existing_map.get_mut(key) {
635 merge_json_values(existing_value, template_value);
636 } else {
637 existing_map.insert(key.clone(), template_value.clone());
638 }
639 }
640}
641
642fn merge_json_values(existing: &mut Value, template: &Value) {
643 match (existing, template) {
644 (Value::Object(existing_map), Value::Object(template_map)) => {
645 for (key, template_value) in template_map {
646 if let Some(existing_value) = existing_map.get_mut(key) {
647 merge_json_values(existing_value, template_value);
648 } else {
649 existing_map.insert(key.clone(), template_value.clone());
650 }
651 }
652 }
653 (Value::Array(existing_items), Value::Array(template_items)) => {
654 for template_item in template_items {
655 if !existing_items.contains(template_item) {
656 existing_items.push(template_item.clone());
657 }
658 }
659 }
660 (existing_value, template_value) => *existing_value = template_value.clone(),
661 }
662}
663
664fn install_adapter_files(
665 project_root: &Path,
666 _mode: InstallMode,
667 opts: &InitOptions,
668 worktree_ctx: Option<&WorktreeTemplateContext>,
669) -> CoreResult<()> {
670 for tool in &opts.tools {
671 match tool.as_str() {
672 TOOL_OPENCODE => {
673 let config_dir = project_root.join(".opencode");
674 let manifests = crate::distribution::opencode_manifests(&config_dir);
675 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
676 }
677 TOOL_CLAUDE => {
678 let manifests = crate::distribution::claude_manifests(project_root);
679 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
680 }
681 TOOL_CODEX => {
682 let manifests = crate::distribution::codex_manifests(project_root);
683 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
684 }
685 TOOL_GITHUB_COPILOT => {
686 let manifests = crate::distribution::github_manifests(project_root);
687 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
688 }
689 TOOL_PI => {
690 let manifests = crate::distribution::pi_manifests(project_root);
691 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
692 }
693 _ => {}
694 }
695 }
696
697 Ok(())
698}
699
700fn install_agent_templates(
702 project_root: &Path,
703 mode: InstallMode,
704 opts: &InitOptions,
705) -> CoreResult<()> {
706 use ito_templates::agents::{
707 AgentTier, Harness, default_agent_configs, get_agent_files, render_agent_template,
708 };
709
710 let configs = default_agent_configs();
711
712 let tool_harness_map = [
714 (TOOL_OPENCODE, Harness::OpenCode),
715 (TOOL_CLAUDE, Harness::ClaudeCode),
716 (TOOL_CODEX, Harness::Codex),
717 (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
718 (TOOL_PI, Harness::Pi),
719 ];
720
721 for (tool_id, harness) in tool_harness_map {
722 if !opts.tools.contains(tool_id) {
723 continue;
724 }
725
726 let agent_dir = project_root.join(harness.project_agent_path());
727
728 let files = get_agent_files(harness);
730
731 for (rel_path, contents) in files {
732 let target = agent_dir.join(rel_path);
733
734 let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
736 Some(AgentTier::Quick)
737 } else if rel_path.contains("ito-general") || rel_path.contains("general") {
738 Some(AgentTier::General)
739 } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
740 Some(AgentTier::Thinking)
741 } else {
742 None
743 };
744
745 let config = tier.and_then(|t| configs.get(&(harness, t)));
747
748 match mode {
749 InstallMode::Init => {
750 if target.exists() {
751 if opts.update {
752 if let Some(cfg) = config {
754 update_agent_model_field(&target, &cfg.model)?;
755 }
756 continue;
757 }
758 if !opts.force {
759 continue;
761 }
762 }
763
764 let rendered = if let Some(cfg) = config {
766 if let Ok(template_str) = std::str::from_utf8(contents) {
767 render_agent_template(template_str, cfg).into_bytes()
768 } else {
769 contents.to_vec()
770 }
771 } else {
772 contents.to_vec()
773 };
774
775 if let Some(parent) = target.parent() {
777 ito_common::io::create_dir_all_std(parent).map_err(|e| {
778 CoreError::io(format!("creating directory {}", parent.display()), e)
779 })?;
780 }
781
782 ito_common::io::write_std(&target, rendered)
783 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
784 }
785 InstallMode::Update => {
786 if !target.exists() {
788 let rendered = if let Some(cfg) = config {
790 if let Ok(template_str) = std::str::from_utf8(contents) {
791 render_agent_template(template_str, cfg).into_bytes()
792 } else {
793 contents.to_vec()
794 }
795 } else {
796 contents.to_vec()
797 };
798
799 if let Some(parent) = target.parent() {
800 ito_common::io::create_dir_all_std(parent).map_err(|e| {
801 CoreError::io(format!("creating directory {}", parent.display()), e)
802 })?;
803 }
804 ito_common::io::write_std(&target, rendered).map_err(|e| {
805 CoreError::io(format!("writing {}", target.display()), e)
806 })?;
807 } else if let Some(cfg) = config {
808 update_agent_model_field(&target, &cfg.model)?;
810 }
811 }
812 }
813 }
814 }
815
816 Ok(())
817}
818
819fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
821 let content = ito_common::io::read_to_string_or_default(path);
822
823 if !content.starts_with("---") {
825 return Ok(());
826 }
827
828 let rest = &content[3..];
830 let Some(end_idx) = rest.find("\n---") else {
831 return Ok(());
832 };
833
834 let frontmatter = &rest[..end_idx];
835 let body = &rest[end_idx + 4..]; let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
839
840 let updated = format!("---{}\n---{}", updated_frontmatter, body);
842 ito_common::io::write_std(path, updated)
843 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
844
845 Ok(())
846}
847
848fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
865 let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
866 let mut found = false;
867
868 for line in &mut lines {
869 if line.trim_start().starts_with("model:") {
870 *line = format!("model: \"{}\"", new_model);
871 found = true;
872 break;
873 }
874 }
875
876 if !found {
878 lines.push(format!("model: \"{}\"", new_model));
879 }
880
881 lines.join("\n")
882}
883
884#[cfg(test)]
885mod json_tests;
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890
891 #[test]
892 fn gitignore_created_when_missing() {
893 let td = tempfile::tempdir().unwrap();
894 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
895 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
896 assert_eq!(s, ".ito/session.json\n");
897 }
898
899 #[test]
900 fn gitignore_noop_when_already_present() {
901 let td = tempfile::tempdir().unwrap();
902 std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
903 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
904 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
905 assert_eq!(s, ".ito/session.json\n");
906 }
907
908 #[test]
909 fn gitignore_does_not_duplicate_on_repeated_calls() {
910 let td = tempfile::tempdir().unwrap();
911 std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
912 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
913 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
914 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
915 assert_eq!(s, "node_modules\n.ito/session.json\n");
916 }
917
918 #[test]
919 fn gitignore_audit_session_added() {
920 let td = tempfile::tempdir().unwrap();
921 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
922 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
923 assert!(s.contains(".ito/.state/audit/.session"));
924 }
925
926 #[test]
927 fn gitignore_both_session_entries() {
928 let td = tempfile::tempdir().unwrap();
929 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
930 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
931 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
932 assert!(s.contains(".ito/session.json"));
933 assert!(s.contains(".ito/.state/audit/.session"));
934 }
935
936 #[test]
937 fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
938 let td = tempfile::tempdir().unwrap();
939 std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
940 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
941 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
942 assert_eq!(s, "node_modules\n.ito/session.json\n");
943 }
944
945 #[test]
946 fn gitignore_legacy_audit_events_unignore_removed() {
947 let td = tempfile::tempdir().unwrap();
948 std::fs::write(
949 td.path().join(".gitignore"),
950 ".ito/.state/\n!.ito/.state/audit/\n",
951 )
952 .unwrap();
953 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
954 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
955 assert_eq!(s, ".ito/.state/\n");
956 }
957
958 #[test]
959 fn gitignore_legacy_audit_events_unignore_noop_when_absent() {
960 let td = tempfile::tempdir().unwrap();
961 std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
962 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
963 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
964 assert_eq!(s, ".ito/.state/\n");
965 }
966
967 #[test]
968 fn gitignore_full_audit_setup() {
969 let td = tempfile::tempdir().unwrap();
970 std::fs::write(
972 td.path().join(".gitignore"),
973 ".ito/.state/\n!.ito/.state/audit/\n",
974 )
975 .unwrap();
976 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
977 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
978 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
979 assert!(s.contains(".ito/.state/audit/.session"));
980 assert!(!s.contains("!.ito/.state/audit/"));
981 }
982
983 #[test]
984 fn gitignore_ignores_local_configs() {
985 let td = tempfile::tempdir().unwrap();
986 ensure_repo_gitignore_ignores_local_configs(td.path(), ".ito").unwrap();
987 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
988 assert!(s.contains(".ito/config.local.json"));
989 assert!(s.contains(".local/ito/config.json"));
990 }
991
992 #[test]
993 fn gitignore_exact_line_matching_trims_whitespace() {
994 assert!(gitignore_has_exact_line(" foo \nbar\n", "foo"));
995 assert!(!gitignore_has_exact_line("foo\n", "bar"));
996 }
997
998 #[test]
999 fn should_install_project_rel_filters_by_tool_id() {
1000 let mut tools = BTreeSet::new();
1001 tools.insert(TOOL_OPENCODE.to_string());
1002
1003 assert!(should_install_project_rel("AGENTS.md", &tools));
1004 assert!(should_install_project_rel(".ito/config.json", &tools));
1005 assert!(should_install_project_rel(".opencode/config.json", &tools));
1006 assert!(!should_install_project_rel(".claude/settings.json", &tools));
1007 assert!(!should_install_project_rel(".codex/config.json", &tools));
1008 assert!(!should_install_project_rel(
1009 ".github/workflows/x.yml",
1010 &tools
1011 ));
1012 assert!(!should_install_project_rel(".pi/settings.json", &tools));
1013 }
1014
1015 #[test]
1016 fn should_install_project_rel_filters_pi() {
1017 let mut tools = BTreeSet::new();
1018 tools.insert(TOOL_PI.to_string());
1019
1020 assert!(should_install_project_rel(".pi/settings.json", &tools));
1022 assert!(should_install_project_rel(
1023 ".pi/extensions/ito-skills.ts",
1024 &tools
1025 ));
1026
1027 assert!(should_install_project_rel("AGENTS.md", &tools));
1029 assert!(should_install_project_rel(".ito/config.json", &tools));
1030
1031 assert!(!should_install_project_rel(".opencode/config.json", &tools));
1033 assert!(!should_install_project_rel(".claude/settings.json", &tools));
1034 assert!(!should_install_project_rel(".codex/config.json", &tools));
1035 }
1036
1037 #[test]
1038 fn release_tag_is_prefixed_with_v() {
1039 let tag = release_tag();
1040 assert!(tag.starts_with('v'));
1041 }
1042
1043 #[test]
1044 fn update_model_in_yaml_replaces_or_inserts() {
1045 let yaml = "name: test\nmodel: \"old\"\n";
1046 let updated = update_model_in_yaml(yaml, "new");
1047 assert!(updated.contains("model: \"new\""));
1048
1049 let yaml = "name: test\n";
1050 let updated = update_model_in_yaml(yaml, "new");
1051 assert!(updated.contains("model: \"new\""));
1052 }
1053
1054 #[test]
1055 fn update_agent_model_field_updates_frontmatter_when_present() {
1056 let td = tempfile::tempdir().unwrap();
1057 let path = td.path().join("agent.md");
1058 std::fs::write(&path, "---\nname: test\nmodel: \"old\"\n---\nbody\n").unwrap();
1059 update_agent_model_field(&path, "new").unwrap();
1060 let s = std::fs::read_to_string(&path).unwrap();
1061 assert!(s.contains("model: \"new\""));
1062
1063 let path = td.path().join("no-frontmatter.md");
1064 std::fs::write(&path, "no frontmatter\n").unwrap();
1065 update_agent_model_field(&path, "newer").unwrap();
1066 let s = std::fs::read_to_string(&path).unwrap();
1067 assert_eq!(s, "no frontmatter\n");
1068 }
1069
1070 #[test]
1071 fn write_one_non_marker_files_skip_on_init_update_mode() {
1072 let td = tempfile::tempdir().unwrap();
1073 let target = td.path().join("plain.txt");
1074 std::fs::write(&target, "existing").unwrap();
1075
1076 let opts = InitOptions::new(BTreeSet::new(), false, true);
1077 write_one(
1078 &target,
1079 b"new",
1080 InstallMode::Init,
1081 &opts,
1082 FileOwnership::UserOwned,
1083 )
1084 .unwrap();
1085 let s = std::fs::read_to_string(&target).unwrap();
1086 assert_eq!(s, "existing");
1087 }
1088
1089 #[test]
1090 fn write_one_non_marker_ito_managed_files_overwrite_on_init_update_mode() {
1091 let td = tempfile::tempdir().unwrap();
1092 let target = td.path().join("plain.txt");
1093 std::fs::write(&target, "existing").unwrap();
1094
1095 let opts = InitOptions::new(BTreeSet::new(), false, true);
1096 write_one(
1097 &target,
1098 b"new",
1099 InstallMode::Init,
1100 &opts,
1101 FileOwnership::ItoManaged,
1102 )
1103 .unwrap();
1104 let s = std::fs::read_to_string(&target).unwrap();
1105 assert_eq!(s, "new");
1106 }
1107
1108 #[test]
1109 fn write_one_non_marker_user_owned_files_preserve_on_update_mode() {
1110 let td = tempfile::tempdir().unwrap();
1111 let target = td.path().join("plain.txt");
1112 std::fs::write(&target, "existing").unwrap();
1113
1114 let opts = InitOptions::new(BTreeSet::new(), false, true);
1115 write_one(
1116 &target,
1117 b"new",
1118 InstallMode::Update,
1119 &opts,
1120 FileOwnership::UserOwned,
1121 )
1122 .unwrap();
1123 let s = std::fs::read_to_string(&target).unwrap();
1124 assert_eq!(s, "existing");
1125 }
1126
1127 #[test]
1128 fn write_one_marker_managed_files_refuse_overwrite_without_markers() {
1129 let td = tempfile::tempdir().unwrap();
1130 let target = td.path().join("managed.md");
1131 std::fs::write(&target, "existing without markers\n").unwrap();
1132
1133 let template = format!(
1134 "before\n{}\nmanaged\n{}\nafter\n",
1135 ito_templates::ITO_START_MARKER,
1136 ito_templates::ITO_END_MARKER
1137 );
1138 let opts = InitOptions::new(BTreeSet::new(), false, false);
1139 let err = write_one(
1140 &target,
1141 template.as_bytes(),
1142 InstallMode::Init,
1143 &opts,
1144 FileOwnership::ItoManaged,
1145 )
1146 .unwrap_err();
1147 assert!(err.to_string().contains("Refusing to overwrite"));
1148 }
1149
1150 #[test]
1151 fn write_one_marker_managed_files_update_existing_markers() {
1152 let td = tempfile::tempdir().unwrap();
1153 let target = td.path().join("managed.md");
1154 let existing = format!(
1155 "before\n{}\nold\n{}\nafter\n",
1156 ito_templates::ITO_START_MARKER,
1157 ito_templates::ITO_END_MARKER
1158 );
1159 std::fs::write(&target, existing).unwrap();
1160
1161 let template = format!(
1162 "before\n{}\nnew\n{}\nafter\n",
1163 ito_templates::ITO_START_MARKER,
1164 ito_templates::ITO_END_MARKER
1165 );
1166 let opts = InitOptions::new(BTreeSet::new(), false, false);
1167 write_one(
1168 &target,
1169 template.as_bytes(),
1170 InstallMode::Init,
1171 &opts,
1172 FileOwnership::ItoManaged,
1173 )
1174 .unwrap();
1175 let s = std::fs::read_to_string(&target).unwrap();
1176 assert!(s.contains("new"));
1177 assert!(!s.contains("old"));
1178 }
1179
1180 #[test]
1181 fn write_one_marker_managed_files_error_when_markers_missing_in_update_mode() {
1182 let td = tempfile::tempdir().unwrap();
1183 let target = td.path().join("managed.md");
1184 std::fs::write(
1186 &target,
1187 format!(
1188 "{}\nexisting without end marker\n",
1189 ito_templates::ITO_START_MARKER
1190 ),
1191 )
1192 .unwrap();
1193
1194 let template = format!(
1195 "before\n{}\nmanaged\n{}\nafter\n",
1196 ito_templates::ITO_START_MARKER,
1197 ito_templates::ITO_END_MARKER
1198 );
1199 let opts = InitOptions::new(BTreeSet::new(), false, true);
1200 let err = write_one(
1201 &target,
1202 template.as_bytes(),
1203 InstallMode::Init,
1204 &opts,
1205 FileOwnership::ItoManaged,
1206 )
1207 .unwrap_err();
1208 assert!(err.to_string().contains("Failed to update markers"));
1209 }
1210}