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        remove_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
178    }
179
180    // Local (per-developer) config overlays should never be committed.
181    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    // Strategy/worktree settings are often personal preferences; users can keep
193    // them in a local overlay file.
194    let entry = format!("{ito_dir}/config.local.json");
195    ensure_gitignore_contains_line(project_root, &entry)?;
196
197    // Optional convention: keep local configs under `.local/`.
198    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
211/// Ensure `.ito/.state/audit/.session` is gitignored (per-worktree UUID).
212fn 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
220/// Remove the legacy audit events unignore so worktree audit logs stay untracked.
221fn 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__", &current_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        // Render worktree-aware project templates (AGENTS.md) with worktree
330        // config. Only AGENTS.md uses Jinja2 for worktree rendering; other
331        // files (e.g., .ito/commands/) may contain `{{` as user-facing prompt
332        // placeholders that must NOT be processed by minijinja.
333        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    // Always install Ito project assets.
363    if rel == "AGENTS.md" {
364        return true;
365    }
366    if rel.starts_with(".ito/") {
367        return true;
368    }
369
370    // Tool-specific assets.
371    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    // Unknown/unclassified: only install when tools=all (caller controls via set contents).
388    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
415/// Writes a rendered template to `target`, handling Ito-managed marker blocks, overwrite/update semantics,
416/// and ownership rules.
417///
418/// When the rendered template contains Ito start/end markers, this function treats the file as
419/// marker-managed: it will update only the managed block when the target exists (honoring `--force`,
420/// `--update`, and `--upgrade` semantics), or write the template verbatim when the target does not
421/// exist. For non-marker files, behavior depends on `mode`, `opts.force`, `opts.update`, and `ownership`:
422/// - On Init: `--force` overwrites; `--update` skips user-owned files; otherwise the function refuses
423///   to overwrite existing files without markers.
424/// - On Update: skips user-owned files; otherwise writes/overwrites the target.
425///
426/// Errors are returned for IO failures and for invalid marker states when an update is attempted
427/// (except when `opts.upgrade` is true, in which case a missing marker in an expected marker-managed
428/// file produces a warning and the existing file is preserved).
429///
430/// # Examples
431///
432/// ```ignore
433/// use std::collections::BTreeSet;
434/// use std::path::Path;
435/// use ito_core::installers::{InitOptions, InstallMode};
436///
437/// let opts = InitOptions::new(BTreeSet::new(), false, false);
438/// let target = Path::new("/tmp/example.txt");
439/// let bytes = b"example content";
440/// // write_one is private; shown here for documentation purposes only.
441/// ```
442fn 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    // Marker-managed files: template contains markers; we extract the inner block.
455    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            // --force always overwrites the file wholesale on init.
460            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            // Read the existing file once and check for both Ito markers.
467            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                    // Upgrade fail-safe: when a file is expected to be marker-managed but no
475                    // longer contains valid Ito markers, preserve the file unchanged and emit
476                    // actionable guidance rather than returning an error.
477                    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                    // Plain init: refuse to overwrite without --force or --update.
495                    return Err(CoreError::Validation(format!(
496                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
497                        target.display()
498                    )));
499                }
500
501                // update mode (no upgrade): fall through to update_file_with_markers,
502                // which will produce a proper error on malformed marker state.
503            }
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            // New file: write the template bytes verbatim so output matches embedded assets.
523            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                    // --force always overwrites on init.
535                } 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        // Preserve user-owned files that are not valid JSON during update flows.
603        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
700/// Install Ito agent templates (ito-quick, ito-general, ito-thinking)
701fn 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    // Map tool names to harnesses
713    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        // Get agent template files for this harness
729        let files = get_agent_files(harness);
730
731        for (rel_path, contents) in files {
732            let target = agent_dir.join(rel_path);
733
734            // Parse the template and determine which tier it is
735            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            // Get config for this tier
746            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                            // --update: only update model field in existing agent files
753                            if let Some(cfg) = config {
754                                update_agent_model_field(&target, &cfg.model)?;
755                            }
756                            continue;
757                        }
758                        if !opts.force {
759                            // Default init: skip existing files
760                            continue;
761                        }
762                    }
763
764                    // Render full template
765                    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                    // Ensure parent directory exists
776                    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                    // During update: only update model in existing ito agent files
787                    if !target.exists() {
788                        // File doesn't exist, create it
789                        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                        // File exists, only update model field in frontmatter
809                        update_agent_model_field(&target, &cfg.model)?;
810                    }
811                }
812            }
813        }
814    }
815
816    Ok(())
817}
818
819/// Update only the model field in an existing agent file's frontmatter
820fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
821    let content = ito_common::io::read_to_string_or_default(path);
822
823    // Only update files with frontmatter
824    if !content.starts_with("---") {
825        return Ok(());
826    }
827
828    // Find frontmatter boundaries
829    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..]; // Skip "\n---"
836
837    // Update model field in frontmatter using simple string replacement
838    let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
839
840    // Reconstruct file
841    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
848/// Replaces or inserts a `model` field in a YAML frontmatter string.
849///
850/// If a `model:` line exists (ignoring leading whitespace) it is replaced with `model: "<new_model>"`.
851/// If no `model:` line is present, a `model: "<new_model>"` line is appended. Other lines are preserved and the resulting YAML is returned.
852///
853/// # Examples
854///
855/// ```ignore
856/// let src = "---\ntitle: Example\nmodel: \"old\"\n---\ncontent\n";
857/// let updated = update_model_in_yaml(src, "new-model");
858/// assert!(updated.contains("model: \"new-model\""));
859/// // When model is missing it is appended:
860/// let src2 = "---\ntitle: Example\n---\ncontent\n";
861/// let updated2 = update_model_in_yaml(src2, "new-model");
862/// assert!(updated2.contains("model: \"new-model\""));
863/// ```
864fn 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 no model field found, add it
877    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        // Simulate a broad .state/ ignore
971        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        // Pi-specific files install when Pi is selected
1021        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        // Common files always install
1028        assert!(should_install_project_rel("AGENTS.md", &tools));
1029        assert!(should_install_project_rel(".ito/config.json", &tools));
1030
1031        // Other harness files do not install
1032        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        // One marker present, one missing -> update should error.
1185        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}