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