Skip to main content

ito_core/installers/
mod.rs

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
17/// Tool id for Claude Code.
18pub const TOOL_CLAUDE: &str = "claude";
19/// Tool id for Codex.
20pub const TOOL_CODEX: &str = "codex";
21/// Tool id for GitHub Copilot.
22pub const TOOL_GITHUB_COPILOT: &str = "github-copilot";
23/// Tool id for OpenCode.
24pub const TOOL_OPENCODE: &str = "opencode";
25
26const CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER: &str = "__ITO_RELEASE_TAG__";
27
28/// Return the set of supported tool ids.
29pub fn available_tool_ids() -> &'static [&'static str] {
30    &[TOOL_CLAUDE, TOOL_CODEX, TOOL_GITHUB_COPILOT, TOOL_OPENCODE]
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34/// Options that control template installation behavior.
35pub struct InitOptions {
36    /// Selected tool ids.
37    pub tools: BTreeSet<String>,
38    /// Overwrite existing files when `true`.
39    pub force: bool,
40    /// When `true`, update managed files while preserving user-edited files.
41    ///
42    /// In this mode, non-marker files that already exist are silently skipped
43    /// instead of triggering an error. Marker-managed files still get their
44    /// managed blocks updated. Adapter files, skills, and commands are
45    /// overwritten as usual.
46    pub update: bool,
47}
48
49impl InitOptions {
50    /// Create new init options.
51    pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
52        Self {
53            tools,
54            force,
55            update,
56        }
57    }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61/// Installation mode used by the installer.
62pub enum InstallMode {
63    /// Initial installation (`ito init`).
64    Init,
65    /// Update installation (`ito update`).
66    Update,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum FileOwnership {
71    ItoManaged,
72    UserOwned,
73}
74
75/// Install the default project templates and selected tool adapters.
76///
77/// When `worktree_ctx` is `Some`, templates containing Jinja2 syntax will be
78/// rendered with the given worktree configuration. When `None`, a disabled
79/// default context is used.
80pub fn install_default_templates(
81    project_root: &Path,
82    ctx: &ConfigContext,
83    mode: InstallMode,
84    opts: &InitOptions,
85    worktree_ctx: Option<&WorktreeTemplateContext>,
86) -> CoreResult<()> {
87    let ito_dir_name = get_ito_dir_name(project_root, ctx);
88    let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
89
90    install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
91
92    // Repository-local ignore rules for per-worktree state.
93    // This is not a templated file: we update `.gitignore` directly to preserve existing content.
94    if mode == InstallMode::Init {
95        ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
96        ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
97        // Un-ignore audit event log so it is git-tracked even if .state/ is broadly ignored.
98        ensure_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
99    }
100
101    // Local (per-developer) config overlays should never be committed.
102    ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
103
104    install_adapter_files(project_root, mode, opts, worktree_ctx)?;
105    install_agent_templates(project_root, mode, opts)?;
106    Ok(())
107}
108
109fn ensure_repo_gitignore_ignores_local_configs(
110    project_root: &Path,
111    ito_dir: &str,
112) -> CoreResult<()> {
113    // Strategy/worktree settings are often personal preferences; users can keep
114    // them in a local overlay file.
115    let entry = format!("{ito_dir}/config.local.json");
116    ensure_gitignore_contains_line(project_root, &entry)?;
117
118    // Optional convention: keep local configs under `.local/`.
119    let entry = ".local/ito/config.json";
120    ensure_gitignore_contains_line(project_root, entry)?;
121    Ok(())
122}
123
124fn ensure_repo_gitignore_ignores_session_json(
125    project_root: &Path,
126    ito_dir: &str,
127) -> CoreResult<()> {
128    let entry = format!("{ito_dir}/session.json");
129    ensure_gitignore_contains_line(project_root, &entry)
130}
131
132/// Ensure `.ito/.state/audit/.session` is gitignored (per-worktree UUID).
133fn ensure_repo_gitignore_ignores_audit_session(
134    project_root: &Path,
135    ito_dir: &str,
136) -> CoreResult<()> {
137    let entry = format!("{ito_dir}/.state/audit/.session");
138    ensure_gitignore_contains_line(project_root, &entry)
139}
140
141/// Un-ignore the audit events directory so `events.jsonl` is git-tracked.
142///
143/// If `.ito/.state/` is broadly gitignored (e.g., by a user rule or template),
144/// we add `!.ito/.state/audit/` to override the ignore and ensure the audit
145/// event log is committed alongside other project artifacts.
146fn ensure_repo_gitignore_unignores_audit_events(
147    project_root: &Path,
148    ito_dir: &str,
149) -> CoreResult<()> {
150    let entry = format!("!{ito_dir}/.state/audit/");
151    ensure_gitignore_contains_line(project_root, &entry)
152}
153
154fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
155    let path = project_root.join(".gitignore");
156    let existing = match ito_common::io::read_to_string_std(&path) {
157        Ok(s) => Some(s),
158        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
159        Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
160    };
161
162    let Some(mut s) = existing else {
163        ito_common::io::write_std(&path, format!("{entry}\n"))
164            .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
165        return Ok(());
166    };
167
168    if gitignore_has_exact_line(&s, entry) {
169        return Ok(());
170    }
171
172    if !s.ends_with('\n') {
173        s.push('\n');
174    }
175    s.push_str(entry);
176    s.push('\n');
177
178    ito_common::io::write_std(&path, s)
179        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
180    Ok(())
181}
182
183fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
184    contents.lines().map(|l| l.trim()).any(|l| l == entry)
185}
186
187fn install_project_templates(
188    project_root: &Path,
189    ito_dir: &str,
190    mode: InstallMode,
191    opts: &InitOptions,
192    worktree_ctx: Option<&WorktreeTemplateContext>,
193) -> CoreResult<()> {
194    use ito_templates::project_templates::render_project_template;
195
196    let selected = &opts.tools;
197    let current_date = Utc::now().format("%Y-%m-%d").to_string();
198    let state_rel = format!("{ito_dir}/planning/STATE.md");
199    let config_json_rel = format!("{ito_dir}/config.json");
200    let release_tag = release_tag();
201    let default_ctx = WorktreeTemplateContext::default();
202    let ctx = worktree_ctx.unwrap_or(&default_ctx);
203
204    for f in ito_templates::default_project_files() {
205        let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
206        let rel = rel.as_ref();
207
208        if !should_install_project_rel(rel, selected) {
209            continue;
210        }
211
212        let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
213        if let Ok(s) = std::str::from_utf8(&bytes) {
214            if rel == state_rel {
215                bytes = s.replace("__CURRENT_DATE__", &current_date).into_bytes();
216            } else if rel == config_json_rel {
217                bytes = s
218                    .replace(CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER, &release_tag)
219                    .into_bytes();
220            }
221        }
222
223        // Render worktree-aware project templates (AGENTS.md) with worktree
224        // config. Only AGENTS.md uses Jinja2 for worktree rendering; other
225        // files (e.g., .ito/commands/) may contain `{{` as user-facing prompt
226        // placeholders that must NOT be processed by minijinja.
227        if rel == "AGENTS.md" {
228            bytes = render_project_template(&bytes, ctx).map_err(|e| {
229                CoreError::Validation(format!("Failed to render template {rel}: {e}"))
230            })?;
231        }
232
233        let ownership = classify_project_file_ownership(rel, ito_dir);
234
235        let target = project_root.join(rel);
236        if rel == ".claude/settings.json" {
237            write_claude_settings(&target, &bytes, mode, opts)?;
238            continue;
239        }
240        write_one(&target, &bytes, mode, opts, ownership)?;
241    }
242
243    Ok(())
244}
245
246fn release_tag() -> String {
247    let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
248    if version.starts_with('v') {
249        return version.to_string();
250    }
251
252    format!("v{version}")
253}
254
255fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
256    // Always install Ito project assets.
257    if rel == "AGENTS.md" {
258        return true;
259    }
260    if rel.starts_with(".ito/") {
261        return true;
262    }
263
264    // Tool-specific assets.
265    if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
266        return tools.contains(TOOL_CLAUDE);
267    }
268    if rel.starts_with(".opencode/") {
269        return tools.contains(TOOL_OPENCODE);
270    }
271    if rel.starts_with(".github/") {
272        return tools.contains(TOOL_GITHUB_COPILOT);
273    }
274    if rel.starts_with(".codex/") {
275        return tools.contains(TOOL_CODEX);
276    }
277
278    // Unknown/unclassified: only install when tools=all (caller controls via set contents).
279    false
280}
281
282fn classify_project_file_ownership(rel: &str, ito_dir: &str) -> FileOwnership {
283    let project_md_rel = format!("{ito_dir}/project.md");
284    if rel == project_md_rel {
285        return FileOwnership::UserOwned;
286    }
287
288    let config_json_rel = format!("{ito_dir}/config.json");
289    if rel == config_json_rel {
290        return FileOwnership::UserOwned;
291    }
292
293    let user_guidance_rel = format!("{ito_dir}/user-guidance.md");
294    if rel == user_guidance_rel {
295        return FileOwnership::UserOwned;
296    }
297
298    let user_prompts_prefix = format!("{ito_dir}/user-prompts/");
299    if rel.starts_with(&user_prompts_prefix) {
300        return FileOwnership::UserOwned;
301    }
302
303    FileOwnership::ItoManaged
304}
305
306fn write_one(
307    target: &Path,
308    rendered_bytes: &[u8],
309    mode: InstallMode,
310    opts: &InitOptions,
311    ownership: FileOwnership,
312) -> CoreResult<()> {
313    if let Some(parent) = target.parent() {
314        ito_common::io::create_dir_all_std(parent)
315            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
316    }
317
318    // Marker-managed files: template contains markers; we extract the inner block.
319    if let Ok(text) = std::str::from_utf8(rendered_bytes)
320        && let Some(block) = ito_templates::extract_managed_block(text)
321    {
322        if target.exists() {
323            if mode == InstallMode::Init && opts.force {
324                ito_common::io::write_std(target, rendered_bytes)
325                    .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
326                return Ok(());
327            }
328
329            if mode == InstallMode::Init && !opts.force && !opts.update {
330                // If the file exists but doesn't contain Ito markers, mimic TS init behavior:
331                // refuse to overwrite without --force or --update.
332                let existing = ito_common::io::read_to_string_or_default(target);
333                let has_start = existing.contains(ito_templates::ITO_START_MARKER);
334                let has_end = existing.contains(ito_templates::ITO_END_MARKER);
335                if !(has_start && has_end) {
336                    return Err(CoreError::Validation(format!(
337                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
338                        target.display()
339                    )));
340                }
341            }
342
343            update_file_with_markers(
344                target,
345                block,
346                ito_templates::ITO_START_MARKER,
347                ito_templates::ITO_END_MARKER,
348            )
349            .map_err(|e| match e {
350                markers::FsEditError::Io(io_err) => {
351                    CoreError::io(format!("updating markers in {}", target.display()), io_err)
352                }
353                markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
354                    "Failed to update markers in {}: {}",
355                    target.display(),
356                    marker_err
357                )),
358            })?;
359        } else {
360            // New file: write the template bytes verbatim so output matches embedded assets.
361            ito_common::io::write_std(target, rendered_bytes)
362                .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
363        }
364
365        return Ok(());
366    }
367
368    if target.exists() {
369        match mode {
370            InstallMode::Init => {
371                if opts.force {
372                    // --force always overwrites on init.
373                } else if opts.update {
374                    if ownership == FileOwnership::UserOwned {
375                        return Ok(());
376                    }
377                } else {
378                    return Err(CoreError::Validation(format!(
379                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
380                        target.display()
381                    )));
382                }
383            }
384            InstallMode::Update => {
385                if ownership == FileOwnership::UserOwned {
386                    return Ok(());
387                }
388            }
389        }
390    }
391
392    ito_common::io::write_std(target, rendered_bytes)
393        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
394    Ok(())
395}
396
397fn write_claude_settings(
398    target: &Path,
399    rendered_bytes: &[u8],
400    mode: InstallMode,
401    opts: &InitOptions,
402) -> CoreResult<()> {
403    if let Some(parent) = target.parent() {
404        ito_common::io::create_dir_all_std(parent)
405            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
406    }
407
408    if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
409        return Err(CoreError::Validation(format!(
410            "Refusing to overwrite existing file without markers: {} (re-run with --force)",
411            target.display()
412        )));
413    }
414
415    let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
416        CoreError::Validation(format!(
417            "Failed to parse Claude settings template {}: {}",
418            target.display(),
419            e
420        ))
421    })?;
422
423    if !target.exists() || (mode == InstallMode::Init && opts.force) {
424        let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
425            CoreError::Validation(format!(
426                "Failed to render Claude settings template {}: {}",
427                target.display(),
428                e
429            ))
430        })?;
431        bytes.push(b'\n');
432        ito_common::io::write_std(target, bytes)
433            .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
434        return Ok(());
435    }
436
437    let existing_raw = ito_common::io::read_to_string_std(target)
438        .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
439    let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
440        // Preserve user-owned files that are not valid JSON during update flows.
441        return Ok(());
442    };
443
444    merge_json_objects(&mut existing_value, &template_value);
445    let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
446        CoreError::Validation(format!(
447            "Failed to render merged Claude settings {}: {}",
448            target.display(),
449            e
450        ))
451    })?;
452    merged.push(b'\n');
453    ito_common::io::write_std(target, merged)
454        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
455    Ok(())
456}
457
458fn merge_json_objects(existing: &mut Value, template: &Value) {
459    let Value::Object(template_map) = template else {
460        *existing = template.clone();
461        return;
462    };
463    if !existing.is_object() {
464        *existing = Value::Object(Map::new());
465    }
466
467    let Some(existing_map) = existing.as_object_mut() else {
468        return;
469    };
470
471    for (key, template_value) in template_map {
472        if let Some(existing_value) = existing_map.get_mut(key) {
473            merge_json_values(existing_value, template_value);
474        } else {
475            existing_map.insert(key.clone(), template_value.clone());
476        }
477    }
478}
479
480fn merge_json_values(existing: &mut Value, template: &Value) {
481    match (existing, template) {
482        (Value::Object(existing_map), Value::Object(template_map)) => {
483            for (key, template_value) in template_map {
484                if let Some(existing_value) = existing_map.get_mut(key) {
485                    merge_json_values(existing_value, template_value);
486                } else {
487                    existing_map.insert(key.clone(), template_value.clone());
488                }
489            }
490        }
491        (Value::Array(existing_items), Value::Array(template_items)) => {
492            for template_item in template_items {
493                if !existing_items.contains(template_item) {
494                    existing_items.push(template_item.clone());
495                }
496            }
497        }
498        (existing_value, template_value) => *existing_value = template_value.clone(),
499    }
500}
501
502fn install_adapter_files(
503    project_root: &Path,
504    _mode: InstallMode,
505    opts: &InitOptions,
506    worktree_ctx: Option<&WorktreeTemplateContext>,
507) -> CoreResult<()> {
508    for tool in &opts.tools {
509        match tool.as_str() {
510            TOOL_OPENCODE => {
511                let config_dir = project_root.join(".opencode");
512                let manifests = crate::distribution::opencode_manifests(&config_dir);
513                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
514            }
515            TOOL_CLAUDE => {
516                let manifests = crate::distribution::claude_manifests(project_root);
517                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
518            }
519            TOOL_CODEX => {
520                let manifests = crate::distribution::codex_manifests(project_root);
521                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
522            }
523            TOOL_GITHUB_COPILOT => {
524                let manifests = crate::distribution::github_manifests(project_root);
525                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
526            }
527            _ => {}
528        }
529    }
530
531    Ok(())
532}
533
534/// Install Ito agent templates (ito-quick, ito-general, ito-thinking)
535fn install_agent_templates(
536    project_root: &Path,
537    mode: InstallMode,
538    opts: &InitOptions,
539) -> CoreResult<()> {
540    use ito_templates::agents::{
541        AgentTier, Harness, default_agent_configs, get_agent_files, render_agent_template,
542    };
543
544    let configs = default_agent_configs();
545
546    // Map tool names to harnesses
547    let tool_harness_map = [
548        (TOOL_OPENCODE, Harness::OpenCode),
549        (TOOL_CLAUDE, Harness::ClaudeCode),
550        (TOOL_CODEX, Harness::Codex),
551        (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
552    ];
553
554    for (tool_id, harness) in tool_harness_map {
555        if !opts.tools.contains(tool_id) {
556            continue;
557        }
558
559        let agent_dir = project_root.join(harness.project_agent_path());
560
561        // Get agent template files for this harness
562        let files = get_agent_files(harness);
563
564        for (rel_path, contents) in files {
565            let target = agent_dir.join(rel_path);
566
567            // Parse the template and determine which tier it is
568            let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
569                Some(AgentTier::Quick)
570            } else if rel_path.contains("ito-general") || rel_path.contains("general") {
571                Some(AgentTier::General)
572            } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
573                Some(AgentTier::Thinking)
574            } else {
575                None
576            };
577
578            // Get config for this tier
579            let config = tier.and_then(|t| configs.get(&(harness, t)));
580
581            match mode {
582                InstallMode::Init => {
583                    if target.exists() {
584                        if opts.update {
585                            // --update: only update model field in existing agent files
586                            if let Some(cfg) = config {
587                                update_agent_model_field(&target, &cfg.model)?;
588                            }
589                            continue;
590                        }
591                        if !opts.force {
592                            // Default init: skip existing files
593                            continue;
594                        }
595                    }
596
597                    // Render full template
598                    let rendered = if let Some(cfg) = config {
599                        if let Ok(template_str) = std::str::from_utf8(contents) {
600                            render_agent_template(template_str, cfg).into_bytes()
601                        } else {
602                            contents.to_vec()
603                        }
604                    } else {
605                        contents.to_vec()
606                    };
607
608                    // Ensure parent directory exists
609                    if let Some(parent) = target.parent() {
610                        ito_common::io::create_dir_all_std(parent).map_err(|e| {
611                            CoreError::io(format!("creating directory {}", parent.display()), e)
612                        })?;
613                    }
614
615                    ito_common::io::write_std(&target, rendered)
616                        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
617                }
618                InstallMode::Update => {
619                    // During update: only update model in existing ito agent files
620                    if !target.exists() {
621                        // File doesn't exist, create it
622                        let rendered = if let Some(cfg) = config {
623                            if let Ok(template_str) = std::str::from_utf8(contents) {
624                                render_agent_template(template_str, cfg).into_bytes()
625                            } else {
626                                contents.to_vec()
627                            }
628                        } else {
629                            contents.to_vec()
630                        };
631
632                        if let Some(parent) = target.parent() {
633                            ito_common::io::create_dir_all_std(parent).map_err(|e| {
634                                CoreError::io(format!("creating directory {}", parent.display()), e)
635                            })?;
636                        }
637                        ito_common::io::write_std(&target, rendered).map_err(|e| {
638                            CoreError::io(format!("writing {}", target.display()), e)
639                        })?;
640                    } else if let Some(cfg) = config {
641                        // File exists, only update model field in frontmatter
642                        update_agent_model_field(&target, &cfg.model)?;
643                    }
644                }
645            }
646        }
647    }
648
649    Ok(())
650}
651
652/// Update only the model field in an existing agent file's frontmatter
653fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
654    let content = ito_common::io::read_to_string_or_default(path);
655
656    // Only update files with frontmatter
657    if !content.starts_with("---") {
658        return Ok(());
659    }
660
661    // Find frontmatter boundaries
662    let rest = &content[3..];
663    let Some(end_idx) = rest.find("\n---") else {
664        return Ok(());
665    };
666
667    let frontmatter = &rest[..end_idx];
668    let body = &rest[end_idx + 4..]; // Skip "\n---"
669
670    // Update model field in frontmatter using simple string replacement
671    let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
672
673    // Reconstruct file
674    let updated = format!("---{}\n---{}", updated_frontmatter, body);
675    ito_common::io::write_std(path, updated)
676        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
677
678    Ok(())
679}
680
681/// Update the model field in YAML frontmatter string
682fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
683    let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
684    let mut found = false;
685
686    for line in &mut lines {
687        if line.trim_start().starts_with("model:") {
688            *line = format!("model: \"{}\"", new_model);
689            found = true;
690            break;
691        }
692    }
693
694    // If no model field found, add it
695    if !found {
696        lines.push(format!("model: \"{}\"", new_model));
697    }
698
699    lines.join("\n")
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    #[test]
707    fn gitignore_created_when_missing() {
708        let td = tempfile::tempdir().unwrap();
709        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
710        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
711        assert_eq!(s, ".ito/session.json\n");
712    }
713
714    #[test]
715    fn gitignore_noop_when_already_present() {
716        let td = tempfile::tempdir().unwrap();
717        std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
718        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
719        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
720        assert_eq!(s, ".ito/session.json\n");
721    }
722
723    #[test]
724    fn gitignore_does_not_duplicate_on_repeated_calls() {
725        let td = tempfile::tempdir().unwrap();
726        std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
727        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
728        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
729        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
730        assert_eq!(s, "node_modules\n.ito/session.json\n");
731    }
732
733    #[test]
734    fn gitignore_audit_session_added() {
735        let td = tempfile::tempdir().unwrap();
736        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
737        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
738        assert!(s.contains(".ito/.state/audit/.session"));
739    }
740
741    #[test]
742    fn gitignore_both_session_entries() {
743        let td = tempfile::tempdir().unwrap();
744        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
745        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
746        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
747        assert!(s.contains(".ito/session.json"));
748        assert!(s.contains(".ito/.state/audit/.session"));
749    }
750
751    #[test]
752    fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
753        let td = tempfile::tempdir().unwrap();
754        std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
755        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
756        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
757        assert_eq!(s, "node_modules\n.ito/session.json\n");
758    }
759
760    #[test]
761    fn gitignore_audit_events_unignored() {
762        let td = tempfile::tempdir().unwrap();
763        ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
764        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
765        assert!(s.contains("!.ito/.state/audit/"));
766    }
767
768    #[test]
769    fn gitignore_full_audit_setup() {
770        let td = tempfile::tempdir().unwrap();
771        // Simulate a broad .state/ ignore
772        std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
773        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
774        ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
775        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
776        assert!(s.contains(".ito/.state/audit/.session"));
777        assert!(s.contains("!.ito/.state/audit/"));
778    }
779
780    #[test]
781    fn gitignore_ignores_local_configs() {
782        let td = tempfile::tempdir().unwrap();
783        ensure_repo_gitignore_ignores_local_configs(td.path(), ".ito").unwrap();
784        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
785        assert!(s.contains(".ito/config.local.json"));
786        assert!(s.contains(".local/ito/config.json"));
787    }
788
789    #[test]
790    fn gitignore_exact_line_matching_trims_whitespace() {
791        assert!(gitignore_has_exact_line("  foo  \nbar\n", "foo"));
792        assert!(!gitignore_has_exact_line("foo\n", "bar"));
793    }
794
795    #[test]
796    fn should_install_project_rel_filters_by_tool_id() {
797        let mut tools = BTreeSet::new();
798        tools.insert(TOOL_OPENCODE.to_string());
799
800        assert!(should_install_project_rel("AGENTS.md", &tools));
801        assert!(should_install_project_rel(".ito/config.json", &tools));
802        assert!(should_install_project_rel(".opencode/config.json", &tools));
803        assert!(!should_install_project_rel(".claude/settings.json", &tools));
804        assert!(!should_install_project_rel(".codex/config.json", &tools));
805        assert!(!should_install_project_rel(
806            ".github/workflows/x.yml",
807            &tools
808        ));
809    }
810
811    #[test]
812    fn release_tag_is_prefixed_with_v() {
813        let tag = release_tag();
814        assert!(tag.starts_with('v'));
815    }
816
817    #[test]
818    fn update_model_in_yaml_replaces_or_inserts() {
819        let yaml = "name: test\nmodel: \"old\"\n";
820        let updated = update_model_in_yaml(yaml, "new");
821        assert!(updated.contains("model: \"new\""));
822
823        let yaml = "name: test\n";
824        let updated = update_model_in_yaml(yaml, "new");
825        assert!(updated.contains("model: \"new\""));
826    }
827
828    #[test]
829    fn update_agent_model_field_updates_frontmatter_when_present() {
830        let td = tempfile::tempdir().unwrap();
831        let path = td.path().join("agent.md");
832        std::fs::write(&path, "---\nname: test\nmodel: \"old\"\n---\nbody\n").unwrap();
833        update_agent_model_field(&path, "new").unwrap();
834        let s = std::fs::read_to_string(&path).unwrap();
835        assert!(s.contains("model: \"new\""));
836
837        let path = td.path().join("no-frontmatter.md");
838        std::fs::write(&path, "no frontmatter\n").unwrap();
839        update_agent_model_field(&path, "newer").unwrap();
840        let s = std::fs::read_to_string(&path).unwrap();
841        assert_eq!(s, "no frontmatter\n");
842    }
843
844    #[test]
845    fn write_one_non_marker_files_skip_on_init_update_mode() {
846        let td = tempfile::tempdir().unwrap();
847        let target = td.path().join("plain.txt");
848        std::fs::write(&target, "existing").unwrap();
849
850        let opts = InitOptions::new(BTreeSet::new(), false, true);
851        write_one(
852            &target,
853            b"new",
854            InstallMode::Init,
855            &opts,
856            FileOwnership::UserOwned,
857        )
858        .unwrap();
859        let s = std::fs::read_to_string(&target).unwrap();
860        assert_eq!(s, "existing");
861    }
862
863    #[test]
864    fn write_one_non_marker_ito_managed_files_overwrite_on_init_update_mode() {
865        let td = tempfile::tempdir().unwrap();
866        let target = td.path().join("plain.txt");
867        std::fs::write(&target, "existing").unwrap();
868
869        let opts = InitOptions::new(BTreeSet::new(), false, true);
870        write_one(
871            &target,
872            b"new",
873            InstallMode::Init,
874            &opts,
875            FileOwnership::ItoManaged,
876        )
877        .unwrap();
878        let s = std::fs::read_to_string(&target).unwrap();
879        assert_eq!(s, "new");
880    }
881
882    #[test]
883    fn write_one_non_marker_user_owned_files_preserve_on_update_mode() {
884        let td = tempfile::tempdir().unwrap();
885        let target = td.path().join("plain.txt");
886        std::fs::write(&target, "existing").unwrap();
887
888        let opts = InitOptions::new(BTreeSet::new(), false, true);
889        write_one(
890            &target,
891            b"new",
892            InstallMode::Update,
893            &opts,
894            FileOwnership::UserOwned,
895        )
896        .unwrap();
897        let s = std::fs::read_to_string(&target).unwrap();
898        assert_eq!(s, "existing");
899    }
900
901    #[test]
902    fn write_one_marker_managed_files_refuse_overwrite_without_markers() {
903        let td = tempfile::tempdir().unwrap();
904        let target = td.path().join("managed.md");
905        std::fs::write(&target, "existing without markers\n").unwrap();
906
907        let template = format!(
908            "before\n{}\nmanaged\n{}\nafter\n",
909            ito_templates::ITO_START_MARKER,
910            ito_templates::ITO_END_MARKER
911        );
912        let opts = InitOptions::new(BTreeSet::new(), false, false);
913        let err = write_one(
914            &target,
915            template.as_bytes(),
916            InstallMode::Init,
917            &opts,
918            FileOwnership::ItoManaged,
919        )
920        .unwrap_err();
921        assert!(err.to_string().contains("Refusing to overwrite"));
922    }
923
924    #[test]
925    fn write_one_marker_managed_files_update_existing_markers() {
926        let td = tempfile::tempdir().unwrap();
927        let target = td.path().join("managed.md");
928        let existing = format!(
929            "before\n{}\nold\n{}\nafter\n",
930            ito_templates::ITO_START_MARKER,
931            ito_templates::ITO_END_MARKER
932        );
933        std::fs::write(&target, existing).unwrap();
934
935        let template = format!(
936            "before\n{}\nnew\n{}\nafter\n",
937            ito_templates::ITO_START_MARKER,
938            ito_templates::ITO_END_MARKER
939        );
940        let opts = InitOptions::new(BTreeSet::new(), false, false);
941        write_one(
942            &target,
943            template.as_bytes(),
944            InstallMode::Init,
945            &opts,
946            FileOwnership::ItoManaged,
947        )
948        .unwrap();
949        let s = std::fs::read_to_string(&target).unwrap();
950        assert!(s.contains("new"));
951        assert!(!s.contains("old"));
952    }
953
954    #[test]
955    fn write_one_marker_managed_files_error_when_markers_missing_in_update_mode() {
956        let td = tempfile::tempdir().unwrap();
957        let target = td.path().join("managed.md");
958        // One marker present, one missing -> update should error.
959        std::fs::write(
960            &target,
961            format!(
962                "{}\nexisting without end marker\n",
963                ito_templates::ITO_START_MARKER
964            ),
965        )
966        .unwrap();
967
968        let template = format!(
969            "before\n{}\nmanaged\n{}\nafter\n",
970            ito_templates::ITO_START_MARKER,
971            ito_templates::ITO_END_MARKER
972        );
973        let opts = InitOptions::new(BTreeSet::new(), false, true);
974        let err = write_one(
975            &target,
976            template.as_bytes(),
977            InstallMode::Init,
978            &opts,
979            FileOwnership::ItoManaged,
980        )
981        .unwrap_err();
982        assert!(err.to_string().contains("Failed to update markers"));
983    }
984
985    #[test]
986    fn merge_json_objects_keeps_existing_and_adds_template_keys() {
987        let mut existing = serde_json::json!({
988            "permissions": {
989                "allow": ["Bash(ls)"]
990            },
991            "hooks": {
992                "SessionStart": [
993                    {
994                        "matcher": "*"
995                    }
996                ]
997            }
998        });
999        let template = serde_json::json!({
1000            "hooks": {
1001                "PreToolUse": [
1002                    {
1003                        "matcher": "Bash|Edit|Write",
1004                        "hooks": [
1005                            {
1006                                "type": "command",
1007                                "command": "bash .claude/hooks/ito-audit.sh"
1008                            }
1009                        ]
1010                    }
1011                ]
1012            }
1013        });
1014
1015        merge_json_objects(&mut existing, &template);
1016
1017        assert_eq!(
1018            existing
1019                .pointer("/permissions/allow/0")
1020                .and_then(Value::as_str),
1021            Some("Bash(ls)")
1022        );
1023        assert!(existing.pointer("/hooks/SessionStart/0/matcher").is_some());
1024        assert!(
1025            existing
1026                .pointer("/hooks/PreToolUse/0/hooks/0/command")
1027                .is_some()
1028        );
1029    }
1030
1031    #[test]
1032    fn classify_project_file_ownership_handles_user_owned_paths() {
1033        let ito_dir = ".ito";
1034
1035        assert_eq!(
1036            classify_project_file_ownership(".ito/project.md", ito_dir),
1037            FileOwnership::UserOwned
1038        );
1039        assert_eq!(
1040            classify_project_file_ownership(".ito/config.json", ito_dir),
1041            FileOwnership::UserOwned
1042        );
1043        assert_eq!(
1044            classify_project_file_ownership(".ito/user-guidance.md", ito_dir),
1045            FileOwnership::UserOwned
1046        );
1047        assert_eq!(
1048            classify_project_file_ownership(".ito/user-prompts/tasks.md", ito_dir),
1049            FileOwnership::UserOwned
1050        );
1051        assert_eq!(
1052            classify_project_file_ownership(".ito/commands/review-edge.md", ito_dir),
1053            FileOwnership::ItoManaged
1054        );
1055    }
1056
1057    #[test]
1058    fn write_claude_settings_merges_existing_file_on_update() {
1059        let td = tempfile::tempdir().unwrap();
1060        let target = td.path().join(".claude/settings.json");
1061        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
1062        std::fs::write(
1063            &target,
1064            "{\n  \"permissions\": {\n    \"allow\": [\"Bash(ls)\"]\n  }\n}\n",
1065        )
1066        .unwrap();
1067
1068        let template = br#"{
1069  "hooks": {
1070    "PreToolUse": [
1071      {
1072        "matcher": "Bash|Edit|Write",
1073        "hooks": [
1074          {
1075            "type": "command",
1076            "command": "bash .claude/hooks/ito-audit.sh"
1077          }
1078        ]
1079      }
1080    ]
1081  }
1082}
1083"#;
1084
1085        let opts = InitOptions::new(BTreeSet::new(), false, true);
1086        write_claude_settings(&target, template, InstallMode::Update, &opts).unwrap();
1087
1088        let updated = std::fs::read_to_string(&target).unwrap();
1089        let value: Value = serde_json::from_str(&updated).unwrap();
1090        assert!(value.pointer("/permissions/allow").is_some());
1091        assert!(
1092            value
1093                .pointer("/hooks/PreToolUse/0/hooks/0/command")
1094                .is_some()
1095        );
1096    }
1097
1098    #[test]
1099    fn merge_json_objects_appends_and_deduplicates_array_entries() {
1100        let mut existing = serde_json::json!({
1101            "permissions": {
1102                "allow": ["Bash(ls)"]
1103            },
1104            "hooks": {
1105                "PreToolUse": [
1106                    {
1107                        "matcher": "Bash",
1108                        "hooks": [{"type": "command", "command": "echo existing"}]
1109                    }
1110                ]
1111            }
1112        });
1113        let template = serde_json::json!({
1114            "permissions": {
1115                "allow": ["Bash(ls)", "Bash(git status)"]
1116            },
1117            "hooks": {
1118                "PreToolUse": [
1119                    {
1120                        "matcher": "Bash",
1121                        "hooks": [{"type": "command", "command": "echo existing"}]
1122                    },
1123                    {
1124                        "matcher": "Edit|Write",
1125                        "hooks": [{"type": "command", "command": "echo template"}]
1126                    }
1127                ]
1128            }
1129        });
1130
1131        merge_json_objects(&mut existing, &template);
1132
1133        let permissions = existing
1134            .pointer("/permissions/allow")
1135            .and_then(Value::as_array)
1136            .expect("permissions allow should remain an array");
1137        assert_eq!(permissions.len(), 2);
1138        assert_eq!(permissions[0].as_str(), Some("Bash(ls)"));
1139        assert_eq!(permissions[1].as_str(), Some("Bash(git status)"));
1140
1141        let hooks = existing
1142            .pointer("/hooks/PreToolUse")
1143            .and_then(Value::as_array)
1144            .expect("PreToolUse should remain an array");
1145        assert_eq!(hooks.len(), 2);
1146        assert_eq!(
1147            hooks[0].pointer("/hooks/0/command").and_then(Value::as_str),
1148            Some("echo existing")
1149        );
1150        assert_eq!(
1151            hooks[1].pointer("/hooks/0/command").and_then(Value::as_str),
1152            Some("echo template")
1153        );
1154    }
1155
1156    #[test]
1157    fn write_claude_settings_preserves_invalid_json_on_update() {
1158        let td = tempfile::tempdir().unwrap();
1159        let target = td.path().join(".claude/settings.json");
1160        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
1161        std::fs::write(&target, "not-json\n").unwrap();
1162
1163        let template = br#"{
1164  "hooks": {
1165    "PreToolUse": [
1166      {
1167        "matcher": "Bash|Edit|Write",
1168        "hooks": [
1169          {
1170            "type": "command",
1171            "command": "bash .claude/hooks/ito-audit.sh"
1172          }
1173        ]
1174      }
1175    ]
1176  }
1177}
1178"#;
1179
1180        let opts = InitOptions::new(BTreeSet::new(), false, true);
1181        write_claude_settings(&target, template, InstallMode::Update, &opts).unwrap();
1182
1183        let updated = std::fs::read_to_string(&target).unwrap();
1184        assert_eq!(updated, "not-json\n");
1185    }
1186}