Skip to main content

ito_core/installers/
mod.rs

1use std::collections::BTreeSet;
2use std::path::Path;
3
4use chrono::Utc;
5
6use crate::errors::{CoreError, CoreResult};
7
8use markers::update_file_with_markers;
9
10mod markers;
11
12use ito_config::ConfigContext;
13use ito_config::ito_dir::get_ito_dir_name;
14use ito_templates::project_templates::WorktreeTemplateContext;
15
16/// Tool id for Claude Code.
17pub const TOOL_CLAUDE: &str = "claude";
18/// Tool id for Codex.
19pub const TOOL_CODEX: &str = "codex";
20/// Tool id for GitHub Copilot.
21pub const TOOL_GITHUB_COPILOT: &str = "github-copilot";
22/// Tool id for OpenCode.
23pub const TOOL_OPENCODE: &str = "opencode";
24
25const CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER: &str = "__ITO_RELEASE_TAG__";
26
27/// Return the set of supported tool ids.
28pub fn available_tool_ids() -> &'static [&'static str] {
29    &[TOOL_CLAUDE, TOOL_CODEX, TOOL_GITHUB_COPILOT, TOOL_OPENCODE]
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33/// Options that control template installation behavior.
34pub struct InitOptions {
35    /// Selected tool ids.
36    pub tools: BTreeSet<String>,
37    /// Overwrite existing files when `true`.
38    pub force: bool,
39    /// When `true`, update managed files while preserving user-edited files.
40    ///
41    /// In this mode, non-marker files that already exist are silently skipped
42    /// instead of triggering an error. Marker-managed files still get their
43    /// managed blocks updated. Adapter files, skills, and commands are
44    /// overwritten as usual.
45    pub update: bool,
46}
47
48impl InitOptions {
49    /// Create new init options.
50    pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
51        Self {
52            tools,
53            force,
54            update,
55        }
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60/// Installation mode used by the installer.
61pub enum InstallMode {
62    /// Initial installation (`ito init`).
63    Init,
64    /// Update installation (`ito update`).
65    Update,
66}
67
68/// Install the default project templates and selected tool adapters.
69///
70/// When `worktree_ctx` is `Some`, templates containing Jinja2 syntax will be
71/// rendered with the given worktree configuration. When `None`, a disabled
72/// default context is used.
73pub fn install_default_templates(
74    project_root: &Path,
75    ctx: &ConfigContext,
76    mode: InstallMode,
77    opts: &InitOptions,
78    worktree_ctx: Option<&WorktreeTemplateContext>,
79) -> CoreResult<()> {
80    let ito_dir_name = get_ito_dir_name(project_root, ctx);
81    let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
82
83    install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
84
85    // Repository-local ignore rules for per-worktree state.
86    // This is not a templated file: we update `.gitignore` directly to preserve existing content.
87    if mode == InstallMode::Init {
88        ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
89        ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
90        // Un-ignore audit event log so it is git-tracked even if .state/ is broadly ignored.
91        ensure_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
92    }
93
94    // Local (per-developer) config overlays should never be committed.
95    ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
96
97    install_adapter_files(project_root, mode, opts, worktree_ctx)?;
98    install_agent_templates(project_root, mode, opts)?;
99    Ok(())
100}
101
102fn ensure_repo_gitignore_ignores_local_configs(
103    project_root: &Path,
104    ito_dir: &str,
105) -> CoreResult<()> {
106    // Strategy/worktree settings are often personal preferences; users can keep
107    // them in a local overlay file.
108    let entry = format!("{ito_dir}/config.local.json");
109    ensure_gitignore_contains_line(project_root, &entry)?;
110
111    // Optional convention: keep local configs under `.local/`.
112    let entry = ".local/ito/config.json";
113    ensure_gitignore_contains_line(project_root, entry)?;
114    Ok(())
115}
116
117fn ensure_repo_gitignore_ignores_session_json(
118    project_root: &Path,
119    ito_dir: &str,
120) -> CoreResult<()> {
121    let entry = format!("{ito_dir}/session.json");
122    ensure_gitignore_contains_line(project_root, &entry)
123}
124
125/// Ensure `.ito/.state/audit/.session` is gitignored (per-worktree UUID).
126fn ensure_repo_gitignore_ignores_audit_session(
127    project_root: &Path,
128    ito_dir: &str,
129) -> CoreResult<()> {
130    let entry = format!("{ito_dir}/.state/audit/.session");
131    ensure_gitignore_contains_line(project_root, &entry)
132}
133
134/// Un-ignore the audit events directory so `events.jsonl` is git-tracked.
135///
136/// If `.ito/.state/` is broadly gitignored (e.g., by a user rule or template),
137/// we add `!.ito/.state/audit/` to override the ignore and ensure the audit
138/// event log is committed alongside other project artifacts.
139fn ensure_repo_gitignore_unignores_audit_events(
140    project_root: &Path,
141    ito_dir: &str,
142) -> CoreResult<()> {
143    let entry = format!("!{ito_dir}/.state/audit/");
144    ensure_gitignore_contains_line(project_root, &entry)
145}
146
147fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
148    let path = project_root.join(".gitignore");
149    let existing = match ito_common::io::read_to_string_std(&path) {
150        Ok(s) => Some(s),
151        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
152        Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
153    };
154
155    let Some(mut s) = existing else {
156        ito_common::io::write_std(&path, format!("{entry}\n"))
157            .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
158        return Ok(());
159    };
160
161    if gitignore_has_exact_line(&s, entry) {
162        return Ok(());
163    }
164
165    if !s.ends_with('\n') {
166        s.push('\n');
167    }
168    s.push_str(entry);
169    s.push('\n');
170
171    ito_common::io::write_std(&path, s)
172        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
173    Ok(())
174}
175
176fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
177    contents.lines().map(|l| l.trim()).any(|l| l == entry)
178}
179
180fn install_project_templates(
181    project_root: &Path,
182    ito_dir: &str,
183    mode: InstallMode,
184    opts: &InitOptions,
185    worktree_ctx: Option<&WorktreeTemplateContext>,
186) -> CoreResult<()> {
187    use ito_templates::project_templates::render_project_template;
188
189    let selected = &opts.tools;
190    let current_date = Utc::now().format("%Y-%m-%d").to_string();
191    let state_rel = format!("{ito_dir}/planning/STATE.md");
192    let project_md_rel = format!("{ito_dir}/project.md");
193    let config_json_rel = format!("{ito_dir}/config.json");
194    let release_tag = release_tag();
195    let default_ctx = WorktreeTemplateContext::default();
196    let ctx = worktree_ctx.unwrap_or(&default_ctx);
197
198    for f in ito_templates::default_project_files() {
199        let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
200        let rel = rel.as_ref();
201
202        if !should_install_project_rel(rel, selected) {
203            continue;
204        }
205
206        let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
207        if let Ok(s) = std::str::from_utf8(&bytes) {
208            if rel == state_rel {
209                bytes = s.replace("__CURRENT_DATE__", &current_date).into_bytes();
210            } else if rel == config_json_rel {
211                bytes = s
212                    .replace(CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER, &release_tag)
213                    .into_bytes();
214            }
215        }
216
217        // Render worktree-aware project templates (AGENTS.md) with worktree
218        // config. Only AGENTS.md uses Jinja2 for worktree rendering; other
219        // files (e.g., .ito/commands/) may contain `{{` as user-facing prompt
220        // placeholders that must NOT be processed by minijinja.
221        if rel == "AGENTS.md" {
222            bytes = render_project_template(&bytes, ctx).map_err(|e| {
223                CoreError::Validation(format!("Failed to render template {rel}: {e}"))
224            })?;
225        }
226
227        // Preserve project-owned config/docs on `ito update`.
228        // These files are explicitly user-editable (e.g. `ito init` tells users
229        // to edit `.ito/project.md` and `.ito/config.json`), so `ito update`
230        // must not clobber them once they exist.
231        if mode == InstallMode::Update
232            && (rel == project_md_rel || rel == config_json_rel)
233            && project_root.join(rel).exists()
234        {
235            continue;
236        }
237
238        let target = project_root.join(rel);
239        write_one(&target, &bytes, mode, opts)?;
240    }
241
242    Ok(())
243}
244
245fn release_tag() -> String {
246    let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
247    if version.starts_with('v') {
248        return version.to_string();
249    }
250
251    format!("v{version}")
252}
253
254fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
255    // Always install Ito project assets.
256    if rel == "AGENTS.md" {
257        return true;
258    }
259    if rel.starts_with(".ito/") {
260        return true;
261    }
262
263    // Tool-specific assets.
264    if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
265        return tools.contains(TOOL_CLAUDE);
266    }
267    if rel.starts_with(".opencode/") {
268        return tools.contains(TOOL_OPENCODE);
269    }
270    if rel.starts_with(".github/") {
271        return tools.contains(TOOL_GITHUB_COPILOT);
272    }
273    if rel.starts_with(".codex/") {
274        return tools.contains(TOOL_CODEX);
275    }
276
277    // Unknown/unclassified: only install when tools=all (caller controls via set contents).
278    false
279}
280
281fn write_one(
282    target: &Path,
283    rendered_bytes: &[u8],
284    mode: InstallMode,
285    opts: &InitOptions,
286) -> CoreResult<()> {
287    if let Some(parent) = target.parent() {
288        ito_common::io::create_dir_all_std(parent)
289            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
290    }
291
292    // Marker-managed files: template contains markers; we extract the inner block.
293    if let Ok(text) = std::str::from_utf8(rendered_bytes)
294        && let Some(block) = ito_templates::extract_managed_block(text)
295    {
296        if target.exists() {
297            if mode == InstallMode::Init && opts.force {
298                ito_common::io::write_std(target, rendered_bytes)
299                    .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
300                return Ok(());
301            }
302
303            if mode == InstallMode::Init && !opts.force && !opts.update {
304                // If the file exists but doesn't contain Ito markers, mimic TS init behavior:
305                // refuse to overwrite without --force or --update.
306                let existing = ito_common::io::read_to_string_or_default(target);
307                let has_start = existing.contains(ito_templates::ITO_START_MARKER);
308                let has_end = existing.contains(ito_templates::ITO_END_MARKER);
309                if !(has_start && has_end) {
310                    return Err(CoreError::Validation(format!(
311                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
312                        target.display()
313                    )));
314                }
315            }
316
317            update_file_with_markers(
318                target,
319                block,
320                ito_templates::ITO_START_MARKER,
321                ito_templates::ITO_END_MARKER,
322            )
323            .map_err(|e| match e {
324                markers::FsEditError::Io(io_err) => {
325                    CoreError::io(format!("updating markers in {}", target.display()), io_err)
326                }
327                markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
328                    "Failed to update markers in {}: {}",
329                    target.display(),
330                    marker_err
331                )),
332            })?;
333        } else {
334            // New file: write the template bytes verbatim so output matches embedded assets.
335            ito_common::io::write_std(target, rendered_bytes)
336                .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
337        }
338
339        return Ok(());
340    }
341
342    // Non-marker-managed files: init refuses to overwrite unless --force.
343    // With --update, silently skip existing files to preserve user edits.
344    if mode == InstallMode::Init && target.exists() && !opts.force {
345        if opts.update {
346            return Ok(());
347        }
348        return Err(CoreError::Validation(format!(
349            "Refusing to overwrite existing file without markers: {} (re-run with --force)",
350            target.display()
351        )));
352    }
353
354    ito_common::io::write_std(target, rendered_bytes)
355        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
356    Ok(())
357}
358
359fn install_adapter_files(
360    project_root: &Path,
361    _mode: InstallMode,
362    opts: &InitOptions,
363    worktree_ctx: Option<&WorktreeTemplateContext>,
364) -> CoreResult<()> {
365    for tool in &opts.tools {
366        match tool.as_str() {
367            TOOL_OPENCODE => {
368                let config_dir = project_root.join(".opencode");
369                let manifests = crate::distribution::opencode_manifests(&config_dir);
370                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
371            }
372            TOOL_CLAUDE => {
373                let manifests = crate::distribution::claude_manifests(project_root);
374                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
375            }
376            TOOL_CODEX => {
377                let manifests = crate::distribution::codex_manifests(project_root);
378                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
379            }
380            TOOL_GITHUB_COPILOT => {
381                let manifests = crate::distribution::github_manifests(project_root);
382                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
383            }
384            _ => {}
385        }
386    }
387
388    Ok(())
389}
390
391/// Install Ito agent templates (ito-quick, ito-general, ito-thinking)
392fn install_agent_templates(
393    project_root: &Path,
394    mode: InstallMode,
395    opts: &InitOptions,
396) -> CoreResult<()> {
397    use ito_templates::agents::{
398        AgentTier, Harness, default_agent_configs, get_agent_files, render_agent_template,
399    };
400
401    let configs = default_agent_configs();
402
403    // Map tool names to harnesses
404    let tool_harness_map = [
405        (TOOL_OPENCODE, Harness::OpenCode),
406        (TOOL_CLAUDE, Harness::ClaudeCode),
407        (TOOL_CODEX, Harness::Codex),
408        (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
409    ];
410
411    for (tool_id, harness) in tool_harness_map {
412        if !opts.tools.contains(tool_id) {
413            continue;
414        }
415
416        let agent_dir = project_root.join(harness.project_agent_path());
417
418        // Get agent template files for this harness
419        let files = get_agent_files(harness);
420
421        for (rel_path, contents) in files {
422            let target = agent_dir.join(rel_path);
423
424            // Parse the template and determine which tier it is
425            let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
426                Some(AgentTier::Quick)
427            } else if rel_path.contains("ito-general") || rel_path.contains("general") {
428                Some(AgentTier::General)
429            } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
430                Some(AgentTier::Thinking)
431            } else {
432                None
433            };
434
435            // Get config for this tier
436            let config = tier.and_then(|t| configs.get(&(harness, t)));
437
438            match mode {
439                InstallMode::Init => {
440                    if target.exists() {
441                        if opts.update {
442                            // --update: only update model field in existing agent files
443                            if let Some(cfg) = config {
444                                update_agent_model_field(&target, &cfg.model)?;
445                            }
446                            continue;
447                        }
448                        if !opts.force {
449                            // Default init: skip existing files
450                            continue;
451                        }
452                    }
453
454                    // Render full template
455                    let rendered = if let Some(cfg) = config {
456                        if let Ok(template_str) = std::str::from_utf8(contents) {
457                            render_agent_template(template_str, cfg).into_bytes()
458                        } else {
459                            contents.to_vec()
460                        }
461                    } else {
462                        contents.to_vec()
463                    };
464
465                    // Ensure parent directory exists
466                    if let Some(parent) = target.parent() {
467                        ito_common::io::create_dir_all_std(parent).map_err(|e| {
468                            CoreError::io(format!("creating directory {}", parent.display()), e)
469                        })?;
470                    }
471
472                    ito_common::io::write_std(&target, rendered)
473                        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
474                }
475                InstallMode::Update => {
476                    // During update: only update model in existing ito agent files
477                    if !target.exists() {
478                        // File doesn't exist, create it
479                        let rendered = if let Some(cfg) = config {
480                            if let Ok(template_str) = std::str::from_utf8(contents) {
481                                render_agent_template(template_str, cfg).into_bytes()
482                            } else {
483                                contents.to_vec()
484                            }
485                        } else {
486                            contents.to_vec()
487                        };
488
489                        if let Some(parent) = target.parent() {
490                            ito_common::io::create_dir_all_std(parent).map_err(|e| {
491                                CoreError::io(format!("creating directory {}", parent.display()), e)
492                            })?;
493                        }
494                        ito_common::io::write_std(&target, rendered).map_err(|e| {
495                            CoreError::io(format!("writing {}", target.display()), e)
496                        })?;
497                    } else if let Some(cfg) = config {
498                        // File exists, only update model field in frontmatter
499                        update_agent_model_field(&target, &cfg.model)?;
500                    }
501                }
502            }
503        }
504    }
505
506    Ok(())
507}
508
509/// Update only the model field in an existing agent file's frontmatter
510fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
511    let content = ito_common::io::read_to_string_or_default(path);
512
513    // Only update files with frontmatter
514    if !content.starts_with("---") {
515        return Ok(());
516    }
517
518    // Find frontmatter boundaries
519    let rest = &content[3..];
520    let Some(end_idx) = rest.find("\n---") else {
521        return Ok(());
522    };
523
524    let frontmatter = &rest[..end_idx];
525    let body = &rest[end_idx + 4..]; // Skip "\n---"
526
527    // Update model field in frontmatter using simple string replacement
528    let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
529
530    // Reconstruct file
531    let updated = format!("---{}\n---{}", updated_frontmatter, body);
532    ito_common::io::write_std(path, updated)
533        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
534
535    Ok(())
536}
537
538/// Update the model field in YAML frontmatter string
539fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
540    let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
541    let mut found = false;
542
543    for line in &mut lines {
544        if line.trim_start().starts_with("model:") {
545            *line = format!("model: \"{}\"", new_model);
546            found = true;
547            break;
548        }
549    }
550
551    // If no model field found, add it
552    if !found {
553        lines.push(format!("model: \"{}\"", new_model));
554    }
555
556    lines.join("\n")
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn gitignore_created_when_missing() {
565        let td = tempfile::tempdir().unwrap();
566        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
567        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
568        assert_eq!(s, ".ito/session.json\n");
569    }
570
571    #[test]
572    fn gitignore_noop_when_already_present() {
573        let td = tempfile::tempdir().unwrap();
574        std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
575        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
576        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
577        assert_eq!(s, ".ito/session.json\n");
578    }
579
580    #[test]
581    fn gitignore_does_not_duplicate_on_repeated_calls() {
582        let td = tempfile::tempdir().unwrap();
583        std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
584        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
585        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
586        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
587        assert_eq!(s, "node_modules\n.ito/session.json\n");
588    }
589
590    #[test]
591    fn gitignore_audit_session_added() {
592        let td = tempfile::tempdir().unwrap();
593        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
594        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
595        assert!(s.contains(".ito/.state/audit/.session"));
596    }
597
598    #[test]
599    fn gitignore_both_session_entries() {
600        let td = tempfile::tempdir().unwrap();
601        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
602        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
603        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
604        assert!(s.contains(".ito/session.json"));
605        assert!(s.contains(".ito/.state/audit/.session"));
606    }
607
608    #[test]
609    fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
610        let td = tempfile::tempdir().unwrap();
611        std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
612        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
613        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
614        assert_eq!(s, "node_modules\n.ito/session.json\n");
615    }
616
617    #[test]
618    fn gitignore_audit_events_unignored() {
619        let td = tempfile::tempdir().unwrap();
620        ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
621        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
622        assert!(s.contains("!.ito/.state/audit/"));
623    }
624
625    #[test]
626    fn gitignore_full_audit_setup() {
627        let td = tempfile::tempdir().unwrap();
628        // Simulate a broad .state/ ignore
629        std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
630        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
631        ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
632        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
633        assert!(s.contains(".ito/.state/audit/.session"));
634        assert!(s.contains("!.ito/.state/audit/"));
635    }
636
637    #[test]
638    fn gitignore_ignores_local_configs() {
639        let td = tempfile::tempdir().unwrap();
640        ensure_repo_gitignore_ignores_local_configs(td.path(), ".ito").unwrap();
641        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
642        assert!(s.contains(".ito/config.local.json"));
643        assert!(s.contains(".local/ito/config.json"));
644    }
645
646    #[test]
647    fn gitignore_exact_line_matching_trims_whitespace() {
648        assert!(gitignore_has_exact_line("  foo  \nbar\n", "foo"));
649        assert!(!gitignore_has_exact_line("foo\n", "bar"));
650    }
651
652    #[test]
653    fn should_install_project_rel_filters_by_tool_id() {
654        let mut tools = BTreeSet::new();
655        tools.insert(TOOL_OPENCODE.to_string());
656
657        assert!(should_install_project_rel("AGENTS.md", &tools));
658        assert!(should_install_project_rel(".ito/config.json", &tools));
659        assert!(should_install_project_rel(".opencode/config.json", &tools));
660        assert!(!should_install_project_rel(".claude/settings.json", &tools));
661        assert!(!should_install_project_rel(".codex/config.json", &tools));
662        assert!(!should_install_project_rel(
663            ".github/workflows/x.yml",
664            &tools
665        ));
666    }
667
668    #[test]
669    fn release_tag_is_prefixed_with_v() {
670        let tag = release_tag();
671        assert!(tag.starts_with('v'));
672    }
673
674    #[test]
675    fn update_model_in_yaml_replaces_or_inserts() {
676        let yaml = "name: test\nmodel: \"old\"\n";
677        let updated = update_model_in_yaml(yaml, "new");
678        assert!(updated.contains("model: \"new\""));
679
680        let yaml = "name: test\n";
681        let updated = update_model_in_yaml(yaml, "new");
682        assert!(updated.contains("model: \"new\""));
683    }
684
685    #[test]
686    fn update_agent_model_field_updates_frontmatter_when_present() {
687        let td = tempfile::tempdir().unwrap();
688        let path = td.path().join("agent.md");
689        std::fs::write(&path, "---\nname: test\nmodel: \"old\"\n---\nbody\n").unwrap();
690        update_agent_model_field(&path, "new").unwrap();
691        let s = std::fs::read_to_string(&path).unwrap();
692        assert!(s.contains("model: \"new\""));
693
694        let path = td.path().join("no-frontmatter.md");
695        std::fs::write(&path, "no frontmatter\n").unwrap();
696        update_agent_model_field(&path, "newer").unwrap();
697        let s = std::fs::read_to_string(&path).unwrap();
698        assert_eq!(s, "no frontmatter\n");
699    }
700
701    #[test]
702    fn write_one_non_marker_files_skip_on_init_update_mode() {
703        let td = tempfile::tempdir().unwrap();
704        let target = td.path().join("plain.txt");
705        std::fs::write(&target, "existing").unwrap();
706
707        let opts = InitOptions::new(BTreeSet::new(), false, true);
708        write_one(&target, b"new", InstallMode::Init, &opts).unwrap();
709        let s = std::fs::read_to_string(&target).unwrap();
710        assert_eq!(s, "existing");
711    }
712
713    #[test]
714    fn write_one_marker_managed_files_refuse_overwrite_without_markers() {
715        let td = tempfile::tempdir().unwrap();
716        let target = td.path().join("managed.md");
717        std::fs::write(&target, "existing without markers\n").unwrap();
718
719        let template = format!(
720            "before\n{}\nmanaged\n{}\nafter\n",
721            ito_templates::ITO_START_MARKER,
722            ito_templates::ITO_END_MARKER
723        );
724        let opts = InitOptions::new(BTreeSet::new(), false, false);
725        let err = write_one(&target, template.as_bytes(), InstallMode::Init, &opts).unwrap_err();
726        assert!(err.to_string().contains("Refusing to overwrite"));
727    }
728
729    #[test]
730    fn write_one_marker_managed_files_update_existing_markers() {
731        let td = tempfile::tempdir().unwrap();
732        let target = td.path().join("managed.md");
733        let existing = format!(
734            "before\n{}\nold\n{}\nafter\n",
735            ito_templates::ITO_START_MARKER,
736            ito_templates::ITO_END_MARKER
737        );
738        std::fs::write(&target, existing).unwrap();
739
740        let template = format!(
741            "before\n{}\nnew\n{}\nafter\n",
742            ito_templates::ITO_START_MARKER,
743            ito_templates::ITO_END_MARKER
744        );
745        let opts = InitOptions::new(BTreeSet::new(), false, false);
746        write_one(&target, template.as_bytes(), InstallMode::Init, &opts).unwrap();
747        let s = std::fs::read_to_string(&target).unwrap();
748        assert!(s.contains("new"));
749        assert!(!s.contains("old"));
750    }
751
752    #[test]
753    fn write_one_marker_managed_files_error_when_markers_missing_in_update_mode() {
754        let td = tempfile::tempdir().unwrap();
755        let target = td.path().join("managed.md");
756        // One marker present, one missing -> update should error.
757        std::fs::write(
758            &target,
759            format!(
760                "{}\nexisting without end marker\n",
761                ito_templates::ITO_START_MARKER
762            ),
763        )
764        .unwrap();
765
766        let template = format!(
767            "before\n{}\nmanaged\n{}\nafter\n",
768            ito_templates::ITO_START_MARKER,
769            ito_templates::ITO_END_MARKER
770        );
771        let opts = InitOptions::new(BTreeSet::new(), false, true);
772        let err = write_one(&target, template.as_bytes(), InstallMode::Init, &opts).unwrap_err();
773        assert!(err.to_string().contains("Failed to update markers"));
774    }
775}