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