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/// Write a rendered managed-block markdown file with marker-scoped update
461/// semantics, suitable for installer paths that do not need the full
462/// `write_one` ownership/upgrade machinery (e.g. the harness manifest
463/// installer in `distribution.rs`).
464///
465/// Behaviour:
466///
467/// - **No target on disk** → write `rendered_bytes` verbatim.
468/// - **`mode == Init` && `opts.force`** → wholesale overwrite (matches the
469///   `--force` semantics in `write_one`).
470/// - **Template has no managed block** → wholesale overwrite (caller wanted
471///   plain replacement).
472/// - **Existing target has no markers** → wholesale overwrite. Treats the
473///   file as legacy from before managed markers were retrofitted; no user
474///   content is at risk because there was no marker boundary to honour.
475/// - **Existing target has markers** → marker-scoped update via
476///   `update_file_with_markers`, preserving everything outside the managed
477///   block byte-for-byte.
478///
479/// Returns `Ok(())` on success. Errors mirror `write_one` (IO + marker
480/// validation diagnostics).
481pub(crate) fn write_marker_aware_markdown(
482    target: &Path,
483    rendered_bytes: &[u8],
484    mode: InstallMode,
485    opts: &InitOptions,
486) -> CoreResult<()> {
487    if let Some(parent) = target.parent() {
488        ito_common::io::create_dir_all_std(parent)
489            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
490    }
491
492    let wholesale = |target: &Path| -> CoreResult<()> {
493        ito_common::io::write_std(target, rendered_bytes)
494            .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))
495    };
496
497    if !target.exists() {
498        return wholesale(target);
499    }
500
501    if mode == InstallMode::Init && opts.force {
502        return wholesale(target);
503    }
504
505    let Ok(text) = std::str::from_utf8(rendered_bytes) else {
506        return wholesale(target);
507    };
508    let Some(block) = ito_templates::extract_managed_block(text) else {
509        return wholesale(target);
510    };
511
512    let existing = ito_common::io::read_to_string_std(target)
513        .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
514    let has_start = existing.contains(ito_templates::ITO_START_MARKER);
515    let has_end = existing.contains(ito_templates::ITO_END_MARKER);
516    match (has_start, has_end) {
517        (false, false) => return wholesale(target),
518        (true, true) => {}
519        (true, false) | (false, true) => {
520            // Partial marker pair indicates the user (or some other tool)
521            // damaged the managed region. Refusing to write here mirrors
522            // `update_file_with_markers`' error path and prevents silently
523            // clobbering user content. The user must restore the markers
524            // (or pass `--force`) before update can proceed.
525            return Err(CoreError::Validation(format!(
526                "Refusing to update {}: file has a partial Ito marker pair (start={has_start}, end={has_end}). \
527Restore both markers manually, or rerun with `--force` to overwrite the file wholesale.",
528                target.display()
529            )));
530        }
531    }
532
533    let _ = update_file_with_markers(
534        target,
535        block,
536        ito_templates::ITO_START_MARKER,
537        ito_templates::ITO_END_MARKER,
538    )
539    .map_err(|e| match e {
540        markers::FsEditError::Io(io_err) => {
541            CoreError::io(format!("updating markers in {}", target.display()), io_err)
542        }
543        markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
544            "Failed to update markers in {}: {}",
545            target.display(),
546            marker_err
547        )),
548    })?;
549    Ok(())
550}
551
552/// Writes a rendered template to `target`, handling Ito-managed marker blocks, overwrite/update semantics,
553/// and ownership rules.
554///
555/// When the rendered template contains Ito start/end markers, this function treats the file as
556/// marker-managed: it will update only the managed block when the target exists (honoring `--force`,
557/// `--update`, and `--upgrade` semantics), or write the template verbatim when the target does not
558/// exist. For non-marker files, behavior depends on `mode`, `opts.force`, `opts.update`, and `ownership`:
559/// - On Init: `--force` overwrites; `--update` skips user-owned files; otherwise the function refuses
560///   to overwrite existing files without markers.
561/// - On Update: skips user-owned files; otherwise writes/overwrites the target.
562///
563/// Errors are returned for IO failures and for invalid marker states when an update is attempted
564/// (except when `opts.upgrade` is true, in which case a missing marker in an expected marker-managed
565/// file produces a warning and the existing file is preserved).
566fn write_one(
567    target: &Path,
568    rendered_bytes: &[u8],
569    mode: InstallMode,
570    opts: &InitOptions,
571    ownership: FileOwnership,
572) -> CoreResult<()> {
573    if let Some(parent) = target.parent() {
574        ito_common::io::create_dir_all_std(parent)
575            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
576    }
577
578    // Marker-managed files: template contains markers; we extract the inner block.
579    if let Ok(text) = std::str::from_utf8(rendered_bytes)
580        && let Some(block) = ito_templates::extract_managed_block(text)
581    {
582        if target.exists() {
583            // --force always overwrites the file wholesale on init.
584            if mode == InstallMode::Init && opts.force {
585                ito_common::io::write_std(target, rendered_bytes)
586                    .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
587                return Ok(());
588            }
589
590            // User-owned files keep their content untouched on update / non-forced
591            // init even when the template now ships managed markers. Updating the
592            // managed block here would clobber user edits to files Ito only seeds
593            // (e.g. .ito/project.md, .ito/user-prompts/*.md).
594            if ownership == FileOwnership::UserOwned {
595                let updating = mode == InstallMode::Update
596                    || (mode == InstallMode::Init && (opts.update || opts.upgrade));
597                if updating {
598                    return Ok(());
599                }
600            }
601
602            // Read the existing file once and check for both Ito markers.
603            let existing = ito_common::io::read_to_string_std(target)
604                .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
605            let has_markers = existing.contains(ito_templates::ITO_START_MARKER)
606                && existing.contains(ito_templates::ITO_END_MARKER);
607
608            if !has_markers {
609                if opts.upgrade {
610                    // Upgrade fail-safe: when a file is expected to be marker-managed but no
611                    // longer contains valid Ito markers, preserve the file unchanged and emit
612                    // actionable guidance rather than returning an error.
613                    eprintln!(
614                        "warning: skipping upgrade of {} — Ito markers not found.\n\
615                        To restore managed upgrade support, re-add the markers manually:\n\
616                        \n\
617                        {start}\n\
618                        <ito-managed content>\n\
619                        {end}\n\
620                        \n\
621                        Then re-run `ito init --upgrade`.",
622                        target.display(),
623                        start = ito_templates::ITO_START_MARKER,
624                        end = ito_templates::ITO_END_MARKER,
625                    );
626                    return Ok(());
627                }
628
629                if mode == InstallMode::Init && !opts.update {
630                    // Plain init: refuse to overwrite without --force or --update.
631                    return Err(CoreError::Validation(format!(
632                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
633                        target.display()
634                    )));
635                }
636
637                // update / `init --update` against an Ito-managed file that
638                // predates marker rollout. Only safe to wholesale-rewrite when
639                // the existing file is genuinely marker-free (a partial
640                // marker pair indicates the user has manually edited the
641                // managed region and we should error rather than overwrite).
642                let existing_has_no_markers = !existing.contains(ito_templates::ITO_START_MARKER)
643                    && !existing.contains(ito_templates::ITO_END_MARKER);
644                if existing_has_no_markers {
645                    // Decide between wholesale rewrite and marker-prepend
646                    // based on the template's shape:
647                    //
648                    // - If the template's managed block spans the entire
649                    //   file, there is no user-editable region; rewrite
650                    //   wholesale to drop stale content from the previous
651                    //   version.
652                    //
653                    // - If the template has a non-empty prefix (typically
654                    //   YAML frontmatter) above the managed block,
655                    //   marker-prepend would re-order the existing
656                    //   frontmatter and corrupt the file. Rewrite wholesale
657                    //   instead.
658                    //
659                    // - Otherwise (template has only markers, no surrounding
660                    //   content), fall through to `update_file_with_markers`
661                    //   which prepends the managed block to the existing
662                    //   file while preserving user content.
663                    if template_is_entirely_managed(text)
664                        || template_has_prefix_outside_markers(text)
665                    {
666                        ito_common::io::write_std(target, rendered_bytes).map_err(|e| {
667                            CoreError::io(format!("writing {}", target.display()), e)
668                        })?;
669                        return Ok(());
670                    }
671                }
672            }
673
674            update_file_with_markers(
675                target,
676                block,
677                ito_templates::ITO_START_MARKER,
678                ito_templates::ITO_END_MARKER,
679            )
680            .map_err(|e| match e {
681                markers::FsEditError::Io(io_err) => {
682                    CoreError::io(format!("updating markers in {}", target.display()), io_err)
683                }
684                markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
685                    "Failed to update markers in {}: {}",
686                    target.display(),
687                    marker_err
688                )),
689            })?;
690        } else {
691            // New file: write the template bytes verbatim so output matches embedded assets.
692            ito_common::io::write_std(target, rendered_bytes)
693                .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
694        }
695
696        return Ok(());
697    }
698
699    if target.exists() {
700        match mode {
701            InstallMode::Init => {
702                if opts.force {
703                    // --force always overwrites on init.
704                } else if opts.update {
705                    if ownership == FileOwnership::UserOwned {
706                        return Ok(());
707                    }
708                } else {
709                    return Err(CoreError::Validation(format!(
710                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
711                        target.display()
712                    )));
713                }
714            }
715            InstallMode::Update => {
716                if ownership == FileOwnership::UserOwned {
717                    return Ok(());
718                }
719            }
720        }
721    }
722
723    ito_common::io::write_std(target, rendered_bytes)
724        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
725    Ok(())
726}
727
728fn write_claude_settings(
729    target: &Path,
730    rendered_bytes: &[u8],
731    mode: InstallMode,
732    opts: &InitOptions,
733) -> CoreResult<()> {
734    if let Some(parent) = target.parent() {
735        ito_common::io::create_dir_all_std(parent)
736            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
737    }
738
739    if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
740        return Err(CoreError::Validation(format!(
741            "Refusing to overwrite existing file without markers: {} (re-run with --force)",
742            target.display()
743        )));
744    }
745
746    let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
747        CoreError::Validation(format!(
748            "Failed to parse Claude settings template {}: {}",
749            target.display(),
750            e
751        ))
752    })?;
753
754    if !target.exists() || (mode == InstallMode::Init && opts.force) {
755        let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
756            CoreError::Validation(format!(
757                "Failed to render Claude settings template {}: {}",
758                target.display(),
759                e
760            ))
761        })?;
762        bytes.push(b'\n');
763        ito_common::io::write_std(target, bytes)
764            .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
765        return Ok(());
766    }
767
768    let existing_raw = ito_common::io::read_to_string_std(target)
769        .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
770    let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
771        // Preserve user-owned files that are not valid JSON during update flows.
772        return Ok(());
773    };
774
775    merge_json_objects(&mut existing_value, &template_value);
776    let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
777        CoreError::Validation(format!(
778            "Failed to render merged Claude settings {}: {}",
779            target.display(),
780            e
781        ))
782    })?;
783    merged.push(b'\n');
784    ito_common::io::write_std(target, merged)
785        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
786    Ok(())
787}
788
789fn merge_json_objects(existing: &mut Value, template: &Value) {
790    let Value::Object(template_map) = template else {
791        *existing = template.clone();
792        return;
793    };
794    if !existing.is_object() {
795        *existing = Value::Object(Map::new());
796    }
797
798    let Some(existing_map) = existing.as_object_mut() else {
799        return;
800    };
801
802    for (key, template_value) in template_map {
803        if let Some(existing_value) = existing_map.get_mut(key) {
804            merge_json_values(existing_value, template_value);
805        } else {
806            existing_map.insert(key.clone(), template_value.clone());
807        }
808    }
809}
810
811fn merge_json_values(existing: &mut Value, template: &Value) {
812    match (existing, template) {
813        (Value::Object(existing_map), Value::Object(template_map)) => {
814            for (key, template_value) in template_map {
815                if let Some(existing_value) = existing_map.get_mut(key) {
816                    merge_json_values(existing_value, template_value);
817                } else {
818                    existing_map.insert(key.clone(), template_value.clone());
819                }
820            }
821        }
822        (Value::Array(existing_items), Value::Array(template_items)) => {
823            for template_item in template_items {
824                if !existing_items.contains(template_item) {
825                    existing_items.push(template_item.clone());
826                }
827            }
828        }
829        (existing_value, template_value) => *existing_value = template_value.clone(),
830    }
831}
832
833fn install_adapter_files(
834    project_root: &Path,
835    mode: InstallMode,
836    opts: &InitOptions,
837    worktree_ctx: Option<&WorktreeTemplateContext>,
838) -> CoreResult<()> {
839    for tool in &opts.tools {
840        match tool.as_str() {
841            TOOL_OPENCODE => {
842                let config_dir = project_root.join(".opencode");
843                let manifests = crate::distribution::opencode_manifests(&config_dir);
844                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
845            }
846            TOOL_CLAUDE => {
847                let manifests = crate::distribution::claude_manifests(project_root);
848                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
849            }
850            TOOL_CODEX => {
851                let manifests = crate::distribution::codex_manifests(project_root);
852                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
853            }
854            TOOL_GITHUB_COPILOT => {
855                let manifests = crate::distribution::github_manifests(project_root);
856                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
857            }
858            TOOL_PI => {
859                let manifests = crate::distribution::pi_manifests(project_root);
860                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
861            }
862            _ => {}
863        }
864    }
865
866    Ok(())
867}
868
869/// Install Ito agent templates (ito-quick, ito-general, ito-thinking)
870fn install_agent_templates(
871    project_root: &Path,
872    mode: InstallMode,
873    opts: &InitOptions,
874) -> CoreResult<()> {
875    use ito_templates::agents::{AgentTier, Harness, default_agent_configs, get_agent_files};
876
877    let configs = default_agent_configs();
878
879    // Map tool names to harnesses
880    let tool_harness_map = [
881        (TOOL_OPENCODE, Harness::OpenCode),
882        (TOOL_CLAUDE, Harness::ClaudeCode),
883        (TOOL_CODEX, Harness::Codex),
884        (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
885        (TOOL_PI, Harness::Pi),
886    ];
887
888    for (tool_id, harness) in tool_harness_map {
889        if !opts.tools.contains(tool_id) {
890            continue;
891        }
892
893        let agent_dir = project_root.join(harness.project_agent_path());
894
895        // Get agent template files for this harness
896        let files = get_agent_files(harness);
897
898        for (rel_path, contents) in files {
899            let target = agent_dir.join(rel_path);
900
901            // Parse the template and determine which tier it is
902            let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
903                Some(AgentTier::Quick)
904            } else if rel_path.contains("ito-general") || rel_path.contains("general") {
905                Some(AgentTier::General)
906            } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
907                Some(AgentTier::Thinking)
908            } else {
909                None
910            };
911
912            // Get config for this tier.
913            //
914            // Some harness agent templates are not tiered but still include
915            // `{{model}}` placeholders (e.g., coordinator agents). In that case,
916            // render using the harness's General tier defaults.
917            let mut config = tier.and_then(|t| configs.get(&(harness, t)));
918            if config.is_none()
919                && let Ok(s) = std::str::from_utf8(contents)
920                && s.contains("{{model}}")
921            {
922                config = configs.get(&(harness, AgentTier::General));
923            }
924
925            match mode {
926                InstallMode::Init => {
927                    if target.exists() && !opts.force {
928                        if opts.update {
929                            let rendered = render_and_stamp_agent(contents, config, &target);
930                            update_existing_agent_template(&target, &rendered, mode, opts, config)?;
931                        }
932                        continue;
933                    }
934
935                    let rendered = render_and_stamp_agent(contents, config, &target);
936                    write_marker_aware_markdown(&target, &rendered, mode, opts)?;
937                }
938                InstallMode::Update => {
939                    let rendered = render_and_stamp_agent(contents, config, &target);
940                    if target.exists() {
941                        update_existing_agent_template(&target, &rendered, mode, opts, config)?;
942                    } else {
943                        write_marker_aware_markdown(&target, &rendered, mode, opts)?;
944                    }
945                }
946            }
947        }
948    }
949
950    Ok(())
951}
952
953/// Updates an existing agent template without clobbering user-owned bodies.
954///
955/// Files with complete Ito markers get their managed block refreshed. Legacy
956/// markerless files keep their body and only receive frontmatter model updates.
957/// Partial marker pairs are treated like damaged managed regions: preserve the
958/// body for compatibility, but warn so the user can repair the file.
959fn update_existing_agent_template(
960    target: &Path,
961    rendered: &[u8],
962    mode: InstallMode,
963    opts: &InitOptions,
964    config: Option<&ito_templates::agents::AgentConfig>,
965) -> CoreResult<()> {
966    let existing = ito_common::io::read_to_string_std(target)
967        .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
968    let has_start = existing.contains(ito_templates::ITO_START_MARKER);
969    let has_end = existing.contains(ito_templates::ITO_END_MARKER);
970
971    match (has_start, has_end) {
972        (true, true) => write_marker_aware_markdown(target, rendered, mode, opts)?,
973        (false, false) => {}
974        (true, false) | (false, true) => {
975            eprintln!(
976                "warning: skipping marker update for {}: file has a partial Ito marker pair \
977                (start={has_start}, end={has_end}). Restore both markers manually, or rerun \
978                with `--force` to overwrite the file wholesale.",
979                target.display()
980            );
981        }
982    }
983
984    if let Some(cfg) = config {
985        update_agent_model_field(target, &cfg.model)?;
986    }
987
988    Ok(())
989}
990
991/// Update only the model field in an existing agent file's frontmatter
992fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
993    let content = ito_common::io::read_to_string_or_default(path);
994
995    // Only update files with frontmatter
996    if !content.starts_with("---") {
997        return Ok(());
998    }
999
1000    // Find frontmatter boundaries
1001    let rest = &content[3..];
1002    let Some(end_idx) = rest.find("\n---") else {
1003        return Ok(());
1004    };
1005
1006    let frontmatter = &rest[..end_idx];
1007    let body = &rest[end_idx + 4..]; // Skip "\n---"
1008
1009    // Update model field in frontmatter using simple string replacement
1010    let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
1011
1012    // Reconstruct file
1013    let updated = format!("---{}\n---{}", updated_frontmatter, body);
1014    ito_common::io::write_std(path, updated)
1015        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
1016
1017    Ok(())
1018}
1019
1020/// Replace or insert the `model` field in YAML frontmatter.
1021fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
1022    let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
1023    let mut found = false;
1024
1025    for line in &mut lines {
1026        if line.trim_start().starts_with("model:") {
1027            *line = format!("model: \"{}\"", new_model);
1028            found = true;
1029            break;
1030        }
1031    }
1032
1033    // If no model field found, add it
1034    if !found {
1035        lines.push(format!("model: \"{}\"", new_model));
1036    }
1037
1038    lines.join("\n")
1039}
1040
1041#[cfg(test)]
1042mod json_tests;
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047
1048    #[test]
1049    fn gitignore_created_when_missing() {
1050        let td = tempfile::tempdir().unwrap();
1051        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1052        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1053        assert_eq!(s, ".ito/session.json\n");
1054    }
1055
1056    #[test]
1057    fn gitignore_noop_when_already_present() {
1058        let td = tempfile::tempdir().unwrap();
1059        std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
1060        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1061        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1062        assert_eq!(s, ".ito/session.json\n");
1063    }
1064
1065    #[test]
1066    fn gitignore_does_not_duplicate_on_repeated_calls() {
1067        let td = tempfile::tempdir().unwrap();
1068        std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
1069        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1070        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1071        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1072        assert_eq!(s, "node_modules\n.ito/session.json\n");
1073    }
1074
1075    #[test]
1076    fn gitignore_audit_session_added() {
1077        let td = tempfile::tempdir().unwrap();
1078        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1079        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1080        assert!(s.contains(".ito/.state/audit/.session"));
1081    }
1082
1083    #[test]
1084    fn gitignore_both_session_entries() {
1085        let td = tempfile::tempdir().unwrap();
1086        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1087        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1088        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1089        assert!(s.contains(".ito/session.json"));
1090        assert!(s.contains(".ito/.state/audit/.session"));
1091    }
1092
1093    #[test]
1094    fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
1095        let td = tempfile::tempdir().unwrap();
1096        std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
1097        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1098        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1099        assert_eq!(s, "node_modules\n.ito/session.json\n");
1100    }
1101
1102    #[test]
1103    fn gitignore_legacy_audit_events_unignore_removed() {
1104        let td = tempfile::tempdir().unwrap();
1105        std::fs::write(
1106            td.path().join(".gitignore"),
1107            ".ito/.state/\n!.ito/.state/audit/\n",
1108        )
1109        .unwrap();
1110        remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1111        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1112        assert_eq!(s, ".ito/.state/\n");
1113    }
1114
1115    #[test]
1116    fn gitignore_legacy_audit_events_unignore_noop_when_absent() {
1117        let td = tempfile::tempdir().unwrap();
1118        std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
1119        remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1120        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1121        assert_eq!(s, ".ito/.state/\n");
1122    }
1123
1124    #[test]
1125    fn gitignore_full_audit_setup() {
1126        let td = tempfile::tempdir().unwrap();
1127        // Simulate a broad .state/ ignore
1128        std::fs::write(
1129            td.path().join(".gitignore"),
1130            ".ito/.state/\n!.ito/.state/audit/\n",
1131        )
1132        .unwrap();
1133        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1134        remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1135        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1136        assert!(s.contains(".ito/.state/audit/.session"));
1137        assert!(!s.contains("!.ito/.state/audit/"));
1138    }
1139
1140    #[test]
1141    fn gitignore_ignores_local_configs() {
1142        let td = tempfile::tempdir().unwrap();
1143        ensure_repo_gitignore_ignores_local_configs(td.path(), ".ito").unwrap();
1144        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1145        assert!(s.contains(".ito/config.local.json"));
1146        assert!(s.contains(".local/ito/config.json"));
1147    }
1148
1149    #[test]
1150    fn gitignore_exact_line_matching_trims_whitespace() {
1151        assert!(gitignore_has_exact_line("  foo  \nbar\n", "foo"));
1152        assert!(!gitignore_has_exact_line("foo\n", "bar"));
1153    }
1154
1155    #[test]
1156    fn should_install_project_rel_filters_by_tool_id() {
1157        let mut tools = BTreeSet::new();
1158        tools.insert(TOOL_OPENCODE.to_string());
1159
1160        assert!(should_install_project_rel("AGENTS.md", &tools));
1161        assert!(should_install_project_rel(".ito/config.json", &tools));
1162        assert!(should_install_project_rel(".opencode/config.json", &tools));
1163        assert!(!should_install_project_rel(".claude/settings.json", &tools));
1164        assert!(!should_install_project_rel(".codex/config.json", &tools));
1165        assert!(!should_install_project_rel(
1166            ".github/workflows/x.yml",
1167            &tools
1168        ));
1169        assert!(!should_install_project_rel(".pi/settings.json", &tools));
1170    }
1171
1172    #[test]
1173    fn should_install_project_rel_filters_pi() {
1174        let mut tools = BTreeSet::new();
1175        tools.insert(TOOL_PI.to_string());
1176
1177        // Pi-specific files install when Pi is selected
1178        assert!(should_install_project_rel(".pi/settings.json", &tools));
1179        assert!(should_install_project_rel(
1180            ".pi/extensions/ito-skills.ts",
1181            &tools
1182        ));
1183
1184        // Common files always install
1185        assert!(should_install_project_rel("AGENTS.md", &tools));
1186        assert!(should_install_project_rel(".ito/config.json", &tools));
1187
1188        // Other harness files do not install
1189        assert!(!should_install_project_rel(".opencode/config.json", &tools));
1190        assert!(!should_install_project_rel(".claude/settings.json", &tools));
1191        assert!(!should_install_project_rel(".codex/config.json", &tools));
1192    }
1193
1194    #[test]
1195    fn release_tag_is_prefixed_with_v() {
1196        let tag = release_tag();
1197        assert!(tag.starts_with('v'));
1198    }
1199
1200    #[test]
1201    fn update_model_in_yaml_replaces_or_inserts() {
1202        let yaml = "name: test\nmodel: \"old\"\n";
1203        let updated = update_model_in_yaml(yaml, "new");
1204        assert!(updated.contains("model: \"new\""));
1205
1206        let yaml = "name: test\n";
1207        let updated = update_model_in_yaml(yaml, "new");
1208        assert!(updated.contains("model: \"new\""));
1209    }
1210
1211    #[test]
1212    fn update_agent_model_field_updates_frontmatter_when_present() {
1213        let td = tempfile::tempdir().unwrap();
1214        let path = td.path().join("agent.md");
1215        std::fs::write(&path, "---\nname: test\nmodel: \"old\"\n---\nbody\n").unwrap();
1216        update_agent_model_field(&path, "new").unwrap();
1217        let s = std::fs::read_to_string(&path).unwrap();
1218        assert!(s.contains("model: \"new\""));
1219
1220        let path = td.path().join("no-frontmatter.md");
1221        std::fs::write(&path, "no frontmatter\n").unwrap();
1222        update_agent_model_field(&path, "newer").unwrap();
1223        let s = std::fs::read_to_string(&path).unwrap();
1224        assert_eq!(s, "no frontmatter\n");
1225    }
1226
1227    #[test]
1228    fn write_one_non_marker_files_skip_on_init_update_mode() {
1229        let td = tempfile::tempdir().unwrap();
1230        let target = td.path().join("plain.txt");
1231        std::fs::write(&target, "existing").unwrap();
1232
1233        let opts = InitOptions::new(BTreeSet::new(), false, true);
1234        write_one(
1235            &target,
1236            b"new",
1237            InstallMode::Init,
1238            &opts,
1239            FileOwnership::UserOwned,
1240        )
1241        .unwrap();
1242        let s = std::fs::read_to_string(&target).unwrap();
1243        assert_eq!(s, "existing");
1244    }
1245
1246    #[test]
1247    fn write_one_non_marker_ito_managed_files_overwrite_on_init_update_mode() {
1248        let td = tempfile::tempdir().unwrap();
1249        let target = td.path().join("plain.txt");
1250        std::fs::write(&target, "existing").unwrap();
1251
1252        let opts = InitOptions::new(BTreeSet::new(), false, true);
1253        write_one(
1254            &target,
1255            b"new",
1256            InstallMode::Init,
1257            &opts,
1258            FileOwnership::ItoManaged,
1259        )
1260        .unwrap();
1261        let s = std::fs::read_to_string(&target).unwrap();
1262        assert_eq!(s, "new");
1263    }
1264
1265    #[test]
1266    fn write_one_non_marker_user_owned_files_preserve_on_update_mode() {
1267        let td = tempfile::tempdir().unwrap();
1268        let target = td.path().join("plain.txt");
1269        std::fs::write(&target, "existing").unwrap();
1270
1271        let opts = InitOptions::new(BTreeSet::new(), false, true);
1272        write_one(
1273            &target,
1274            b"new",
1275            InstallMode::Update,
1276            &opts,
1277            FileOwnership::UserOwned,
1278        )
1279        .unwrap();
1280        let s = std::fs::read_to_string(&target).unwrap();
1281        assert_eq!(s, "existing");
1282    }
1283
1284    #[test]
1285    fn write_one_marker_managed_files_refuse_overwrite_without_markers() {
1286        let td = tempfile::tempdir().unwrap();
1287        let target = td.path().join("managed.md");
1288        std::fs::write(&target, "existing without markers\n").unwrap();
1289
1290        let template = format!(
1291            "before\n{}\nmanaged\n{}\nafter\n",
1292            ito_templates::ITO_START_MARKER,
1293            ito_templates::ITO_END_MARKER
1294        );
1295        let opts = InitOptions::new(BTreeSet::new(), false, false);
1296        let err = write_one(
1297            &target,
1298            template.as_bytes(),
1299            InstallMode::Init,
1300            &opts,
1301            FileOwnership::ItoManaged,
1302        )
1303        .unwrap_err();
1304        assert!(err.to_string().contains("Refusing to overwrite"));
1305    }
1306
1307    #[test]
1308    fn write_one_marker_managed_files_update_existing_markers() {
1309        let td = tempfile::tempdir().unwrap();
1310        let target = td.path().join("managed.md");
1311        let existing = format!(
1312            "before\n{}\nold\n{}\nafter\n",
1313            ito_templates::ITO_START_MARKER,
1314            ito_templates::ITO_END_MARKER
1315        );
1316        std::fs::write(&target, existing).unwrap();
1317
1318        let template = format!(
1319            "before\n{}\nnew\n{}\nafter\n",
1320            ito_templates::ITO_START_MARKER,
1321            ito_templates::ITO_END_MARKER
1322        );
1323        let opts = InitOptions::new(BTreeSet::new(), false, false);
1324        write_one(
1325            &target,
1326            template.as_bytes(),
1327            InstallMode::Init,
1328            &opts,
1329            FileOwnership::ItoManaged,
1330        )
1331        .unwrap();
1332        let s = std::fs::read_to_string(&target).unwrap();
1333        assert!(s.contains("new"));
1334        assert!(!s.contains("old"));
1335    }
1336
1337    #[test]
1338    fn write_one_marker_managed_files_error_when_markers_missing_in_update_mode() {
1339        let td = tempfile::tempdir().unwrap();
1340        let target = td.path().join("managed.md");
1341        // One marker present, one missing -> update should error.
1342        std::fs::write(
1343            &target,
1344            format!(
1345                "{}\nexisting without end marker\n",
1346                ito_templates::ITO_START_MARKER
1347            ),
1348        )
1349        .unwrap();
1350
1351        let template = format!(
1352            "before\n{}\nmanaged\n{}\nafter\n",
1353            ito_templates::ITO_START_MARKER,
1354            ito_templates::ITO_END_MARKER
1355        );
1356        let opts = InitOptions::new(BTreeSet::new(), false, true);
1357        let err = write_one(
1358            &target,
1359            template.as_bytes(),
1360            InstallMode::Init,
1361            &opts,
1362            FileOwnership::ItoManaged,
1363        )
1364        .unwrap_err();
1365        assert!(err.to_string().contains("Failed to update markers"));
1366    }
1367}