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    pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
75        Self {
76            tools,
77            force,
78            update,
79            upgrade: false,
80        }
81    }
82
83    /// Constructs `InitOptions` configured for upgrade mode.
84    ///
85    /// In upgrade mode the options enable update semantics and preserve user-owned
86    /// files. The `force` flag is disabled and `update` and `upgrade` are enabled,
87    /// so marker-managed files missing Ito markers are left unchanged with guidance
88    /// rather than causing an error.
89    ///
90    pub fn new_upgrade(tools: BTreeSet<String>) -> Self {
91        Self {
92            tools,
93            force: false,
94            update: true,
95            upgrade: true,
96        }
97    }
98
99    /// Enable upgrade mode and its update semantics on this `InitOptions`.
100    ///
101    /// When upgrade is enabled it implies `update = true` and `force = false`; this
102    /// method sets all three fields so that `force` cannot override the non-destructive
103    /// marker-scoped upgrade behavior.
104    ///
105    pub fn with_upgrade(mut self) -> Self {
106        self.upgrade = true;
107        self.update = true;
108        self.force = false;
109        self
110    }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114/// Installation mode used by the installer.
115pub enum InstallMode {
116    /// Initial installation (`ito init`).
117    Init,
118    /// Update installation (`ito update`).
119    Update,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123enum FileOwnership {
124    ItoManaged,
125    UserOwned,
126}
127
128/// Install the default project templates and selected tool adapters.
129///
130/// When `worktree_ctx` is `Some`, templates containing Jinja2 syntax will be
131/// rendered with the given worktree configuration. When `None`, a disabled
132/// default context is used.
133pub fn install_default_templates(
134    project_root: &Path,
135    ctx: &ConfigContext,
136    mode: InstallMode,
137    opts: &InitOptions,
138    worktree_ctx: Option<&WorktreeTemplateContext>,
139) -> CoreResult<()> {
140    let ito_dir_name = get_ito_dir_name(project_root, ctx);
141    let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
142
143    install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
144
145    // Repository-local ignore rules for per-worktree state.
146    // This is not a templated file: we update `.gitignore` directly to preserve existing content.
147    if mode == InstallMode::Init {
148        ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
149        ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
150        remove_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
151    }
152
153    // Local (per-developer) config overlays should never be committed.
154    ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
155
156    install_adapter_files(project_root, mode, opts, worktree_ctx)?;
157    install_agent_templates(project_root, mode, opts)?;
158    Ok(())
159}
160
161fn ensure_repo_gitignore_ignores_local_configs(
162    project_root: &Path,
163    ito_dir: &str,
164) -> CoreResult<()> {
165    // Strategy/worktree settings are often personal preferences; users can keep
166    // them in a local overlay file.
167    let entry = format!("{ito_dir}/config.local.json");
168    ensure_gitignore_contains_line(project_root, &entry)?;
169
170    // Optional convention: keep local configs under `.local/`.
171    let entry = ".local/ito/config.json";
172    ensure_gitignore_contains_line(project_root, entry)?;
173    Ok(())
174}
175
176fn ensure_repo_gitignore_ignores_session_json(
177    project_root: &Path,
178    ito_dir: &str,
179) -> CoreResult<()> {
180    let entry = format!("{ito_dir}/session.json");
181    ensure_gitignore_contains_line(project_root, &entry)
182}
183
184/// Ensure `.ito/.state/audit/.session` is gitignored (per-worktree UUID).
185fn ensure_repo_gitignore_ignores_audit_session(
186    project_root: &Path,
187    ito_dir: &str,
188) -> CoreResult<()> {
189    let entry = format!("{ito_dir}/.state/audit/.session");
190    ensure_gitignore_contains_line(project_root, &entry)
191}
192
193/// Remove the legacy audit events unignore so worktree audit logs stay untracked.
194fn remove_repo_gitignore_unignores_audit_events(
195    project_root: &Path,
196    ito_dir: &str,
197) -> CoreResult<()> {
198    let entry = format!("!{ito_dir}/.state/audit/");
199    remove_gitignore_exact_line(project_root, &entry)
200}
201
202fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
203    let path = project_root.join(".gitignore");
204    let existing = match ito_common::io::read_to_string_std(&path) {
205        Ok(s) => Some(s),
206        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
207        Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
208    };
209
210    let Some(mut s) = existing else {
211        ito_common::io::write_std(&path, format!("{entry}\n"))
212            .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
213        return Ok(());
214    };
215
216    if gitignore_has_exact_line(&s, entry) {
217        return Ok(());
218    }
219
220    if !s.ends_with('\n') {
221        s.push('\n');
222    }
223    s.push_str(entry);
224    s.push('\n');
225
226    ito_common::io::write_std(&path, s)
227        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
228    Ok(())
229}
230
231fn remove_gitignore_exact_line(project_root: &Path, entry: &str) -> CoreResult<()> {
232    let path = project_root.join(".gitignore");
233    let existing = match ito_common::io::read_to_string_std(&path) {
234        Ok(s) => s,
235        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
236        Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
237    };
238
239    let mut filtered = Vec::new();
240    let mut removed = false;
241    for line in existing.lines() {
242        if line.trim() == entry {
243            removed = true;
244            continue;
245        }
246        filtered.push(line);
247    }
248    if !removed {
249        return Ok(());
250    }
251
252    let mut updated = filtered.join("\n");
253    if !updated.is_empty() {
254        updated.push('\n');
255    }
256
257    ito_common::io::write_std(&path, updated)
258        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
259    Ok(())
260}
261
262fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
263    contents.lines().map(|l| l.trim()).any(|l| l == entry)
264}
265
266fn install_project_templates(
267    project_root: &Path,
268    ito_dir: &str,
269    mode: InstallMode,
270    opts: &InitOptions,
271    worktree_ctx: Option<&WorktreeTemplateContext>,
272) -> CoreResult<()> {
273    use ito_templates::project_templates::render_project_template;
274
275    let selected = &opts.tools;
276    let current_date = Utc::now().format("%Y-%m-%d").to_string();
277    let state_rel = format!("{ito_dir}/planning/STATE.md");
278    let config_json_rel = format!("{ito_dir}/config.json");
279    let release_tag = release_tag();
280    let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
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        // Stamp every managed-block markdown file with the current CLI version.
314        if rel.ends_with(".md")
315            && !rel.ends_with(".md.j2")
316            && let Ok(text) = std::str::from_utf8(&bytes)
317            && text.contains(ito_templates::ITO_START_MARKER)
318        {
319            bytes = ito_templates::stamp_version(text, semver).into_bytes();
320        }
321
322        let ownership = classify_project_file_ownership(rel, ito_dir);
323
324        let target = project_root.join(rel);
325        if rel == ".claude/settings.json" {
326            write_claude_settings(&target, &bytes, mode, opts)?;
327            continue;
328        }
329        write_one(&target, &bytes, mode, opts, ownership)?;
330    }
331
332    Ok(())
333}
334
335fn release_tag() -> String {
336    let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
337    if version.starts_with('v') {
338        return version.to_string();
339    }
340
341    format!("v{version}")
342}
343
344fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
345    // Always install Ito project assets.
346    if rel == "AGENTS.md" {
347        return true;
348    }
349    if rel.starts_with(".ito/") {
350        return true;
351    }
352
353    // Tool-specific assets.
354    if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
355        return tools.contains(TOOL_CLAUDE);
356    }
357    if rel.starts_with(".opencode/") {
358        return tools.contains(TOOL_OPENCODE);
359    }
360    if rel.starts_with(".github/") {
361        return tools.contains(TOOL_GITHUB_COPILOT);
362    }
363    if rel.starts_with(".codex/") {
364        return tools.contains(TOOL_CODEX);
365    }
366    if rel.starts_with(".pi/") {
367        return tools.contains(TOOL_PI);
368    }
369
370    // Unknown/unclassified: only install when tools=all (caller controls via set contents).
371    false
372}
373
374fn classify_project_file_ownership(rel: &str, ito_dir: &str) -> FileOwnership {
375    let project_md_rel = format!("{ito_dir}/project.md");
376    if rel == project_md_rel {
377        return FileOwnership::UserOwned;
378    }
379
380    let config_json_rel = format!("{ito_dir}/config.json");
381    if rel == config_json_rel {
382        return FileOwnership::UserOwned;
383    }
384
385    let user_guidance_rel = format!("{ito_dir}/user-guidance.md");
386    if rel == user_guidance_rel {
387        return FileOwnership::UserOwned;
388    }
389
390    let user_prompts_prefix = format!("{ito_dir}/user-prompts/");
391    if rel.starts_with(&user_prompts_prefix) {
392        return FileOwnership::UserOwned;
393    }
394
395    FileOwnership::ItoManaged
396}
397
398/// Returns `true` when the rendered template's managed block spans the entire
399/// file — i.e. nothing meaningful sits outside the `<!-- ITO:START -->` /
400/// `<!-- ITO:END -->` markers. Such files have no user-editable region, so on
401/// update they can be rewritten wholesale to drop stale content from previous
402/// versions.
403///
404/// The rendered template bytes come from embedded assets that always place
405/// markers on their own lines, so a raw `find` is sufficient here.
406fn template_is_entirely_managed(text: &str) -> bool {
407    let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
408        return false;
409    };
410    let Some(end) = text.find(ito_templates::ITO_END_MARKER) else {
411        return false;
412    };
413    let before = text[..start].trim();
414    let after = text[end + ito_templates::ITO_END_MARKER.len()..].trim();
415    before.is_empty() && after.is_empty()
416}
417
418/// Returns `true` when the rendered template has non-trivial content sitting
419/// **before** its managed block (typically YAML frontmatter). Used by the
420/// first-marker migration path: when an existing on-disk file has no markers
421/// and the template has frontmatter + markers, the marker-prepend strategy
422/// would re-order the existing frontmatter and corrupt the file. In that case
423/// the safer migration is to write the rendered template wholesale.
424fn template_has_prefix_outside_markers(text: &str) -> bool {
425    let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
426        return false;
427    };
428    !text[..start].trim().is_empty()
429}
430
431/// Render an agent template and stamp the managed block (if any) with the
432/// current CLI version. Used by both `Init` and `Update` paths so newly
433/// written agent files always carry an `ITO:VERSION` line consistent with the
434/// installer.
435fn render_and_stamp_agent(
436    contents: &[u8],
437    config: Option<&ito_templates::agents::AgentConfig>,
438    target: &Path,
439) -> Vec<u8> {
440    let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
441    let rendered = match (std::str::from_utf8(contents), config) {
442        (Ok(template_str), Some(cfg)) => {
443            ito_templates::agents::render_agent_template(template_str, cfg).into_bytes()
444        }
445        _ => contents.to_vec(),
446    };
447    let path_str = target.to_string_lossy();
448    if !path_str.ends_with(".md") || path_str.ends_with(".md.j2") {
449        return rendered;
450    }
451    let Ok(text) = std::str::from_utf8(&rendered) else {
452        return rendered;
453    };
454    if !text.contains(ito_templates::ITO_START_MARKER) {
455        return rendered;
456    }
457    ito_templates::stamp_version(text, semver).into_bytes()
458}
459
460/// Refresh the `<!--ITO:VERSION:...-->` stamp inside an on-disk agent file.
461///
462/// Used after `update_agent_model_field` patches frontmatter so the file's
463/// stamp matches the CLI version even when only the model field changed. A
464/// no-op for files without managed markers.
465fn refresh_agent_version_stamp(target: &Path) -> CoreResult<()> {
466    let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
467    let existing = ito_common::io::read_to_string_std(target)
468        .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
469    if !existing.contains(ito_templates::ITO_START_MARKER) {
470        return Ok(());
471    }
472    let stamped = ito_templates::stamp_version(&existing, semver);
473    if stamped == existing {
474        return Ok(());
475    }
476    ito_common::io::write_std(target, stamped.into_bytes())
477        .map_err(|e| CoreError::io(format!("stamping {}", target.display()), e))
478}
479
480/// Write a rendered managed-block markdown file with marker-scoped update
481/// semantics, suitable for installer paths that do not need the full
482/// `write_one` ownership/upgrade machinery (e.g. the harness manifest
483/// installer in `distribution.rs`).
484///
485/// Behaviour:
486///
487/// - **No target on disk** → write `rendered_bytes` verbatim.
488/// - **`mode == Init` && `opts.force`** → wholesale overwrite (matches the
489///   `--force` semantics in `write_one`).
490/// - **Template has no managed block** → wholesale overwrite (caller wanted
491///   plain replacement).
492/// - **Existing target has no markers** → wholesale overwrite. Treats the
493///   file as legacy from before managed markers were retrofitted; no user
494///   content is at risk because there was no marker boundary to honour.
495/// - **Existing target has markers** → marker-scoped update via
496///   `update_file_with_markers`, preserving everything outside the managed
497///   block byte-for-byte.
498///
499/// Returns `Ok(())` on success. Errors mirror `write_one` (IO + marker
500/// validation diagnostics).
501pub(crate) fn write_marker_aware_markdown(
502    target: &Path,
503    rendered_bytes: &[u8],
504    mode: InstallMode,
505    opts: &InitOptions,
506) -> CoreResult<()> {
507    if let Some(parent) = target.parent() {
508        ito_common::io::create_dir_all_std(parent)
509            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
510    }
511
512    let wholesale = |target: &Path| -> CoreResult<()> {
513        ito_common::io::write_std(target, rendered_bytes)
514            .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))
515    };
516
517    if !target.exists() {
518        return wholesale(target);
519    }
520
521    if mode == InstallMode::Init && opts.force {
522        return wholesale(target);
523    }
524
525    let Ok(text) = std::str::from_utf8(rendered_bytes) else {
526        return wholesale(target);
527    };
528    let Some(block) = ito_templates::extract_managed_block(text) else {
529        return wholesale(target);
530    };
531
532    let existing = ito_common::io::read_to_string_std(target)
533        .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
534    let has_start = existing.contains(ito_templates::ITO_START_MARKER);
535    let has_end = existing.contains(ito_templates::ITO_END_MARKER);
536    match (has_start, has_end) {
537        (false, false) => return wholesale(target),
538        (true, true) => {}
539        (true, false) | (false, true) => {
540            // Partial marker pair indicates the user (or some other tool)
541            // damaged the managed region. Refusing to write here mirrors
542            // `update_file_with_markers`' error path and prevents silently
543            // clobbering user content. The user must restore the markers
544            // (or pass `--force`) before update can proceed.
545            return Err(CoreError::Validation(format!(
546                "Refusing to update {}: file has a partial Ito marker pair (start={has_start}, end={has_end}). \
547Restore both markers manually, or rerun with `--force` to overwrite the file wholesale.",
548                target.display()
549            )));
550        }
551    }
552
553    let _ = update_file_with_markers(
554        target,
555        block,
556        ito_templates::ITO_START_MARKER,
557        ito_templates::ITO_END_MARKER,
558    )
559    .map_err(|e| match e {
560        markers::FsEditError::Io(io_err) => {
561            CoreError::io(format!("updating markers in {}", target.display()), io_err)
562        }
563        markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
564            "Failed to update markers in {}: {}",
565            target.display(),
566            marker_err
567        )),
568    })?;
569    Ok(())
570}
571
572/// Writes a rendered template to `target`, handling Ito-managed marker blocks, overwrite/update semantics,
573/// and ownership rules.
574///
575/// When the rendered template contains Ito start/end markers, this function treats the file as
576/// marker-managed: it will update only the managed block when the target exists (honoring `--force`,
577/// `--update`, and `--upgrade` semantics), or write the template verbatim when the target does not
578/// exist. For non-marker files, behavior depends on `mode`, `opts.force`, `opts.update`, and `ownership`:
579/// - On Init: `--force` overwrites; `--update` skips user-owned files; otherwise the function refuses
580///   to overwrite existing files without markers.
581/// - On Update: skips user-owned files; otherwise writes/overwrites the target.
582///
583/// Errors are returned for IO failures and for invalid marker states when an update is attempted
584/// (except when `opts.upgrade` is true, in which case a missing marker in an expected marker-managed
585/// file produces a warning and the existing file is preserved).
586fn write_one(
587    target: &Path,
588    rendered_bytes: &[u8],
589    mode: InstallMode,
590    opts: &InitOptions,
591    ownership: FileOwnership,
592) -> CoreResult<()> {
593    if let Some(parent) = target.parent() {
594        ito_common::io::create_dir_all_std(parent)
595            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
596    }
597
598    // Marker-managed files: template contains markers; we extract the inner block.
599    if let Ok(text) = std::str::from_utf8(rendered_bytes)
600        && let Some(block) = ito_templates::extract_managed_block(text)
601    {
602        if target.exists() {
603            // --force always overwrites the file wholesale on init.
604            if mode == InstallMode::Init && opts.force {
605                ito_common::io::write_std(target, rendered_bytes)
606                    .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
607                return Ok(());
608            }
609
610            // User-owned files keep their content untouched on update / non-forced
611            // init even when the template now ships managed markers. Updating the
612            // managed block here would clobber user edits to files Ito only seeds
613            // (e.g. .ito/project.md, .ito/user-prompts/*.md).
614            if ownership == FileOwnership::UserOwned {
615                let updating = mode == InstallMode::Update
616                    || (mode == InstallMode::Init && (opts.update || opts.upgrade));
617                if updating {
618                    return Ok(());
619                }
620            }
621
622            // Read the existing file once and check for both Ito markers.
623            let existing = ito_common::io::read_to_string_std(target)
624                .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
625            let has_markers = existing.contains(ito_templates::ITO_START_MARKER)
626                && existing.contains(ito_templates::ITO_END_MARKER);
627
628            if !has_markers {
629                if opts.upgrade {
630                    // Upgrade fail-safe: when a file is expected to be marker-managed but no
631                    // longer contains valid Ito markers, preserve the file unchanged and emit
632                    // actionable guidance rather than returning an error.
633                    eprintln!(
634                        "warning: skipping upgrade of {} — Ito markers not found.\n\
635                        To restore managed upgrade support, re-add the markers manually:\n\
636                        \n\
637                        {start}\n\
638                        <ito-managed content>\n\
639                        {end}\n\
640                        \n\
641                        Then re-run `ito init --upgrade`.",
642                        target.display(),
643                        start = ito_templates::ITO_START_MARKER,
644                        end = ito_templates::ITO_END_MARKER,
645                    );
646                    return Ok(());
647                }
648
649                if mode == InstallMode::Init && !opts.update {
650                    // Plain init: refuse to overwrite without --force or --update.
651                    return Err(CoreError::Validation(format!(
652                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
653                        target.display()
654                    )));
655                }
656
657                // update / `init --update` against an Ito-managed file that
658                // predates marker rollout. Only safe to wholesale-rewrite when
659                // the existing file is genuinely marker-free (a partial
660                // marker pair indicates the user has manually edited the
661                // managed region and we should error rather than overwrite).
662                let existing_has_no_markers = !existing.contains(ito_templates::ITO_START_MARKER)
663                    && !existing.contains(ito_templates::ITO_END_MARKER);
664                if existing_has_no_markers {
665                    // Decide between wholesale rewrite and marker-prepend
666                    // based on the template's shape:
667                    //
668                    // - If the template's managed block spans the entire
669                    //   file, there is no user-editable region; rewrite
670                    //   wholesale to drop stale content from the previous
671                    //   version.
672                    //
673                    // - If the template has a non-empty prefix (typically
674                    //   YAML frontmatter) above the managed block,
675                    //   marker-prepend would re-order the existing
676                    //   frontmatter and corrupt the file. Rewrite wholesale
677                    //   instead.
678                    //
679                    // - Otherwise (template has only markers, no surrounding
680                    //   content), fall through to `update_file_with_markers`
681                    //   which prepends the managed block to the existing
682                    //   file while preserving user content.
683                    if template_is_entirely_managed(text)
684                        || template_has_prefix_outside_markers(text)
685                    {
686                        ito_common::io::write_std(target, rendered_bytes).map_err(|e| {
687                            CoreError::io(format!("writing {}", target.display()), e)
688                        })?;
689                        return Ok(());
690                    }
691                }
692            }
693
694            update_file_with_markers(
695                target,
696                block,
697                ito_templates::ITO_START_MARKER,
698                ito_templates::ITO_END_MARKER,
699            )
700            .map_err(|e| match e {
701                markers::FsEditError::Io(io_err) => {
702                    CoreError::io(format!("updating markers in {}", target.display()), io_err)
703                }
704                markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
705                    "Failed to update markers in {}: {}",
706                    target.display(),
707                    marker_err
708                )),
709            })?;
710        } else {
711            // New file: write the template bytes verbatim so output matches embedded assets.
712            ito_common::io::write_std(target, rendered_bytes)
713                .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
714        }
715
716        return Ok(());
717    }
718
719    if target.exists() {
720        match mode {
721            InstallMode::Init => {
722                if opts.force {
723                    // --force always overwrites on init.
724                } else if opts.update {
725                    if ownership == FileOwnership::UserOwned {
726                        return Ok(());
727                    }
728                } else {
729                    return Err(CoreError::Validation(format!(
730                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
731                        target.display()
732                    )));
733                }
734            }
735            InstallMode::Update => {
736                if ownership == FileOwnership::UserOwned {
737                    return Ok(());
738                }
739            }
740        }
741    }
742
743    ito_common::io::write_std(target, rendered_bytes)
744        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
745    Ok(())
746}
747
748fn write_claude_settings(
749    target: &Path,
750    rendered_bytes: &[u8],
751    mode: InstallMode,
752    opts: &InitOptions,
753) -> CoreResult<()> {
754    if let Some(parent) = target.parent() {
755        ito_common::io::create_dir_all_std(parent)
756            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
757    }
758
759    if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
760        return Err(CoreError::Validation(format!(
761            "Refusing to overwrite existing file without markers: {} (re-run with --force)",
762            target.display()
763        )));
764    }
765
766    let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
767        CoreError::Validation(format!(
768            "Failed to parse Claude settings template {}: {}",
769            target.display(),
770            e
771        ))
772    })?;
773
774    if !target.exists() || (mode == InstallMode::Init && opts.force) {
775        let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
776            CoreError::Validation(format!(
777                "Failed to render Claude settings template {}: {}",
778                target.display(),
779                e
780            ))
781        })?;
782        bytes.push(b'\n');
783        ito_common::io::write_std(target, bytes)
784            .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
785        return Ok(());
786    }
787
788    let existing_raw = ito_common::io::read_to_string_std(target)
789        .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
790    let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
791        // Preserve user-owned files that are not valid JSON during update flows.
792        return Ok(());
793    };
794
795    merge_json_objects(&mut existing_value, &template_value);
796    let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
797        CoreError::Validation(format!(
798            "Failed to render merged Claude settings {}: {}",
799            target.display(),
800            e
801        ))
802    })?;
803    merged.push(b'\n');
804    ito_common::io::write_std(target, merged)
805        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
806    Ok(())
807}
808
809fn merge_json_objects(existing: &mut Value, template: &Value) {
810    let Value::Object(template_map) = template else {
811        *existing = template.clone();
812        return;
813    };
814    if !existing.is_object() {
815        *existing = Value::Object(Map::new());
816    }
817
818    let Some(existing_map) = existing.as_object_mut() else {
819        return;
820    };
821
822    for (key, template_value) in template_map {
823        if let Some(existing_value) = existing_map.get_mut(key) {
824            merge_json_values(existing_value, template_value);
825        } else {
826            existing_map.insert(key.clone(), template_value.clone());
827        }
828    }
829}
830
831fn merge_json_values(existing: &mut Value, template: &Value) {
832    match (existing, template) {
833        (Value::Object(existing_map), Value::Object(template_map)) => {
834            for (key, template_value) in template_map {
835                if let Some(existing_value) = existing_map.get_mut(key) {
836                    merge_json_values(existing_value, template_value);
837                } else {
838                    existing_map.insert(key.clone(), template_value.clone());
839                }
840            }
841        }
842        (Value::Array(existing_items), Value::Array(template_items)) => {
843            for template_item in template_items {
844                if !existing_items.contains(template_item) {
845                    existing_items.push(template_item.clone());
846                }
847            }
848        }
849        (existing_value, template_value) => *existing_value = template_value.clone(),
850    }
851}
852
853fn install_adapter_files(
854    project_root: &Path,
855    mode: InstallMode,
856    opts: &InitOptions,
857    worktree_ctx: Option<&WorktreeTemplateContext>,
858) -> CoreResult<()> {
859    for tool in &opts.tools {
860        match tool.as_str() {
861            TOOL_OPENCODE => {
862                let config_dir = project_root.join(".opencode");
863                let manifests = crate::distribution::opencode_manifests(&config_dir);
864                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
865            }
866            TOOL_CLAUDE => {
867                let manifests = crate::distribution::claude_manifests(project_root);
868                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
869            }
870            TOOL_CODEX => {
871                let manifests = crate::distribution::codex_manifests(project_root);
872                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
873            }
874            TOOL_GITHUB_COPILOT => {
875                let manifests = crate::distribution::github_manifests(project_root);
876                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
877            }
878            TOOL_PI => {
879                let manifests = crate::distribution::pi_manifests(project_root);
880                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
881            }
882            _ => {}
883        }
884    }
885
886    Ok(())
887}
888
889/// Install Ito agent templates (ito-quick, ito-general, ito-thinking)
890fn install_agent_templates(
891    project_root: &Path,
892    mode: InstallMode,
893    opts: &InitOptions,
894) -> CoreResult<()> {
895    use ito_templates::agents::{AgentTier, Harness, default_agent_configs, get_agent_files};
896
897    let configs = default_agent_configs();
898
899    // Map tool names to harnesses
900    let tool_harness_map = [
901        (TOOL_OPENCODE, Harness::OpenCode),
902        (TOOL_CLAUDE, Harness::ClaudeCode),
903        (TOOL_CODEX, Harness::Codex),
904        (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
905        (TOOL_PI, Harness::Pi),
906    ];
907
908    for (tool_id, harness) in tool_harness_map {
909        if !opts.tools.contains(tool_id) {
910            continue;
911        }
912
913        let agent_dir = project_root.join(harness.project_agent_path());
914
915        // Get agent template files for this harness
916        let files = get_agent_files(harness);
917
918        for (rel_path, contents) in files {
919            let target = agent_dir.join(rel_path);
920
921            // Parse the template and determine which tier it is
922            let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
923                Some(AgentTier::Quick)
924            } else if rel_path.contains("ito-general") || rel_path.contains("general") {
925                Some(AgentTier::General)
926            } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
927                Some(AgentTier::Thinking)
928            } else {
929                None
930            };
931
932            // Get config for this tier.
933            //
934            // Some harness agent templates are not tiered but still include
935            // `{{model}}` placeholders (e.g., coordinator agents). In that case,
936            // render using the harness's General tier defaults.
937            let mut config = tier.and_then(|t| configs.get(&(harness, t)));
938            if config.is_none()
939                && let Ok(s) = std::str::from_utf8(contents)
940                && s.contains("{{model}}")
941            {
942                config = configs.get(&(harness, AgentTier::General));
943            }
944
945            match mode {
946                InstallMode::Init => {
947                    if target.exists() {
948                        if opts.update {
949                            // --update: only update model field in existing agent files
950                            if let Some(cfg) = config {
951                                update_agent_model_field(&target, &cfg.model)?;
952                            }
953                            continue;
954                        }
955                        if !opts.force {
956                            // Default init: skip existing files
957                            continue;
958                        }
959                    }
960
961                    // Render full template (and stamp managed-block markdown)
962                    let rendered = render_and_stamp_agent(contents, config, &target);
963
964                    // Ensure parent directory exists
965                    if let Some(parent) = target.parent() {
966                        ito_common::io::create_dir_all_std(parent).map_err(|e| {
967                            CoreError::io(format!("creating directory {}", parent.display()), e)
968                        })?;
969                    }
970
971                    ito_common::io::write_std(&target, rendered)
972                        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
973                }
974                InstallMode::Update => {
975                    // During update: only update model in existing ito agent files
976                    if !target.exists() {
977                        // File doesn't exist, create it
978                        let rendered = render_and_stamp_agent(contents, config, &target);
979
980                        if let Some(parent) = target.parent() {
981                            ito_common::io::create_dir_all_std(parent).map_err(|e| {
982                                CoreError::io(format!("creating directory {}", parent.display()), e)
983                            })?;
984                        }
985                        ito_common::io::write_std(&target, rendered).map_err(|e| {
986                            CoreError::io(format!("writing {}", target.display()), e)
987                        })?;
988                    } else if let Some(cfg) = config {
989                        // File exists, only update model field in frontmatter
990                        update_agent_model_field(&target, &cfg.model)?;
991                        // Refresh the managed-block stamp so the file's
992                        // ITO:VERSION reflects the current CLI even when
993                        // only the model field was patched.
994                        refresh_agent_version_stamp(&target)?;
995                    }
996                }
997            }
998        }
999    }
1000
1001    Ok(())
1002}
1003
1004/// Update only the model field in an existing agent file's frontmatter
1005fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
1006    let content = ito_common::io::read_to_string_or_default(path);
1007
1008    // Only update files with frontmatter
1009    if !content.starts_with("---") {
1010        return Ok(());
1011    }
1012
1013    // Find frontmatter boundaries
1014    let rest = &content[3..];
1015    let Some(end_idx) = rest.find("\n---") else {
1016        return Ok(());
1017    };
1018
1019    let frontmatter = &rest[..end_idx];
1020    let body = &rest[end_idx + 4..]; // Skip "\n---"
1021
1022    // Update model field in frontmatter using simple string replacement
1023    let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
1024
1025    // Reconstruct file
1026    let updated = format!("---{}\n---{}", updated_frontmatter, body);
1027    ito_common::io::write_std(path, updated)
1028        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
1029
1030    Ok(())
1031}
1032
1033/// Replace or insert the `model` field in YAML frontmatter.
1034fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
1035    let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
1036    let mut found = false;
1037
1038    for line in &mut lines {
1039        if line.trim_start().starts_with("model:") {
1040            *line = format!("model: \"{}\"", new_model);
1041            found = true;
1042            break;
1043        }
1044    }
1045
1046    // If no model field found, add it
1047    if !found {
1048        lines.push(format!("model: \"{}\"", new_model));
1049    }
1050
1051    lines.join("\n")
1052}
1053
1054#[cfg(test)]
1055mod json_tests;
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060
1061    #[test]
1062    fn gitignore_created_when_missing() {
1063        let td = tempfile::tempdir().unwrap();
1064        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1065        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1066        assert_eq!(s, ".ito/session.json\n");
1067    }
1068
1069    #[test]
1070    fn gitignore_noop_when_already_present() {
1071        let td = tempfile::tempdir().unwrap();
1072        std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
1073        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1074        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1075        assert_eq!(s, ".ito/session.json\n");
1076    }
1077
1078    #[test]
1079    fn gitignore_does_not_duplicate_on_repeated_calls() {
1080        let td = tempfile::tempdir().unwrap();
1081        std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
1082        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1083        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1084        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1085        assert_eq!(s, "node_modules\n.ito/session.json\n");
1086    }
1087
1088    #[test]
1089    fn gitignore_audit_session_added() {
1090        let td = tempfile::tempdir().unwrap();
1091        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1092        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1093        assert!(s.contains(".ito/.state/audit/.session"));
1094    }
1095
1096    #[test]
1097    fn gitignore_both_session_entries() {
1098        let td = tempfile::tempdir().unwrap();
1099        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1100        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1101        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1102        assert!(s.contains(".ito/session.json"));
1103        assert!(s.contains(".ito/.state/audit/.session"));
1104    }
1105
1106    #[test]
1107    fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
1108        let td = tempfile::tempdir().unwrap();
1109        std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
1110        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1111        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1112        assert_eq!(s, "node_modules\n.ito/session.json\n");
1113    }
1114
1115    #[test]
1116    fn gitignore_legacy_audit_events_unignore_removed() {
1117        let td = tempfile::tempdir().unwrap();
1118        std::fs::write(
1119            td.path().join(".gitignore"),
1120            ".ito/.state/\n!.ito/.state/audit/\n",
1121        )
1122        .unwrap();
1123        remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1124        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1125        assert_eq!(s, ".ito/.state/\n");
1126    }
1127
1128    #[test]
1129    fn gitignore_legacy_audit_events_unignore_noop_when_absent() {
1130        let td = tempfile::tempdir().unwrap();
1131        std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
1132        remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1133        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1134        assert_eq!(s, ".ito/.state/\n");
1135    }
1136
1137    #[test]
1138    fn gitignore_full_audit_setup() {
1139        let td = tempfile::tempdir().unwrap();
1140        // Simulate a broad .state/ ignore
1141        std::fs::write(
1142            td.path().join(".gitignore"),
1143            ".ito/.state/\n!.ito/.state/audit/\n",
1144        )
1145        .unwrap();
1146        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1147        remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1148        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1149        assert!(s.contains(".ito/.state/audit/.session"));
1150        assert!(!s.contains("!.ito/.state/audit/"));
1151    }
1152
1153    #[test]
1154    fn gitignore_ignores_local_configs() {
1155        let td = tempfile::tempdir().unwrap();
1156        ensure_repo_gitignore_ignores_local_configs(td.path(), ".ito").unwrap();
1157        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1158        assert!(s.contains(".ito/config.local.json"));
1159        assert!(s.contains(".local/ito/config.json"));
1160    }
1161
1162    #[test]
1163    fn gitignore_exact_line_matching_trims_whitespace() {
1164        assert!(gitignore_has_exact_line("  foo  \nbar\n", "foo"));
1165        assert!(!gitignore_has_exact_line("foo\n", "bar"));
1166    }
1167
1168    #[test]
1169    fn should_install_project_rel_filters_by_tool_id() {
1170        let mut tools = BTreeSet::new();
1171        tools.insert(TOOL_OPENCODE.to_string());
1172
1173        assert!(should_install_project_rel("AGENTS.md", &tools));
1174        assert!(should_install_project_rel(".ito/config.json", &tools));
1175        assert!(should_install_project_rel(".opencode/config.json", &tools));
1176        assert!(!should_install_project_rel(".claude/settings.json", &tools));
1177        assert!(!should_install_project_rel(".codex/config.json", &tools));
1178        assert!(!should_install_project_rel(
1179            ".github/workflows/x.yml",
1180            &tools
1181        ));
1182        assert!(!should_install_project_rel(".pi/settings.json", &tools));
1183    }
1184
1185    #[test]
1186    fn should_install_project_rel_filters_pi() {
1187        let mut tools = BTreeSet::new();
1188        tools.insert(TOOL_PI.to_string());
1189
1190        // Pi-specific files install when Pi is selected
1191        assert!(should_install_project_rel(".pi/settings.json", &tools));
1192        assert!(should_install_project_rel(
1193            ".pi/extensions/ito-skills.ts",
1194            &tools
1195        ));
1196
1197        // Common files always install
1198        assert!(should_install_project_rel("AGENTS.md", &tools));
1199        assert!(should_install_project_rel(".ito/config.json", &tools));
1200
1201        // Other harness files do not install
1202        assert!(!should_install_project_rel(".opencode/config.json", &tools));
1203        assert!(!should_install_project_rel(".claude/settings.json", &tools));
1204        assert!(!should_install_project_rel(".codex/config.json", &tools));
1205    }
1206
1207    #[test]
1208    fn release_tag_is_prefixed_with_v() {
1209        let tag = release_tag();
1210        assert!(tag.starts_with('v'));
1211    }
1212
1213    #[test]
1214    fn update_model_in_yaml_replaces_or_inserts() {
1215        let yaml = "name: test\nmodel: \"old\"\n";
1216        let updated = update_model_in_yaml(yaml, "new");
1217        assert!(updated.contains("model: \"new\""));
1218
1219        let yaml = "name: test\n";
1220        let updated = update_model_in_yaml(yaml, "new");
1221        assert!(updated.contains("model: \"new\""));
1222    }
1223
1224    #[test]
1225    fn update_agent_model_field_updates_frontmatter_when_present() {
1226        let td = tempfile::tempdir().unwrap();
1227        let path = td.path().join("agent.md");
1228        std::fs::write(&path, "---\nname: test\nmodel: \"old\"\n---\nbody\n").unwrap();
1229        update_agent_model_field(&path, "new").unwrap();
1230        let s = std::fs::read_to_string(&path).unwrap();
1231        assert!(s.contains("model: \"new\""));
1232
1233        let path = td.path().join("no-frontmatter.md");
1234        std::fs::write(&path, "no frontmatter\n").unwrap();
1235        update_agent_model_field(&path, "newer").unwrap();
1236        let s = std::fs::read_to_string(&path).unwrap();
1237        assert_eq!(s, "no frontmatter\n");
1238    }
1239
1240    #[test]
1241    fn write_one_non_marker_files_skip_on_init_update_mode() {
1242        let td = tempfile::tempdir().unwrap();
1243        let target = td.path().join("plain.txt");
1244        std::fs::write(&target, "existing").unwrap();
1245
1246        let opts = InitOptions::new(BTreeSet::new(), false, true);
1247        write_one(
1248            &target,
1249            b"new",
1250            InstallMode::Init,
1251            &opts,
1252            FileOwnership::UserOwned,
1253        )
1254        .unwrap();
1255        let s = std::fs::read_to_string(&target).unwrap();
1256        assert_eq!(s, "existing");
1257    }
1258
1259    #[test]
1260    fn write_one_non_marker_ito_managed_files_overwrite_on_init_update_mode() {
1261        let td = tempfile::tempdir().unwrap();
1262        let target = td.path().join("plain.txt");
1263        std::fs::write(&target, "existing").unwrap();
1264
1265        let opts = InitOptions::new(BTreeSet::new(), false, true);
1266        write_one(
1267            &target,
1268            b"new",
1269            InstallMode::Init,
1270            &opts,
1271            FileOwnership::ItoManaged,
1272        )
1273        .unwrap();
1274        let s = std::fs::read_to_string(&target).unwrap();
1275        assert_eq!(s, "new");
1276    }
1277
1278    #[test]
1279    fn write_one_non_marker_user_owned_files_preserve_on_update_mode() {
1280        let td = tempfile::tempdir().unwrap();
1281        let target = td.path().join("plain.txt");
1282        std::fs::write(&target, "existing").unwrap();
1283
1284        let opts = InitOptions::new(BTreeSet::new(), false, true);
1285        write_one(
1286            &target,
1287            b"new",
1288            InstallMode::Update,
1289            &opts,
1290            FileOwnership::UserOwned,
1291        )
1292        .unwrap();
1293        let s = std::fs::read_to_string(&target).unwrap();
1294        assert_eq!(s, "existing");
1295    }
1296
1297    #[test]
1298    fn write_one_marker_managed_files_refuse_overwrite_without_markers() {
1299        let td = tempfile::tempdir().unwrap();
1300        let target = td.path().join("managed.md");
1301        std::fs::write(&target, "existing without markers\n").unwrap();
1302
1303        let template = format!(
1304            "before\n{}\nmanaged\n{}\nafter\n",
1305            ito_templates::ITO_START_MARKER,
1306            ito_templates::ITO_END_MARKER
1307        );
1308        let opts = InitOptions::new(BTreeSet::new(), false, false);
1309        let err = write_one(
1310            &target,
1311            template.as_bytes(),
1312            InstallMode::Init,
1313            &opts,
1314            FileOwnership::ItoManaged,
1315        )
1316        .unwrap_err();
1317        assert!(err.to_string().contains("Refusing to overwrite"));
1318    }
1319
1320    #[test]
1321    fn write_one_marker_managed_files_update_existing_markers() {
1322        let td = tempfile::tempdir().unwrap();
1323        let target = td.path().join("managed.md");
1324        let existing = format!(
1325            "before\n{}\nold\n{}\nafter\n",
1326            ito_templates::ITO_START_MARKER,
1327            ito_templates::ITO_END_MARKER
1328        );
1329        std::fs::write(&target, existing).unwrap();
1330
1331        let template = format!(
1332            "before\n{}\nnew\n{}\nafter\n",
1333            ito_templates::ITO_START_MARKER,
1334            ito_templates::ITO_END_MARKER
1335        );
1336        let opts = InitOptions::new(BTreeSet::new(), false, false);
1337        write_one(
1338            &target,
1339            template.as_bytes(),
1340            InstallMode::Init,
1341            &opts,
1342            FileOwnership::ItoManaged,
1343        )
1344        .unwrap();
1345        let s = std::fs::read_to_string(&target).unwrap();
1346        assert!(s.contains("new"));
1347        assert!(!s.contains("old"));
1348    }
1349
1350    #[test]
1351    fn write_one_marker_managed_files_error_when_markers_missing_in_update_mode() {
1352        let td = tempfile::tempdir().unwrap();
1353        let target = td.path().join("managed.md");
1354        // One marker present, one missing -> update should error.
1355        std::fs::write(
1356            &target,
1357            format!(
1358                "{}\nexisting without end marker\n",
1359                ito_templates::ITO_START_MARKER
1360            ),
1361        )
1362        .unwrap();
1363
1364        let template = format!(
1365            "before\n{}\nmanaged\n{}\nafter\n",
1366            ito_templates::ITO_START_MARKER,
1367            ito_templates::ITO_END_MARKER
1368        );
1369        let opts = InitOptions::new(BTreeSet::new(), false, true);
1370        let err = write_one(
1371            &target,
1372            template.as_bytes(),
1373            InstallMode::Init,
1374            &opts,
1375            FileOwnership::ItoManaged,
1376        )
1377        .unwrap_err();
1378        assert!(err.to_string().contains("Failed to update markers"));
1379    }
1380}