Codex Handbook
config/src/marketplace_edit.rs 276 lines
use std::fs;use std::io::ErrorKind;use std::path::Path;use toml_edit::DocumentMut;use toml_edit::Item as TomlItem;use toml_edit::Table as TomlTable;use toml_edit::Value as TomlValue;use toml_edit::value;use crate::CONFIG_TOML_FILE;pub struct MarketplaceConfigUpdate<'a> {    pub last_updated: &'a str,    pub last_revision: Option<&'a str>,    pub source_type: &'a str,    pub source: &'a str,    pub ref_name: Option<&'a str>,    pub sparse_paths: &'a [String],}#[derive(Debug, Clone, PartialEq, Eq)]pub enum RemoveMarketplaceConfigOutcome {    Removed,    NotFound,    NameCaseMismatch { configured_name: String },}pub fn record_user_marketplace(    codex_home: &Path,    marketplace_name: &str,    update: &MarketplaceConfigUpdate<'_>,) -> std::io::Result<()> {    let config_path = codex_home.join(CONFIG_TOML_FILE);    let mut doc = read_or_create_document(&config_path)?;    upsert_marketplace(&mut doc, marketplace_name, update);    fs::create_dir_all(codex_home)?;    fs::write(config_path, doc.to_string())}pub fn remove_user_marketplace(codex_home: &Path, marketplace_name: &str) -> std::io::Result<bool> {    let outcome = remove_user_marketplace_config(codex_home, marketplace_name)?;    Ok(outcome == RemoveMarketplaceConfigOutcome::Removed)}pub fn remove_user_marketplace_config(    codex_home: &Path,    marketplace_name: &str,) -> std::io::Result<RemoveMarketplaceConfigOutcome> {    let config_path = codex_home.join(CONFIG_TOML_FILE);    let mut doc = match fs::read_to_string(&config_path) {        Ok(raw) => raw            .parse::<DocumentMut>()            .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?,        Err(err) if err.kind() == ErrorKind::NotFound => {            return Ok(RemoveMarketplaceConfigOutcome::NotFound);        }        Err(err) => return Err(err),    };    let outcome = remove_marketplace(&mut doc, marketplace_name);    if outcome != RemoveMarketplaceConfigOutcome::Removed {        return Ok(outcome);    }    fs::create_dir_all(codex_home)?;    fs::write(config_path, doc.to_string())?;    Ok(RemoveMarketplaceConfigOutcome::Removed)}fn read_or_create_document(config_path: &Path) -> std::io::Result<DocumentMut> {    match fs::read_to_string(config_path) {        Ok(raw) => raw            .parse::<DocumentMut>()            .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)),        Err(err) if err.kind() == ErrorKind::NotFound => Ok(DocumentMut::new()),        Err(err) => Err(err),    }}fn upsert_marketplace(    doc: &mut DocumentMut,    marketplace_name: &str,    update: &MarketplaceConfigUpdate<'_>,) {    let root = doc.as_table_mut();    if !root.contains_key("marketplaces") {        root.insert("marketplaces", TomlItem::Table(new_implicit_table()));    }    let Some(marketplaces_item) = root.get_mut("marketplaces") else {        return;    };    if !marketplaces_item.is_table() {        *marketplaces_item = TomlItem::Table(new_implicit_table());    }    let Some(marketplaces) = marketplaces_item.as_table_mut() else {        return;    };    let mut entry = TomlTable::new();    entry.set_implicit(false);    entry["last_updated"] = value(update.last_updated.to_string());    if let Some(last_revision) = update.last_revision {        entry["last_revision"] = value(last_revision.to_string());    }    entry["source_type"] = value(update.source_type.to_string());    entry["source"] = value(update.source.to_string());    if let Some(ref_name) = update.ref_name {        entry["ref"] = value(ref_name.to_string());    }    if !update.sparse_paths.is_empty() {        entry["sparse_paths"] = TomlItem::Value(TomlValue::Array(            update.sparse_paths.iter().map(String::as_str).collect(),        ));    }    marketplaces.insert(marketplace_name, TomlItem::Table(entry));}fn remove_marketplace(    doc: &mut DocumentMut,    marketplace_name: &str,) -> RemoveMarketplaceConfigOutcome {    let root = doc.as_table_mut();    let Some(marketplaces_item) = root.get_mut("marketplaces") else {        return RemoveMarketplaceConfigOutcome::NotFound;    };    let mut remove_marketplaces = false;    let outcome = match marketplaces_item {        TomlItem::Table(marketplaces) => {            let outcome = if marketplaces.remove(marketplace_name).is_some() {                RemoveMarketplaceConfigOutcome::Removed            } else if let Some(configured_name) =                case_mismatched_key(marketplaces.iter().map(|(key, _)| key), marketplace_name)            {                RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name }            } else {                RemoveMarketplaceConfigOutcome::NotFound            };            remove_marketplaces = marketplaces.is_empty();            outcome        }        TomlItem::Value(value) => {            let Some(marketplaces) = value.as_inline_table_mut() else {                return RemoveMarketplaceConfigOutcome::NotFound;            };            let outcome = if marketplaces.remove(marketplace_name).is_some() {                RemoveMarketplaceConfigOutcome::Removed            } else if let Some(configured_name) =                case_mismatched_key(marketplaces.iter().map(|(key, _)| key), marketplace_name)            {                RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name }            } else {                RemoveMarketplaceConfigOutcome::NotFound            };            remove_marketplaces = marketplaces.is_empty();            outcome        }        _ => RemoveMarketplaceConfigOutcome::NotFound,    };    if outcome == RemoveMarketplaceConfigOutcome::Removed && remove_marketplaces {        root.remove("marketplaces");    }    outcome}fn case_mismatched_key<'a>(    mut keys: impl Iterator<Item = &'a str>,    requested_name: &str,) -> Option<String> {    keys.find(|key| *key != requested_name && key.eq_ignore_ascii_case(requested_name))        .map(str::to_string)}fn new_implicit_table() -> TomlTable {    let mut table = TomlTable::new();    table.set_implicit(true);    table}#[cfg(test)]mod tests {    use super::*;    use pretty_assertions::assert_eq;    use tempfile::TempDir;    #[test]    fn remove_user_marketplace_removes_requested_entry() {        let codex_home = TempDir::new().unwrap();        let update = MarketplaceConfigUpdate {            last_updated: "2026-04-13T00:00:00Z",            last_revision: None,            source_type: "git",            source: "https://github.com/owner/repo.git",            ref_name: Some("main"),            sparse_paths: &[],        };        record_user_marketplace(codex_home.path(), "debug", &update).unwrap();        record_user_marketplace(codex_home.path(), "other", &update).unwrap();        let removed = remove_user_marketplace(codex_home.path(), "debug").unwrap();        assert!(removed);        let config: toml::Value =            toml::from_str(&fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap())                .unwrap();        let marketplaces = config            .get("marketplaces")            .and_then(toml::Value::as_table)            .unwrap();        assert_eq!(marketplaces.len(), 1);        assert!(marketplaces.contains_key("other"));    }    #[test]    fn remove_user_marketplace_returns_false_when_missing() {        let codex_home = TempDir::new().unwrap();        let removed = remove_user_marketplace(codex_home.path(), "debug").unwrap();        assert!(!removed);    }    #[test]    fn remove_user_marketplace_config_reports_case_mismatch() {        let codex_home = TempDir::new().unwrap();        let update = MarketplaceConfigUpdate {            last_updated: "2026-04-13T00:00:00Z",            last_revision: None,            source_type: "git",            source: "https://github.com/owner/repo.git",            ref_name: Some("main"),            sparse_paths: &[],        };        record_user_marketplace(codex_home.path(), "debug", &update).unwrap();        let outcome = remove_user_marketplace_config(codex_home.path(), "Debug").unwrap();        assert_eq!(            outcome,            RemoveMarketplaceConfigOutcome::NameCaseMismatch {                configured_name: "debug".to_string()            }        );    }    #[test]    fn remove_user_marketplace_config_removes_inline_table_entry() {        let codex_home = TempDir::new().unwrap();        fs::write(            codex_home.path().join(CONFIG_TOML_FILE),            r#"marketplaces = {  debug = { source_type = "git", source = "https://github.com/owner/repo.git" },  other = { source_type = "local", source = "/tmp/marketplace" },}"#,        )        .unwrap();        let outcome = remove_user_marketplace_config(codex_home.path(), "debug").unwrap();        assert_eq!(outcome, RemoveMarketplaceConfigOutcome::Removed);        let config: toml::Value =            toml::from_str(&fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap())                .unwrap();        let marketplaces = config            .get("marketplaces")            .and_then(toml::Value::as_table)            .unwrap();        assert_eq!(marketplaces.len(), 1);        assert!(marketplaces.contains_key("other"));    }}