use std::collections::BTreeSet;
use std::path::Path;
use chrono::Utc;
use serde_json::{Map, Value};
use crate::errors::{CoreError, CoreResult};
use markers::update_file_with_markers;
mod markers;
use ito_config::ConfigContext;
use ito_config::ito_dir::get_ito_dir_name;
use ito_templates::project_templates::WorktreeTemplateContext;
pub const TOOL_CLAUDE: &str = "claude";
pub const TOOL_CODEX: &str = "codex";
pub const TOOL_GITHUB_COPILOT: &str = "github-copilot";
pub const TOOL_OPENCODE: &str = "opencode";
pub const TOOL_PI: &str = "pi";
const CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER: &str = "__ITO_RELEASE_TAG__";
pub fn available_tool_ids() -> &'static [&'static str] {
&[
TOOL_CLAUDE,
TOOL_CODEX,
TOOL_GITHUB_COPILOT,
TOOL_OPENCODE,
TOOL_PI,
]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitOptions {
pub tools: BTreeSet<String>,
pub force: bool,
pub update: bool,
pub upgrade: bool,
}
impl InitOptions {
pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
Self {
tools,
force,
update,
upgrade: false,
}
}
pub fn new_upgrade(tools: BTreeSet<String>) -> Self {
Self {
tools,
force: false,
update: true,
upgrade: true,
}
}
pub fn with_upgrade(mut self) -> Self {
self.upgrade = true;
self.update = true;
self.force = false;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallMode {
Init,
Update,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileOwnership {
ItoManaged,
UserOwned,
}
pub fn install_default_templates(
project_root: &Path,
ctx: &ConfigContext,
mode: InstallMode,
opts: &InitOptions,
worktree_ctx: Option<&WorktreeTemplateContext>,
) -> CoreResult<()> {
let ito_dir_name = get_ito_dir_name(project_root, ctx);
let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
if mode == InstallMode::Init {
ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
remove_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
}
ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
install_adapter_files(project_root, mode, opts, worktree_ctx)?;
install_agent_templates(project_root, mode, opts)?;
Ok(())
}
fn ensure_repo_gitignore_ignores_local_configs(
project_root: &Path,
ito_dir: &str,
) -> CoreResult<()> {
let entry = format!("{ito_dir}/config.local.json");
ensure_gitignore_contains_line(project_root, &entry)?;
let entry = ".local/ito/config.json";
ensure_gitignore_contains_line(project_root, entry)?;
Ok(())
}
fn ensure_repo_gitignore_ignores_session_json(
project_root: &Path,
ito_dir: &str,
) -> CoreResult<()> {
let entry = format!("{ito_dir}/session.json");
ensure_gitignore_contains_line(project_root, &entry)
}
fn ensure_repo_gitignore_ignores_audit_session(
project_root: &Path,
ito_dir: &str,
) -> CoreResult<()> {
let entry = format!("{ito_dir}/.state/audit/.session");
ensure_gitignore_contains_line(project_root, &entry)
}
fn remove_repo_gitignore_unignores_audit_events(
project_root: &Path,
ito_dir: &str,
) -> CoreResult<()> {
let entry = format!("!{ito_dir}/.state/audit/");
remove_gitignore_exact_line(project_root, &entry)
}
fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
let path = project_root.join(".gitignore");
let existing = match ito_common::io::read_to_string_std(&path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
};
let Some(mut s) = existing else {
ito_common::io::write_std(&path, format!("{entry}\n"))
.map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
return Ok(());
};
if gitignore_has_exact_line(&s, entry) {
return Ok(());
}
if !s.ends_with('\n') {
s.push('\n');
}
s.push_str(entry);
s.push('\n');
ito_common::io::write_std(&path, s)
.map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
Ok(())
}
fn remove_gitignore_exact_line(project_root: &Path, entry: &str) -> CoreResult<()> {
let path = project_root.join(".gitignore");
let existing = match ito_common::io::read_to_string_std(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
};
let mut filtered = Vec::new();
let mut removed = false;
for line in existing.lines() {
if line.trim() == entry {
removed = true;
continue;
}
filtered.push(line);
}
if !removed {
return Ok(());
}
let mut updated = filtered.join("\n");
if !updated.is_empty() {
updated.push('\n');
}
ito_common::io::write_std(&path, updated)
.map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
Ok(())
}
fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
contents.lines().map(|l| l.trim()).any(|l| l == entry)
}
fn install_project_templates(
project_root: &Path,
ito_dir: &str,
mode: InstallMode,
opts: &InitOptions,
worktree_ctx: Option<&WorktreeTemplateContext>,
) -> CoreResult<()> {
use ito_templates::project_templates::render_project_template;
let selected = &opts.tools;
let current_date = Utc::now().format("%Y-%m-%d").to_string();
let state_rel = format!("{ito_dir}/planning/STATE.md");
let config_json_rel = format!("{ito_dir}/config.json");
let release_tag = release_tag();
let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
let default_ctx = WorktreeTemplateContext::default();
let ctx = worktree_ctx.unwrap_or(&default_ctx);
for f in ito_templates::default_project_files() {
let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
let rel = rel.as_ref();
if !should_install_project_rel(rel, selected) {
continue;
}
let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
if let Ok(s) = std::str::from_utf8(&bytes) {
if rel == state_rel {
bytes = s.replace("__CURRENT_DATE__", ¤t_date).into_bytes();
} else if rel == config_json_rel {
bytes = s
.replace(CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER, &release_tag)
.into_bytes();
}
}
if rel == "AGENTS.md" {
bytes = render_project_template(&bytes, ctx).map_err(|e| {
CoreError::Validation(format!("Failed to render template {rel}: {e}"))
})?;
}
if rel.ends_with(".md")
&& !rel.ends_with(".md.j2")
&& let Ok(text) = std::str::from_utf8(&bytes)
&& text.contains(ito_templates::ITO_START_MARKER)
{
bytes = ito_templates::stamp_version(text, semver).into_bytes();
}
let ownership = classify_project_file_ownership(rel, ito_dir);
let target = project_root.join(rel);
if rel == ".claude/settings.json" {
write_claude_settings(&target, &bytes, mode, opts)?;
continue;
}
write_one(&target, &bytes, mode, opts, ownership)?;
}
Ok(())
}
fn release_tag() -> String {
let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
if version.starts_with('v') {
return version.to_string();
}
format!("v{version}")
}
fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
if rel == "AGENTS.md" {
return true;
}
if rel.starts_with(".ito/") {
return true;
}
if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
return tools.contains(TOOL_CLAUDE);
}
if rel.starts_with(".opencode/") {
return tools.contains(TOOL_OPENCODE);
}
if rel.starts_with(".github/") {
return tools.contains(TOOL_GITHUB_COPILOT);
}
if rel.starts_with(".codex/") {
return tools.contains(TOOL_CODEX);
}
if rel.starts_with(".pi/") {
return tools.contains(TOOL_PI);
}
false
}
fn classify_project_file_ownership(rel: &str, ito_dir: &str) -> FileOwnership {
let project_md_rel = format!("{ito_dir}/project.md");
if rel == project_md_rel {
return FileOwnership::UserOwned;
}
let config_json_rel = format!("{ito_dir}/config.json");
if rel == config_json_rel {
return FileOwnership::UserOwned;
}
let user_guidance_rel = format!("{ito_dir}/user-guidance.md");
if rel == user_guidance_rel {
return FileOwnership::UserOwned;
}
let user_prompts_prefix = format!("{ito_dir}/user-prompts/");
if rel.starts_with(&user_prompts_prefix) {
return FileOwnership::UserOwned;
}
FileOwnership::ItoManaged
}
fn template_is_entirely_managed(text: &str) -> bool {
let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
return false;
};
let Some(end) = text.find(ito_templates::ITO_END_MARKER) else {
return false;
};
let before = text[..start].trim();
let after = text[end + ito_templates::ITO_END_MARKER.len()..].trim();
before.is_empty() && after.is_empty()
}
fn template_has_prefix_outside_markers(text: &str) -> bool {
let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
return false;
};
!text[..start].trim().is_empty()
}
fn render_and_stamp_agent(
contents: &[u8],
config: Option<&ito_templates::agents::AgentConfig>,
target: &Path,
) -> Vec<u8> {
let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
let rendered = match (std::str::from_utf8(contents), config) {
(Ok(template_str), Some(cfg)) => {
ito_templates::agents::render_agent_template(template_str, cfg).into_bytes()
}
_ => contents.to_vec(),
};
let path_str = target.to_string_lossy();
if !path_str.ends_with(".md") || path_str.ends_with(".md.j2") {
return rendered;
}
let Ok(text) = std::str::from_utf8(&rendered) else {
return rendered;
};
if !text.contains(ito_templates::ITO_START_MARKER) {
return rendered;
}
ito_templates::stamp_version(text, semver).into_bytes()
}
fn refresh_agent_version_stamp(target: &Path) -> CoreResult<()> {
let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
let existing = ito_common::io::read_to_string_std(target)
.map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
if !existing.contains(ito_templates::ITO_START_MARKER) {
return Ok(());
}
let stamped = ito_templates::stamp_version(&existing, semver);
if stamped == existing {
return Ok(());
}
ito_common::io::write_std(target, stamped.into_bytes())
.map_err(|e| CoreError::io(format!("stamping {}", target.display()), e))
}
pub(crate) fn write_marker_aware_markdown(
target: &Path,
rendered_bytes: &[u8],
mode: InstallMode,
opts: &InitOptions,
) -> CoreResult<()> {
if let Some(parent) = target.parent() {
ito_common::io::create_dir_all_std(parent)
.map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
}
let wholesale = |target: &Path| -> CoreResult<()> {
ito_common::io::write_std(target, rendered_bytes)
.map_err(|e| CoreError::io(format!("writing {}", target.display()), e))
};
if !target.exists() {
return wholesale(target);
}
if mode == InstallMode::Init && opts.force {
return wholesale(target);
}
let Ok(text) = std::str::from_utf8(rendered_bytes) else {
return wholesale(target);
};
let Some(block) = ito_templates::extract_managed_block(text) else {
return wholesale(target);
};
let existing = ito_common::io::read_to_string_std(target)
.map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
let has_start = existing.contains(ito_templates::ITO_START_MARKER);
let has_end = existing.contains(ito_templates::ITO_END_MARKER);
match (has_start, has_end) {
(false, false) => return wholesale(target),
(true, true) => {}
(true, false) | (false, true) => {
return Err(CoreError::Validation(format!(
"Refusing to update {}: file has a partial Ito marker pair (start={has_start}, end={has_end}). \
Restore both markers manually, or rerun with `--force` to overwrite the file wholesale.",
target.display()
)));
}
}
let _ = update_file_with_markers(
target,
block,
ito_templates::ITO_START_MARKER,
ito_templates::ITO_END_MARKER,
)
.map_err(|e| match e {
markers::FsEditError::Io(io_err) => {
CoreError::io(format!("updating markers in {}", target.display()), io_err)
}
markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
"Failed to update markers in {}: {}",
target.display(),
marker_err
)),
})?;
Ok(())
}
fn write_one(
target: &Path,
rendered_bytes: &[u8],
mode: InstallMode,
opts: &InitOptions,
ownership: FileOwnership,
) -> CoreResult<()> {
if let Some(parent) = target.parent() {
ito_common::io::create_dir_all_std(parent)
.map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
}
if let Ok(text) = std::str::from_utf8(rendered_bytes)
&& let Some(block) = ito_templates::extract_managed_block(text)
{
if target.exists() {
if mode == InstallMode::Init && opts.force {
ito_common::io::write_std(target, rendered_bytes)
.map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
return Ok(());
}
if ownership == FileOwnership::UserOwned {
let updating = mode == InstallMode::Update
|| (mode == InstallMode::Init && (opts.update || opts.upgrade));
if updating {
return Ok(());
}
}
let existing = ito_common::io::read_to_string_std(target)
.map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
let has_markers = existing.contains(ito_templates::ITO_START_MARKER)
&& existing.contains(ito_templates::ITO_END_MARKER);
if !has_markers {
if opts.upgrade {
eprintln!(
"warning: skipping upgrade of {} — Ito markers not found.\n\
To restore managed upgrade support, re-add the markers manually:\n\
\n\
{start}\n\
<ito-managed content>\n\
{end}\n\
\n\
Then re-run `ito init --upgrade`.",
target.display(),
start = ito_templates::ITO_START_MARKER,
end = ito_templates::ITO_END_MARKER,
);
return Ok(());
}
if mode == InstallMode::Init && !opts.update {
return Err(CoreError::Validation(format!(
"Refusing to overwrite existing file without markers: {} (re-run with --force)",
target.display()
)));
}
let existing_has_no_markers = !existing.contains(ito_templates::ITO_START_MARKER)
&& !existing.contains(ito_templates::ITO_END_MARKER);
if existing_has_no_markers {
if template_is_entirely_managed(text)
|| template_has_prefix_outside_markers(text)
{
ito_common::io::write_std(target, rendered_bytes).map_err(|e| {
CoreError::io(format!("writing {}", target.display()), e)
})?;
return Ok(());
}
}
}
update_file_with_markers(
target,
block,
ito_templates::ITO_START_MARKER,
ito_templates::ITO_END_MARKER,
)
.map_err(|e| match e {
markers::FsEditError::Io(io_err) => {
CoreError::io(format!("updating markers in {}", target.display()), io_err)
}
markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
"Failed to update markers in {}: {}",
target.display(),
marker_err
)),
})?;
} else {
ito_common::io::write_std(target, rendered_bytes)
.map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
}
return Ok(());
}
if target.exists() {
match mode {
InstallMode::Init => {
if opts.force {
} else if opts.update {
if ownership == FileOwnership::UserOwned {
return Ok(());
}
} else {
return Err(CoreError::Validation(format!(
"Refusing to overwrite existing file without markers: {} (re-run with --force)",
target.display()
)));
}
}
InstallMode::Update => {
if ownership == FileOwnership::UserOwned {
return Ok(());
}
}
}
}
ito_common::io::write_std(target, rendered_bytes)
.map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
Ok(())
}
fn write_claude_settings(
target: &Path,
rendered_bytes: &[u8],
mode: InstallMode,
opts: &InitOptions,
) -> CoreResult<()> {
if let Some(parent) = target.parent() {
ito_common::io::create_dir_all_std(parent)
.map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
}
if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
return Err(CoreError::Validation(format!(
"Refusing to overwrite existing file without markers: {} (re-run with --force)",
target.display()
)));
}
let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
CoreError::Validation(format!(
"Failed to parse Claude settings template {}: {}",
target.display(),
e
))
})?;
if !target.exists() || (mode == InstallMode::Init && opts.force) {
let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
CoreError::Validation(format!(
"Failed to render Claude settings template {}: {}",
target.display(),
e
))
})?;
bytes.push(b'\n');
ito_common::io::write_std(target, bytes)
.map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
return Ok(());
}
let existing_raw = ito_common::io::read_to_string_std(target)
.map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
return Ok(());
};
merge_json_objects(&mut existing_value, &template_value);
let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
CoreError::Validation(format!(
"Failed to render merged Claude settings {}: {}",
target.display(),
e
))
})?;
merged.push(b'\n');
ito_common::io::write_std(target, merged)
.map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
Ok(())
}
fn merge_json_objects(existing: &mut Value, template: &Value) {
let Value::Object(template_map) = template else {
*existing = template.clone();
return;
};
if !existing.is_object() {
*existing = Value::Object(Map::new());
}
let Some(existing_map) = existing.as_object_mut() else {
return;
};
for (key, template_value) in template_map {
if let Some(existing_value) = existing_map.get_mut(key) {
merge_json_values(existing_value, template_value);
} else {
existing_map.insert(key.clone(), template_value.clone());
}
}
}
fn merge_json_values(existing: &mut Value, template: &Value) {
match (existing, template) {
(Value::Object(existing_map), Value::Object(template_map)) => {
for (key, template_value) in template_map {
if let Some(existing_value) = existing_map.get_mut(key) {
merge_json_values(existing_value, template_value);
} else {
existing_map.insert(key.clone(), template_value.clone());
}
}
}
(Value::Array(existing_items), Value::Array(template_items)) => {
for template_item in template_items {
if !existing_items.contains(template_item) {
existing_items.push(template_item.clone());
}
}
}
(existing_value, template_value) => *existing_value = template_value.clone(),
}
}
fn install_adapter_files(
project_root: &Path,
mode: InstallMode,
opts: &InitOptions,
worktree_ctx: Option<&WorktreeTemplateContext>,
) -> CoreResult<()> {
for tool in &opts.tools {
match tool.as_str() {
TOOL_OPENCODE => {
let config_dir = project_root.join(".opencode");
let manifests = crate::distribution::opencode_manifests(&config_dir);
crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
}
TOOL_CLAUDE => {
let manifests = crate::distribution::claude_manifests(project_root);
crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
}
TOOL_CODEX => {
let manifests = crate::distribution::codex_manifests(project_root);
crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
}
TOOL_GITHUB_COPILOT => {
let manifests = crate::distribution::github_manifests(project_root);
crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
}
TOOL_PI => {
let manifests = crate::distribution::pi_manifests(project_root);
crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
}
_ => {}
}
}
Ok(())
}
fn install_agent_templates(
project_root: &Path,
mode: InstallMode,
opts: &InitOptions,
) -> CoreResult<()> {
use ito_templates::agents::{AgentTier, Harness, default_agent_configs, get_agent_files};
let configs = default_agent_configs();
let tool_harness_map = [
(TOOL_OPENCODE, Harness::OpenCode),
(TOOL_CLAUDE, Harness::ClaudeCode),
(TOOL_CODEX, Harness::Codex),
(TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
(TOOL_PI, Harness::Pi),
];
for (tool_id, harness) in tool_harness_map {
if !opts.tools.contains(tool_id) {
continue;
}
let agent_dir = project_root.join(harness.project_agent_path());
let files = get_agent_files(harness);
for (rel_path, contents) in files {
let target = agent_dir.join(rel_path);
let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
Some(AgentTier::Quick)
} else if rel_path.contains("ito-general") || rel_path.contains("general") {
Some(AgentTier::General)
} else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
Some(AgentTier::Thinking)
} else {
None
};
let mut config = tier.and_then(|t| configs.get(&(harness, t)));
if config.is_none()
&& let Ok(s) = std::str::from_utf8(contents)
&& s.contains("{{model}}")
{
config = configs.get(&(harness, AgentTier::General));
}
match mode {
InstallMode::Init => {
if target.exists() {
if opts.update {
if let Some(cfg) = config {
update_agent_model_field(&target, &cfg.model)?;
}
continue;
}
if !opts.force {
continue;
}
}
let rendered = render_and_stamp_agent(contents, config, &target);
if let Some(parent) = target.parent() {
ito_common::io::create_dir_all_std(parent).map_err(|e| {
CoreError::io(format!("creating directory {}", parent.display()), e)
})?;
}
ito_common::io::write_std(&target, rendered)
.map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
}
InstallMode::Update => {
if !target.exists() {
let rendered = render_and_stamp_agent(contents, config, &target);
if let Some(parent) = target.parent() {
ito_common::io::create_dir_all_std(parent).map_err(|e| {
CoreError::io(format!("creating directory {}", parent.display()), e)
})?;
}
ito_common::io::write_std(&target, rendered).map_err(|e| {
CoreError::io(format!("writing {}", target.display()), e)
})?;
} else if let Some(cfg) = config {
update_agent_model_field(&target, &cfg.model)?;
refresh_agent_version_stamp(&target)?;
}
}
}
}
}
Ok(())
}
fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
let content = ito_common::io::read_to_string_or_default(path);
if !content.starts_with("---") {
return Ok(());
}
let rest = &content[3..];
let Some(end_idx) = rest.find("\n---") else {
return Ok(());
};
let frontmatter = &rest[..end_idx];
let body = &rest[end_idx + 4..];
let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
let updated = format!("---{}\n---{}", updated_frontmatter, body);
ito_common::io::write_std(path, updated)
.map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
Ok(())
}
fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
let mut found = false;
for line in &mut lines {
if line.trim_start().starts_with("model:") {
*line = format!("model: \"{}\"", new_model);
found = true;
break;
}
}
if !found {
lines.push(format!("model: \"{}\"", new_model));
}
lines.join("\n")
}
#[cfg(test)]
mod json_tests;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gitignore_created_when_missing() {
let td = tempfile::tempdir().unwrap();
ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
assert_eq!(s, ".ito/session.json\n");
}
#[test]
fn gitignore_noop_when_already_present() {
let td = tempfile::tempdir().unwrap();
std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
assert_eq!(s, ".ito/session.json\n");
}
#[test]
fn gitignore_does_not_duplicate_on_repeated_calls() {
let td = tempfile::tempdir().unwrap();
std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
assert_eq!(s, "node_modules\n.ito/session.json\n");
}
#[test]
fn gitignore_audit_session_added() {
let td = tempfile::tempdir().unwrap();
ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
assert!(s.contains(".ito/.state/audit/.session"));
}
#[test]
fn gitignore_both_session_entries() {
let td = tempfile::tempdir().unwrap();
ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
assert!(s.contains(".ito/session.json"));
assert!(s.contains(".ito/.state/audit/.session"));
}
#[test]
fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
let td = tempfile::tempdir().unwrap();
std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
assert_eq!(s, "node_modules\n.ito/session.json\n");
}
#[test]
fn gitignore_legacy_audit_events_unignore_removed() {
let td = tempfile::tempdir().unwrap();
std::fs::write(
td.path().join(".gitignore"),
".ito/.state/\n!.ito/.state/audit/\n",
)
.unwrap();
remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
assert_eq!(s, ".ito/.state/\n");
}
#[test]
fn gitignore_legacy_audit_events_unignore_noop_when_absent() {
let td = tempfile::tempdir().unwrap();
std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
assert_eq!(s, ".ito/.state/\n");
}
#[test]
fn gitignore_full_audit_setup() {
let td = tempfile::tempdir().unwrap();
std::fs::write(
td.path().join(".gitignore"),
".ito/.state/\n!.ito/.state/audit/\n",
)
.unwrap();
ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
assert!(s.contains(".ito/.state/audit/.session"));
assert!(!s.contains("!.ito/.state/audit/"));
}
#[test]
fn gitignore_ignores_local_configs() {
let td = tempfile::tempdir().unwrap();
ensure_repo_gitignore_ignores_local_configs(td.path(), ".ito").unwrap();
let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
assert!(s.contains(".ito/config.local.json"));
assert!(s.contains(".local/ito/config.json"));
}
#[test]
fn gitignore_exact_line_matching_trims_whitespace() {
assert!(gitignore_has_exact_line(" foo \nbar\n", "foo"));
assert!(!gitignore_has_exact_line("foo\n", "bar"));
}
#[test]
fn should_install_project_rel_filters_by_tool_id() {
let mut tools = BTreeSet::new();
tools.insert(TOOL_OPENCODE.to_string());
assert!(should_install_project_rel("AGENTS.md", &tools));
assert!(should_install_project_rel(".ito/config.json", &tools));
assert!(should_install_project_rel(".opencode/config.json", &tools));
assert!(!should_install_project_rel(".claude/settings.json", &tools));
assert!(!should_install_project_rel(".codex/config.json", &tools));
assert!(!should_install_project_rel(
".github/workflows/x.yml",
&tools
));
assert!(!should_install_project_rel(".pi/settings.json", &tools));
}
#[test]
fn should_install_project_rel_filters_pi() {
let mut tools = BTreeSet::new();
tools.insert(TOOL_PI.to_string());
assert!(should_install_project_rel(".pi/settings.json", &tools));
assert!(should_install_project_rel(
".pi/extensions/ito-skills.ts",
&tools
));
assert!(should_install_project_rel("AGENTS.md", &tools));
assert!(should_install_project_rel(".ito/config.json", &tools));
assert!(!should_install_project_rel(".opencode/config.json", &tools));
assert!(!should_install_project_rel(".claude/settings.json", &tools));
assert!(!should_install_project_rel(".codex/config.json", &tools));
}
#[test]
fn release_tag_is_prefixed_with_v() {
let tag = release_tag();
assert!(tag.starts_with('v'));
}
#[test]
fn update_model_in_yaml_replaces_or_inserts() {
let yaml = "name: test\nmodel: \"old\"\n";
let updated = update_model_in_yaml(yaml, "new");
assert!(updated.contains("model: \"new\""));
let yaml = "name: test\n";
let updated = update_model_in_yaml(yaml, "new");
assert!(updated.contains("model: \"new\""));
}
#[test]
fn update_agent_model_field_updates_frontmatter_when_present() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("agent.md");
std::fs::write(&path, "---\nname: test\nmodel: \"old\"\n---\nbody\n").unwrap();
update_agent_model_field(&path, "new").unwrap();
let s = std::fs::read_to_string(&path).unwrap();
assert!(s.contains("model: \"new\""));
let path = td.path().join("no-frontmatter.md");
std::fs::write(&path, "no frontmatter\n").unwrap();
update_agent_model_field(&path, "newer").unwrap();
let s = std::fs::read_to_string(&path).unwrap();
assert_eq!(s, "no frontmatter\n");
}
#[test]
fn write_one_non_marker_files_skip_on_init_update_mode() {
let td = tempfile::tempdir().unwrap();
let target = td.path().join("plain.txt");
std::fs::write(&target, "existing").unwrap();
let opts = InitOptions::new(BTreeSet::new(), false, true);
write_one(
&target,
b"new",
InstallMode::Init,
&opts,
FileOwnership::UserOwned,
)
.unwrap();
let s = std::fs::read_to_string(&target).unwrap();
assert_eq!(s, "existing");
}
#[test]
fn write_one_non_marker_ito_managed_files_overwrite_on_init_update_mode() {
let td = tempfile::tempdir().unwrap();
let target = td.path().join("plain.txt");
std::fs::write(&target, "existing").unwrap();
let opts = InitOptions::new(BTreeSet::new(), false, true);
write_one(
&target,
b"new",
InstallMode::Init,
&opts,
FileOwnership::ItoManaged,
)
.unwrap();
let s = std::fs::read_to_string(&target).unwrap();
assert_eq!(s, "new");
}
#[test]
fn write_one_non_marker_user_owned_files_preserve_on_update_mode() {
let td = tempfile::tempdir().unwrap();
let target = td.path().join("plain.txt");
std::fs::write(&target, "existing").unwrap();
let opts = InitOptions::new(BTreeSet::new(), false, true);
write_one(
&target,
b"new",
InstallMode::Update,
&opts,
FileOwnership::UserOwned,
)
.unwrap();
let s = std::fs::read_to_string(&target).unwrap();
assert_eq!(s, "existing");
}
#[test]
fn write_one_marker_managed_files_refuse_overwrite_without_markers() {
let td = tempfile::tempdir().unwrap();
let target = td.path().join("managed.md");
std::fs::write(&target, "existing without markers\n").unwrap();
let template = format!(
"before\n{}\nmanaged\n{}\nafter\n",
ito_templates::ITO_START_MARKER,
ito_templates::ITO_END_MARKER
);
let opts = InitOptions::new(BTreeSet::new(), false, false);
let err = write_one(
&target,
template.as_bytes(),
InstallMode::Init,
&opts,
FileOwnership::ItoManaged,
)
.unwrap_err();
assert!(err.to_string().contains("Refusing to overwrite"));
}
#[test]
fn write_one_marker_managed_files_update_existing_markers() {
let td = tempfile::tempdir().unwrap();
let target = td.path().join("managed.md");
let existing = format!(
"before\n{}\nold\n{}\nafter\n",
ito_templates::ITO_START_MARKER,
ito_templates::ITO_END_MARKER
);
std::fs::write(&target, existing).unwrap();
let template = format!(
"before\n{}\nnew\n{}\nafter\n",
ito_templates::ITO_START_MARKER,
ito_templates::ITO_END_MARKER
);
let opts = InitOptions::new(BTreeSet::new(), false, false);
write_one(
&target,
template.as_bytes(),
InstallMode::Init,
&opts,
FileOwnership::ItoManaged,
)
.unwrap();
let s = std::fs::read_to_string(&target).unwrap();
assert!(s.contains("new"));
assert!(!s.contains("old"));
}
#[test]
fn write_one_marker_managed_files_error_when_markers_missing_in_update_mode() {
let td = tempfile::tempdir().unwrap();
let target = td.path().join("managed.md");
std::fs::write(
&target,
format!(
"{}\nexisting without end marker\n",
ito_templates::ITO_START_MARKER
),
)
.unwrap();
let template = format!(
"before\n{}\nmanaged\n{}\nafter\n",
ito_templates::ITO_START_MARKER,
ito_templates::ITO_END_MARKER
);
let opts = InitOptions::new(BTreeSet::new(), false, true);
let err = write_one(
&target,
template.as_bytes(),
InstallMode::Init,
&opts,
FileOwnership::ItoManaged,
)
.unwrap_err();
assert!(err.to_string().contains("Failed to update markers"));
}
}