Codex Handbook
core/src/config/edit.rs 990 lines
use crate::path_utils::resolve_symlink_write_paths;use crate::path_utils::write_atomically;use anyhow::Context;use codex_config::CONFIG_TOML_FILE;use codex_config::types::McpServerConfig;use codex_config::types::SessionPickerViewMode;use codex_config::types::ToolSuggestDisabledTool;use codex_features::FEATURES;use codex_protocol::config_types::Personality;use codex_protocol::config_types::ServiceTier;use codex_protocol::config_types::TrustLevel;use codex_protocol::openai_models::ReasoningEffort;use std::collections::BTreeMap;use std::collections::HashMap;use std::collections::HashSet;use std::path::Path;use std::path::PathBuf;use tokio::task;use toml_edit::ArrayOfTables;use toml_edit::DocumentMut;use toml_edit::Item as TomlItem;use toml_edit::Table as TomlTable;use toml_edit::value;const NOTICE_TABLE_KEY: &str = "notice";mod document_helpers;/// Discrete config mutations supported by the persistence engine.#[derive(Clone, Debug)]pub enum ConfigEdit {    /// Update the active (or default) model selection and optional reasoning effort.    SetModel {        model: Option<String>,        effort: Option<ReasoningEffort>,    },    /// Update the service tier preference for future turns.    SetServiceTier { service_tier: Option<String> },    /// Update the active (or default) model personality.    SetModelPersonality { personality: Option<Personality> },    /// Toggle the acknowledgement flag under `[notice]`.    SetNoticeHideFullAccessWarning(bool),    /// Toggle the Windows world-writable directories warning acknowledgement flag.    SetNoticeHideWorldWritableWarning(bool),    /// Toggle the rate limit model nudge acknowledgement flag.    SetNoticeHideRateLimitModelNudge(bool),    /// Toggle the model migration prompt acknowledgement flag.    SetNoticeHideModelMigrationPrompt(String, bool),    /// Toggle the home external config migration prompt acknowledgement flag.    SetNoticeHideExternalConfigMigrationPromptHome(bool),    /// Record when the home external config migration prompt was last shown.    SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(i64),    /// Toggle the project external config migration prompt acknowledgement flag.    SetNoticeHideExternalConfigMigrationPromptProject(String, bool),    /// Record when the project external config migration prompt was last shown.    SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(String, i64),    /// Record that a migration prompt was shown for an old->new model mapping.    RecordModelMigrationSeen { from: String, to: String },    /// Replace the entire `[mcp_servers]` table.    ReplaceMcpServers(BTreeMap<String, McpServerConfig>),    /// Add a disabled tool suggestion under `[tool_suggest].disabled_tools`.    AddToolSuggestDisabledTool(ToolSuggestDisabledTool),    /// Set or clear a skill config entry under `[[skills.config]]` by path.    SetSkillConfig { path: PathBuf, enabled: bool },    /// Set or clear a skill config entry under `[[skills.config]]` by name.    SetSkillConfigByName { name: String, enabled: bool },    /// Set trust_level under `[projects."<path>"]`,    /// migrating inline tables to explicit tables.    SetProjectTrustLevel { path: PathBuf, level: TrustLevel },    /// Set the value stored at the exact dotted path.    SetPath {        segments: Vec<String>,        value: TomlItem,    },    /// Remove the value stored at the exact dotted path.    ClearPath { segments: Vec<String> },}#[derive(Clone, Debug, PartialEq, Eq)]enum SkillConfigSelector {    Name(String),    Path(PathBuf),}/// Produces a config edit that sets `[tui].theme = "<name>"`.pub fn syntax_theme_edit(name: &str) -> ConfigEdit {    ConfigEdit::SetPath {        segments: vec!["tui".to_string(), "theme".to_string()],        value: value(name.to_string()),    }}/// Produces a config edit that sets [tui].pet = "<name>".pub fn tui_pet_edit(name: &str) -> ConfigEdit {    ConfigEdit::SetPath {        segments: vec!["tui".to_string(), "pet".to_string()],        value: value(name.to_string()),    }}/// Produces a config edit that sets `[tui].session_picker_view = "<mode>"`.pub fn session_picker_view_edit(mode: SessionPickerViewMode) -> ConfigEdit {    ConfigEdit::SetPath {        segments: vec!["tui".to_string(), "session_picker_view".to_string()],        value: value(mode.to_string()),    }}/// Produces a config edit that sets `[tui].status_line` to an explicit ordered list.////// The array is written even when it is empty so "hide the status line" stays/// distinct from "unset, so use defaults".pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {    let array = items.iter().cloned().collect::<toml_edit::Array>();    ConfigEdit::SetPath {        segments: vec!["tui".to_string(), "status_line".to_string()],        value: TomlItem::Value(array.into()),    }}/// Produces a config edit that sets `[tui].status_line_use_colors`.pub fn status_line_use_colors_edit(enabled: bool) -> ConfigEdit {    ConfigEdit::SetPath {        segments: vec!["tui".to_string(), "status_line_use_colors".to_string()],        value: value(enabled),    }}/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list.////// The array is written even when it is empty so "disabled title updates" stays/// distinct from "unset, so use defaults".pub fn terminal_title_items_edit(items: &[String]) -> ConfigEdit {    let array = items.iter().cloned().collect::<toml_edit::Array>();    ConfigEdit::SetPath {        segments: vec!["tui".to_string(), "terminal_title".to_string()],        value: TomlItem::Value(array.into()),    }}fn keymap_binding_value(keys: &[String]) -> TomlItem {    if let [key] = keys {        value(key.to_string())    } else {        let array = keys.iter().cloned().collect::<toml_edit::Array>();        TomlItem::Value(array.into())    }}/// Produces a config edit that replaces one root-level TUI keymap binding list.pub fn keymap_bindings_edit(context: &str, action: &str, keys: &[String]) -> ConfigEdit {    ConfigEdit::SetPath {        segments: vec![            "tui".to_string(),            "keymap".to_string(),            context.to_string(),            action.to_string(),        ],        value: keymap_binding_value(keys),    }}/// Produces a config edit that replaces one root-level TUI keymap binding.pub fn keymap_binding_edit(context: &str, action: &str, key: &str) -> ConfigEdit {    keymap_bindings_edit(context, action, &[key.to_string()])}/// Produces a config edit that removes one root-level TUI keymap binding.pub fn keymap_binding_clear_edit(context: &str, action: &str) -> ConfigEdit {    ConfigEdit::ClearPath {        segments: vec![            "tui".to_string(),            "keymap".to_string(),            context.to_string(),            action.to_string(),        ],    }}pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) -> Vec<ConfigEdit> {    let mut shown_count_entries: Vec<_> = shown_count.iter().collect();    shown_count_entries.sort_unstable_by_key(|(left, _)| *left);    let mut edits = vec![ConfigEdit::ClearPath {        segments: vec!["tui".to_string(), "model_availability_nux".to_string()],    }];    for (model_slug, count) in shown_count_entries {        edits.push(ConfigEdit::SetPath {            segments: vec![                "tui".to_string(),                "model_availability_nux".to_string(),                model_slug.clone(),            ],            value: value(i64::from(*count)),        });    }    edits}struct ConfigDocument {    doc: DocumentMut,}#[derive(Copy, Clone)]enum TraversalMode {    Create,    Existing,}impl ConfigDocument {    fn new(doc: DocumentMut) -> Self {        Self { doc }    }    fn apply(&mut self, edit: &ConfigEdit) -> anyhow::Result<bool> {        match edit {            ConfigEdit::SetModel { model, effort } => Ok({                let mut mutated = false;                mutated |= self.write_optional_value(                    &["model"],                    model.as_ref().map(|model_value| value(model_value.clone())),                );                mutated |= self.write_optional_value(                    &["model_reasoning_effort"],                    effort.as_ref().map(|effort| value(effort.to_string())),                );                mutated            }),            ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_optional_value(                &["service_tier"],                service_tier.as_ref().map(|service_tier| {                    // Keep the legacy config spelling stable. Runtime values use                    // `priority`, but config.toml continues to store it as `fast`.                    let config_value = match ServiceTier::from_request_value(service_tier) {                        Some(ServiceTier::Fast) => "fast",                        Some(ServiceTier::Flex) => "flex",                        None => service_tier.as_str(),                    };                    value(config_value)                }),            )),            ConfigEdit::SetModelPersonality { personality } => Ok(self.write_optional_value(                &["personality"],                personality.map(|personality| value(personality.to_string())),            )),            ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value(                &[NOTICE_TABLE_KEY, "hide_full_access_warning"],                value(*acknowledged),            )),            ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value(                &[NOTICE_TABLE_KEY, "hide_world_writable_warning"],                value(*acknowledged),            )),            ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(                &[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"],                value(*acknowledged),            )),            ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => {                Ok(self.write_value(                    &[NOTICE_TABLE_KEY, migration_config.as_str()],                    value(*acknowledged),                ))            }            ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self                .write_value(                    &[                        NOTICE_TABLE_KEY,                        "external_config_migration_prompts",                        "home",                    ],                    value(*acknowledged),                )),            ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => {                Ok(self.write_value(                    &[                        NOTICE_TABLE_KEY,                        "external_config_migration_prompts",                        "home_last_prompted_at",                    ],                    value(*timestamp),                ))            }            ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(                project,                acknowledged,            ) => Ok(self.write_value(                &[                    NOTICE_TABLE_KEY,                    "external_config_migration_prompts",                    "projects",                    project.as_str(),                ],                value(*acknowledged),            )),            ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(                project,                timestamp,            ) => Ok(self.write_value(                &[                    NOTICE_TABLE_KEY,                    "external_config_migration_prompts",                    "project_last_prompted_at",                    project.as_str(),                ],                value(*timestamp),            )),            ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value(                &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()],                value(to.clone()),            )),            ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)),            ConfigEdit::AddToolSuggestDisabledTool(disabled_tool) => {                Ok(self.add_tool_suggest_disabled_tool(disabled_tool))            }            ConfigEdit::SetSkillConfig { path, enabled } => {                Ok(self.set_skill_config(SkillConfigSelector::Path(path.clone()), *enabled))            }            ConfigEdit::SetSkillConfigByName { name, enabled } => {                Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled))            }            ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())),            ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)),            ConfigEdit::SetProjectTrustLevel { path, level } => {                // Delegate to the existing, tested logic in config.rs to                // ensure tables are explicit and migration is preserved.                crate::config::set_project_trust_level_inner(                    &mut self.doc,                    path.as_path(),                    *level,                )?;                Ok(true)            }        }    }    fn write_optional_value(&mut self, segments: &[&str], value: Option<TomlItem>) -> bool {        match value {            Some(item) => self.write_value(segments, item),            None => self.clear(segments),        }    }    fn write_value(&mut self, segments: &[&str], value: TomlItem) -> bool {        let resolved = segments            .iter()            .map(|segment| (*segment).to_string())            .collect::<Vec<_>>();        self.insert(&resolved, value)    }    fn clear(&mut self, segments: &[&str]) -> bool {        let resolved = segments            .iter()            .map(|segment| (*segment).to_string())            .collect::<Vec<_>>();        self.remove(&resolved)    }    fn add_tool_suggest_disabled_tool(&mut self, disabled_tool: &ToolSuggestDisabledTool) -> bool {        let disabled_tools_item = self            .doc            .get("tool_suggest")            .and_then(|item| item.as_table_like())            .and_then(|table| table.get("disabled_tools"));        let existing_from_array = disabled_tools_item            .and_then(|item| item.as_value())            .and_then(|value| value.as_array())            .into_iter()            .flat_map(|array| array.iter())            .filter_map(document_helpers::parse_tool_suggest_disabled_tool);        let existing_from_tables = disabled_tools_item            .and_then(|item| match item {                TomlItem::ArrayOfTables(array) => Some(array),                _ => None,            })            .into_iter()            .flat_map(|array| array.iter())            .filter_map(document_helpers::parse_tool_suggest_disabled_tool_table);        let mut seen = HashSet::new();        let disabled_tools = existing_from_array            .chain(existing_from_tables)            .chain(std::iter::once(disabled_tool.clone()))            .filter_map(|disabled_tool| disabled_tool.normalized())            .filter(|disabled_tool| seen.insert(disabled_tool.clone()))            .collect::<Vec<_>>();        self.write_value(            &["tool_suggest", "disabled_tools"],            document_helpers::tool_suggest_disabled_tools_value(&disabled_tools),        )    }    fn clear_owned(&mut self, segments: &[String]) -> bool {        self.remove(segments)    }    fn replace_mcp_servers(&mut self, servers: &BTreeMap<String, McpServerConfig>) -> bool {        if servers.is_empty() {            return self.clear(&["mcp_servers"]);        }        let root = self.doc.as_table_mut();        if !root.contains_key("mcp_servers") {            root.insert(                "mcp_servers",                TomlItem::Table(document_helpers::new_implicit_table()),            );        }        let Some(item) = root.get_mut("mcp_servers") else {            return false;        };        if document_helpers::ensure_table_for_write(item).is_none() {            *item = TomlItem::Table(document_helpers::new_implicit_table());        }        let Some(table) = item.as_table_mut() else {            return false;        };        let keys_to_remove: Vec<String> = table            .iter()            .map(|(key, _)| key.to_string())            .filter(|key| !servers.contains_key(key.as_str()))            .collect();        for key in keys_to_remove {            table.remove(&key);        }        for (name, config) in servers {            if let Some(existing) = table.get_mut(name.as_str()) {                if let TomlItem::Value(value) = existing                    && let Some(inline) = value.as_inline_table_mut()                {                    let replacement = document_helpers::serialize_mcp_server_inline(config);                    document_helpers::merge_inline_table(inline, replacement);                } else {                    *existing = document_helpers::serialize_mcp_server(config);                }            } else {                table.insert(name, document_helpers::serialize_mcp_server(config));            }        }        true    }    fn set_skill_config(&mut self, selector: SkillConfigSelector, enabled: bool) -> bool {        let selector = match selector {            SkillConfigSelector::Name(name) => SkillConfigSelector::Name(name.trim().to_string()),            SkillConfigSelector::Path(path) => {                SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(&path)))            }        };        if matches!(&selector, SkillConfigSelector::Name(name) if name.is_empty()) {            return false;        }        let mut remove_skills_table = false;        let mut mutated = false;        {            let root = self.doc.as_table_mut();            let skills_item = match root.get_mut("skills") {                Some(item) => item,                None => {                    if enabled {                        return false;                    }                    root.insert(                        "skills",                        TomlItem::Table(document_helpers::new_implicit_table()),                    );                    let Some(item) = root.get_mut("skills") else {                        return false;                    };                    item                }            };            if document_helpers::ensure_table_for_write(skills_item).is_none() {                if enabled {                    return false;                }                *skills_item = TomlItem::Table(document_helpers::new_implicit_table());            }            let Some(skills_table) = skills_item.as_table_mut() else {                return false;            };            let config_item = match skills_table.get_mut("config") {                Some(item) => item,                None => {                    if enabled {                        return false;                    }                    skills_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new()));                    let Some(item) = skills_table.get_mut("config") else {                        return false;                    };                    item                }            };            if !matches!(config_item, TomlItem::ArrayOfTables(_)) {                if enabled {                    return false;                }                *config_item = TomlItem::ArrayOfTables(ArrayOfTables::new());            }            let TomlItem::ArrayOfTables(overrides) = config_item else {                return false;            };            let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| {                skill_config_selector_from_table(table)                    .filter(|value| value == &selector)                    .map(|_| idx)            });            if enabled {                if let Some(index) = existing_index {                    overrides.remove(index);                    mutated = true;                    if overrides.is_empty() {                        skills_table.remove("config");                        if skills_table.is_empty() {                            remove_skills_table = true;                        }                    }                }            } else if let Some(index) = existing_index {                for (idx, table) in overrides.iter_mut().enumerate() {                    if idx == index {                        write_skill_config_selector(table, &selector);                        table["enabled"] = value(false);                        mutated = true;                        break;                    }                }            } else {                let mut entry = TomlTable::new();                entry.set_implicit(false);                write_skill_config_selector(&mut entry, &selector);                entry["enabled"] = value(false);                overrides.push(entry);                mutated = true;            }        }        if remove_skills_table {            let root = self.doc.as_table_mut();            root.remove("skills");        }        mutated    }    fn insert(&mut self, segments: &[String], value: TomlItem) -> bool {        let Some((last, parents)) = segments.split_last() else {            return false;        };        let Some(parent) = self.descend(parents, TraversalMode::Create) else {            return false;        };        let mut value = value;        if let Some(existing) = parent.get(last) {            Self::preserve_decor(existing, &mut value);        }        parent[last] = value;        true    }    fn remove(&mut self, segments: &[String]) -> bool {        let Some((last, parents)) = segments.split_last() else {            return false;        };        let Some(parent) = self.descend(parents, TraversalMode::Existing) else {            return false;        };        parent.remove(last).is_some()    }    fn descend(&mut self, segments: &[String], mode: TraversalMode) -> Option<&mut TomlTable> {        let mut current = self.doc.as_table_mut();        for segment in segments {            match mode {                TraversalMode::Create => {                    if !current.contains_key(segment.as_str()) {                        current.insert(                            segment.as_str(),                            TomlItem::Table(document_helpers::new_implicit_table()),                        );                    }                    let item = current.get_mut(segment.as_str())?;                    current = document_helpers::ensure_table_for_write(item)?;                }                TraversalMode::Existing => {                    let item = current.get_mut(segment.as_str())?;                    current = document_helpers::ensure_table_for_read(item)?;                }            }        }        Some(current)    }    fn preserve_decor(existing: &TomlItem, replacement: &mut TomlItem) {        match (existing, replacement) {            (TomlItem::Table(existing_table), TomlItem::Table(replacement_table)) => {                replacement_table                    .decor_mut()                    .clone_from(existing_table.decor());                for (key, existing_item) in existing_table.iter() {                    if let (Some(existing_key), Some(mut replacement_key)) =                        (existing_table.key(key), replacement_table.key_mut(key))                    {                        replacement_key                            .leaf_decor_mut()                            .clone_from(existing_key.leaf_decor());                        replacement_key                            .dotted_decor_mut()                            .clone_from(existing_key.dotted_decor());                    }                    if let Some(replacement_item) = replacement_table.get_mut(key) {                        Self::preserve_decor(existing_item, replacement_item);                    }                }            }            (TomlItem::Value(existing_value), TomlItem::Value(replacement_value)) => {                replacement_value                    .decor_mut()                    .clone_from(existing_value.decor());            }            _ => {}        }    }}fn normalize_skill_config_path(path: &Path) -> String {    dunce::canonicalize(path)        .unwrap_or_else(|_| path.to_path_buf())        .to_string_lossy()        .to_string()}fn skill_config_selector_from_table(table: &TomlTable) -> Option<SkillConfigSelector> {    let path = table        .get("path")        .and_then(|item| item.as_str())        .map(Path::new)        .map(|path| SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(path))));    let name = table        .get("name")        .and_then(|item| item.as_str())        .map(str::trim)        .filter(|name| !name.is_empty())        .map(|name| SkillConfigSelector::Name(name.to_string()));    match (path, name) {        (Some(selector), None) | (None, Some(selector)) => Some(selector),        _ => None,    }}fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSelector) {    match selector {        SkillConfigSelector::Name(name) => {            table.remove("path");            table["name"] = value(name.clone());        }        SkillConfigSelector::Path(path) => {            table.remove("name");            table["path"] = value(path.to_string_lossy().to_string());        }    }}/// Persist edits using a blocking strategy.pub fn apply_blocking(codex_home: &Path, edits: &[ConfigEdit]) -> anyhow::Result<()> {    let config_path = codex_home.join(CONFIG_TOML_FILE);    apply_blocking_to_resolved_file(&config_path, edits)}fn apply_blocking_to_resolved_file(    resolved_config_file: &Path,    edits: &[ConfigEdit],) -> anyhow::Result<()> {    if edits.is_empty() {        return Ok(());    }    let write_paths = resolve_symlink_write_paths(resolved_config_file)?;    let serialized = match write_paths.read_path {        Some(path) => match std::fs::read_to_string(&path) {            Ok(contents) => contents,            Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),            Err(err) => return Err(err.into()),        },        None => String::new(),    };    let doc = if serialized.is_empty() {        DocumentMut::new()    } else {        serialized.parse::<DocumentMut>()?    };    let mut document = ConfigDocument::new(doc);    let mut mutated = false;    for edit in edits {        mutated |= document.apply(edit)?;    }    if !mutated {        return Ok(());    }    write_atomically(&write_paths.write_path, &document.doc.to_string()).with_context(|| {        format!(            "failed to persist config at {}",            write_paths.write_path.display()        )    })?;    Ok(())}/// Persist edits asynchronously by offloading the blocking writer.///pub async fn apply(codex_home: &Path, edits: Vec<ConfigEdit>) -> anyhow::Result<()> {    let codex_home = codex_home.to_path_buf();    let config_path = codex_home.join(CONFIG_TOML_FILE);    task::spawn_blocking(move || apply_blocking_to_resolved_file(&config_path, &edits))        .await        .context("config persistence task panicked")?}/// Fluent builder to batch config edits and apply them atomically.#[derive(Default)]pub struct ConfigEditsBuilder {    config_path: PathBuf,    edits: Vec<ConfigEdit>,}impl ConfigEditsBuilder {    pub fn new(codex_home: &Path) -> Self {        Self::for_config_path(&codex_home.join(CONFIG_TOML_FILE))    }    pub fn for_config(config: &crate::config::Config) -> Self {        let config_path = config            .config_layer_stack            .get_user_config_file()            .map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf)            .unwrap_or_else(|| config.codex_home.join(CONFIG_TOML_FILE).to_path_buf());        Self::for_config_path(&config_path)    }    pub fn for_config_path(config_path: &Path) -> Self {        Self {            config_path: config_path.to_path_buf(),            edits: Vec::new(),        }    }    pub fn set_model(mut self, model: Option<&str>, effort: Option<ReasoningEffort>) -> Self {        self.edits.push(ConfigEdit::SetModel {            model: model.map(ToOwned::to_owned),            effort,        });        self    }    pub fn set_service_tier(mut self, service_tier: Option<String>) -> Self {        self.edits.push(ConfigEdit::SetServiceTier { service_tier });        self    }    pub fn set_personality(mut self, personality: Option<Personality>) -> Self {        self.edits            .push(ConfigEdit::SetModelPersonality { personality });        self    }    pub fn set_hide_full_access_warning(mut self, acknowledged: bool) -> Self {        self.edits            .push(ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged));        self    }    pub fn set_hide_world_writable_warning(mut self, acknowledged: bool) -> Self {        self.edits            .push(ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged));        self    }    pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self {        self.edits            .push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged));        self    }    pub fn set_hide_model_migration_prompt(mut self, model: &str, acknowledged: bool) -> Self {        self.edits            .push(ConfigEdit::SetNoticeHideModelMigrationPrompt(                model.to_string(),                acknowledged,            ));        self    }    pub fn set_hide_external_config_migration_prompt_home(mut self, acknowledged: bool) -> Self {        self.edits            .push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(                acknowledged,            ));        self    }    pub fn set_hide_external_config_migration_prompt_project(        mut self,        project: &str,        acknowledged: bool,    ) -> Self {        self.edits.push(            ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(                project.to_string(),                acknowledged,            ),        );        self    }    pub fn record_model_migration_seen(mut self, from: &str, to: &str) -> Self {        self.edits.push(ConfigEdit::RecordModelMigrationSeen {            from: from.to_string(),            to: to.to_string(),        });        self    }    pub fn set_model_availability_nux_count(mut self, shown_count: &HashMap<String, u32>) -> Self {        self.edits            .extend(model_availability_nux_count_edits(shown_count));        self    }    pub fn replace_mcp_servers(mut self, servers: &BTreeMap<String, McpServerConfig>) -> Self {        self.edits            .push(ConfigEdit::ReplaceMcpServers(servers.clone()));        self    }    pub fn set_project_trust_level<P: Into<PathBuf>>(        mut self,        project_path: P,        trust_level: TrustLevel,    ) -> Self {        self.edits.push(ConfigEdit::SetProjectTrustLevel {            path: project_path.into(),            level: trust_level,        });        self    }    /// Enable or disable a feature flag by key under the `[features]` table.    ///    /// Disabling a default-false feature clears the key instead of    /// persisting `false`, so the config does not pin the feature once it    /// graduates to globally enabled.    pub fn set_feature_enabled(mut self, key: &str, enabled: bool) -> Self {        let segments = vec!["features".to_string(), key.to_string()];        let is_default_false_feature = FEATURES            .iter()            .find(|spec| spec.key == key)            .is_some_and(|spec| !spec.default_enabled);        if enabled || !is_default_false_feature {            self.edits.push(ConfigEdit::SetPath {                segments,                value: value(enabled),            });        } else {            self.edits.push(ConfigEdit::ClearPath { segments });        }        self    }    pub fn set_windows_sandbox_mode(mut self, mode: &str) -> Self {        self.edits.push(ConfigEdit::SetPath {            segments: vec!["windows".to_string(), "sandbox".to_string()],            value: value(mode),        });        self    }    pub fn set_realtime_microphone(mut self, microphone: Option<&str>) -> Self {        let segments = vec!["audio".to_string(), "microphone".to_string()];        match microphone {            Some(microphone) => self.edits.push(ConfigEdit::SetPath {                segments,                value: value(microphone),            }),            None => self.edits.push(ConfigEdit::ClearPath { segments }),        }        self    }    pub fn set_realtime_speaker(mut self, speaker: Option<&str>) -> Self {        let segments = vec!["audio".to_string(), "speaker".to_string()];        match speaker {            Some(speaker) => self.edits.push(ConfigEdit::SetPath {                segments,                value: value(speaker),            }),            None => self.edits.push(ConfigEdit::ClearPath { segments }),        }        self    }    pub fn set_realtime_voice(mut self, voice: Option<&str>) -> Self {        let segments = vec!["realtime".to_string(), "voice".to_string()];        match voice {            Some(voice) => self.edits.push(ConfigEdit::SetPath {                segments,                value: value(voice),            }),            None => self.edits.push(ConfigEdit::ClearPath { segments }),        }        self    }    pub fn clear_legacy_windows_sandbox_keys(mut self) -> Self {        for key in [            "experimental_windows_sandbox",            "elevated_windows_sandbox",            "enable_experimental_windows_sandbox",        ] {            let segments = vec!["features".to_string(), key.to_string()];            self.edits.push(ConfigEdit::ClearPath { segments });        }        self    }    pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self {        self.edits.push(ConfigEdit::SetPath {            segments: vec!["tui".to_string(), "session_picker_view".to_string()],            value: value(mode.to_string()),        });        self    }    pub fn with_edits<I>(mut self, edits: I) -> Self    where        I: IntoIterator<Item = ConfigEdit>,    {        self.edits.extend(edits);        self    }    /// Apply edits on a blocking thread.    pub fn apply_blocking(self) -> anyhow::Result<()> {        apply_blocking_to_resolved_file(&self.config_path, &self.edits)    }    /// Apply edits asynchronously via a blocking offload.    pub async fn apply(self) -> anyhow::Result<()> {        task::spawn_blocking(move || {            apply_blocking_to_resolved_file(&self.config_path, &self.edits)        })        .await        .context("config persistence task panicked")?    }}#[cfg(test)]#[path = "edit_tests.rs"]mod tests;