Compare commits

...

2 Commits

Author SHA1 Message Date
Rasmus Rygaard
4e5f10f1b6 Use ConfigStore for local config loading 2026-04-14 17:23:28 -07:00
Rasmus Rygaard
ce0a0d2c60 Add a ConfigStore interface 2026-04-14 17:20:16 -07:00
14 changed files with 630 additions and 65 deletions

15
codex-rs/Cargo.lock generated
View File

@@ -1875,6 +1875,20 @@ dependencies = [
"wildmatch",
]
[[package]]
name = "codex-config-store"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-utils-absolute-path",
"pretty_assertions",
"serde",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml 0.9.11+spec-1.1.0",
]
[[package]]
name = "codex-connectors"
version = "0.0.0"
@@ -1908,6 +1922,7 @@ dependencies = [
"codex-async-utils",
"codex-code-mode",
"codex-config",
"codex-config-store",
"codex-connectors",
"codex-core-skills",
"codex-exec-server",

View File

@@ -23,6 +23,7 @@ members = [
"collaboration-mode-templates",
"connectors",
"config",
"config-store",
"shell-command",
"shell-escalation",
"skills",
@@ -125,6 +126,7 @@ codex-cloud-tasks-client = { path = "cloud-tasks-client" }
codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" }
codex-code-mode = { path = "code-mode" }
codex-config = { path = "config" }
codex-config-store = { path = "config-store" }
codex-connectors = { path = "connectors" }
codex-core = { path = "core" }
codex-core-skills = { path = "core-skills" }

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "config-store",
crate_name = "codex_config_store",
)

View File

@@ -0,0 +1,25 @@
[package]
name = "codex-config-store"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_config_store"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
async-trait = { workspace = true }
codex-utils-absolute-path = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
toml = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -0,0 +1,27 @@
/// Result type returned by config-store operations.
pub type ConfigStoreResult<T> = Result<T, ConfigStoreError>;
/// Error type shared by config-store implementations.
#[derive(Debug, thiserror::Error)]
pub enum ConfigStoreError {
/// The caller supplied invalid request data.
#[error("invalid config-store request: {message}")]
InvalidRequest {
/// User-facing explanation of the invalid request.
message: String,
},
/// A backing source could not be queried.
#[error("config-store read failed: {message}")]
ReadFailed {
/// User-facing explanation of the read failure.
message: String,
},
/// Catch-all for implementation failures that do not fit a more specific category.
#[error("config-store internal error: {message}")]
Internal {
/// User-facing explanation of the implementation failure.
message: String,
},
}

View File

@@ -0,0 +1,21 @@
//! Storage-neutral interfaces for loading config-layer documents.
//!
//! Implementations should report observations from their backing store. Codex config loading
//! remains responsible for applying precedence, project trust, path resolution, requirements, and
//! final layer merging.
//!
//! The request and response types in this crate may cross process or network boundaries. Keep them
//! wire-friendly: prefer primitive fields over Rust-specific error or filesystem types.
mod error;
mod local;
mod store;
mod types;
pub use error::ConfigStoreError;
pub use error::ConfigStoreResult;
pub use local::LocalConfigStore;
pub use store::ConfigDocumentStore;
pub use types::ConfigDocumentErrorSpan;
pub use types::ConfigDocumentRead;
pub use types::ReadConfigDocumentParams;

View File

@@ -0,0 +1,125 @@
use async_trait::async_trait;
use crate::ConfigDocumentErrorSpan;
use crate::ConfigDocumentRead;
use crate::ConfigDocumentStore;
use crate::ConfigStoreResult;
use crate::ReadConfigDocumentParams;
/// Filesystem-backed implementation of [ConfigDocumentStore].
///
/// This implementation reads the requested path from the local filesystem and parses it as TOML.
/// It does not apply config-layer ordering, project trust, relative-path resolution, or fallback
/// behavior for missing documents.
#[derive(Debug, Default, Clone)]
pub struct LocalConfigStore;
#[async_trait]
impl ConfigDocumentStore for LocalConfigStore {
async fn read_config_document(
&self,
params: ReadConfigDocumentParams,
) -> ConfigStoreResult<ConfigDocumentRead> {
match tokio::fs::read_to_string(params.path.as_path()).await {
Ok(raw_toml) => match toml::from_str(&raw_toml) {
Ok(value) => Ok(ConfigDocumentRead::Present { value }),
Err(error) => Ok(ConfigDocumentRead::ParseError {
raw_toml,
message: error.message().to_string(),
span: error.span().map(ConfigDocumentErrorSpan::from),
}),
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Ok(ConfigDocumentRead::Missing)
}
Err(err) => Ok(ConfigDocumentRead::ReadError {
kind: read_error_kind_name(err.kind()).to_string(),
message: err.to_string(),
}),
}
}
}
fn read_error_kind_name(kind: std::io::ErrorKind) -> &'static str {
match kind {
std::io::ErrorKind::PermissionDenied => "permission_denied",
std::io::ErrorKind::InvalidData => "invalid_data",
std::io::ErrorKind::InvalidInput => "invalid_input",
std::io::ErrorKind::TimedOut => "timed_out",
std::io::ErrorKind::Interrupted => "interrupted",
std::io::ErrorKind::Unsupported => "unsupported",
_ => "other",
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use toml::Value as TomlValue;
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
fn read_params(path: AbsolutePathBuf) -> ReadConfigDocumentParams {
ReadConfigDocumentParams { path }
}
#[tokio::test]
async fn reads_present_config_document() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let path = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("config.toml"))?;
tokio::fs::write(path.as_path(), "model = \"gpt-5\"\n").await?;
let got = LocalConfigStore
.read_config_document(read_params(path))
.await?;
let expected = ConfigDocumentRead::Present {
value: TomlValue::Table(toml::map::Map::from_iter([(
"model".to_string(),
TomlValue::String("gpt-5".to_string()),
)])),
};
assert_eq!(got, expected);
Ok(())
}
#[tokio::test]
async fn reports_missing_config_document() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let path = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("config.toml"))?;
let got = LocalConfigStore
.read_config_document(read_params(path))
.await?;
assert_eq!(got, ConfigDocumentRead::Missing);
Ok(())
}
#[tokio::test]
async fn reports_parse_error_with_raw_toml() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let path = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("config.toml"))?;
let raw_toml = "model = [";
tokio::fs::write(path.as_path(), raw_toml).await?;
let got = LocalConfigStore
.read_config_document(read_params(path))
.await?;
match got {
ConfigDocumentRead::ParseError {
raw_toml: got_raw,
message,
span,
} => {
assert_eq!(got_raw, raw_toml);
assert!(!message.is_empty());
assert!(span.is_some());
}
other => panic!("expected parse error, got {other:?}"),
}
Ok(())
}
}

View File

@@ -0,0 +1,20 @@
use async_trait::async_trait;
use crate::ConfigDocumentRead;
use crate::ConfigStoreResult;
use crate::ReadConfigDocumentParams;
/// Storage-neutral reader for path-addressed config documents.
///
/// Implementations should only read and parse the requested document. Codex config loading remains
/// responsible for deciding what the document represents, how missing documents are handled, how
/// parse errors interact with project trust, how relative paths are resolved, and how layers are
/// ordered and merged.
#[async_trait]
pub trait ConfigDocumentStore: Send + Sync {
/// Reads one config document addressed by path.
async fn read_config_document(
&self,
params: ReadConfigDocumentParams,
) -> ConfigStoreResult<ConfigDocumentRead>;
}

View File

@@ -0,0 +1,119 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use std::ops::Range;
use toml::Value as TomlValue;
/// Request to read one path-addressed config document.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReadConfigDocumentParams {
/// Absolute path to the config document to read.
pub path: AbsolutePathBuf,
}
/// Byte span for a config document parse error.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigDocumentErrorSpan {
/// Inclusive byte offset where the error starts.
pub start: usize,
/// Exclusive byte offset where the error ends.
pub end: usize,
}
impl From<Range<usize>> for ConfigDocumentErrorSpan {
fn from(span: Range<usize>) -> Self {
Self {
start: span.start,
end: span.end,
}
}
}
impl From<ConfigDocumentErrorSpan> for Range<usize> {
fn from(span: ConfigDocumentErrorSpan) -> Self {
span.start..span.end
}
}
/// Read and parse state for one config document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ConfigDocumentRead {
/// The backing source was absent.
Missing,
/// The backing source was present and parsed successfully.
Present {
/// Parsed TOML document.
value: TomlValue,
},
/// The backing source was present but could not be parsed as TOML.
///
/// This is distinct from ConfigDocumentRead::ReadError because project config parse errors are
/// fatal only after Codex applies project trust policy.
ParseError {
/// Original TOML text that failed to parse.
raw_toml: String,
/// User-facing parse failure message.
message: String,
/// Optional byte span for the parse failure.
span: Option<ConfigDocumentErrorSpan>,
},
/// The provider could not read the backing source.
ReadError {
/// Primitive read failure kind, such as "permission_denied" or "other".
kind: String,
/// User-facing read failure message.
message: String,
},
}
#[cfg(test)]
mod tests {
use async_trait::async_trait;
use pretty_assertions::assert_eq;
use toml::Value as TomlValue;
use super::*;
use crate::ConfigDocumentStore;
use crate::ConfigStoreResult;
struct StaticDocumentStore {
document: ConfigDocumentRead,
}
#[async_trait]
impl ConfigDocumentStore for StaticDocumentStore {
async fn read_config_document(
&self,
_params: ReadConfigDocumentParams,
) -> ConfigStoreResult<ConfigDocumentRead> {
Ok(self.document.clone())
}
}
#[tokio::test]
async fn store_trait_can_return_config_documents() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let path =
AbsolutePathBuf::from_absolute_path(temp_dir.path().join("config.toml")).expect("abs");
let value = TomlValue::Table(toml::map::Map::new());
let document = ConfigDocumentRead::Present { value };
let store = StaticDocumentStore {
document: document.clone(),
};
let got = store
.read_config_document(ReadConfigDocumentParams { path })
.await
.expect("read document");
assert_eq!(got, document);
}
}

View File

@@ -106,6 +106,18 @@ pub fn config_error_from_toml(
ConfigError::new(path.as_ref().to_path_buf(), range, err.message())
}
pub fn config_error_from_parse_message(
path: impl AsRef<Path>,
contents: &str,
message: impl Into<String>,
span: Option<std::ops::Range<usize>>,
) -> ConfigError {
let range = span
.map(|span| text_range_from_span(contents, span))
.unwrap_or_else(default_range);
ConfigError::new(path.as_ref().to_path_buf(), range, message)
}
pub fn config_error_from_typed_toml<T: DeserializeOwned>(
path: impl AsRef<Path>,
contents: &str,

View File

@@ -51,6 +51,7 @@ pub use diagnostics::ConfigError;
pub use diagnostics::ConfigLoadError;
pub use diagnostics::TextPosition;
pub use diagnostics::TextRange;
pub use diagnostics::config_error_from_parse_message;
pub use diagnostics::config_error_from_toml;
pub use diagnostics::config_error_from_typed_toml;
pub use diagnostics::first_layer_config_error;

View File

@@ -33,6 +33,7 @@ codex-async-utils = { workspace = true }
codex-code-mode = { workspace = true }
codex-connectors = { workspace = true }
codex-config = { workspace = true }
codex-config-store = { workspace = true }
codex-core-skills = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }

View File

@@ -11,6 +11,11 @@ use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigRequirementsWithSources;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_config_store::ConfigDocumentRead;
use codex_config_store::ConfigDocumentStore;
use codex_config_store::ConfigStoreError;
use codex_config_store::LocalConfigStore;
use codex_config_store::ReadConfigDocumentParams;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::SandboxMode;
@@ -57,6 +62,8 @@ pub use codex_config::TextPosition;
pub use codex_config::TextRange;
pub use codex_config::WebSearchModeRequirement;
pub(crate) use codex_config::build_cli_overrides_layer;
pub(crate) use codex_config::config_error_from_parse_message;
#[cfg(test)]
pub(crate) use codex_config::config_error_from_toml;
pub use codex_config::default_project_root_markers;
pub use codex_config::format_config_error;
@@ -123,6 +130,28 @@ pub async fn load_config_layers_state(
cli_overrides: &[(String, TomlValue)],
overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
) -> io::Result<ConfigLayerStack> {
let config_store = LocalConfigStore;
load_config_layers_state_with_store(
codex_home,
cwd,
cli_overrides,
overrides,
cloud_requirements,
&config_store,
)
.await
}
/// Equivalent to [load_config_layers_state], using the supplied store for path-addressed
/// config.toml reads.
pub async fn load_config_layers_state_with_store(
codex_home: &Path,
cwd: Option<AbsolutePathBuf>,
cli_overrides: &[(String, TomlValue)],
overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
config_store: &(impl ConfigDocumentStore + ?Sized),
) -> io::Result<ConfigLayerStack> {
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
@@ -172,23 +201,26 @@ pub async fn load_config_layers_state(
// Include an entry for the "system" config folder, loading its config.toml,
// if it exists.
let system_config_toml_file = system_config_toml_file()?;
let system_layer =
load_config_toml_for_required_layer(&system_config_toml_file, |config_toml| {
let system_layer = load_config_toml_for_required_layer(
config_store,
&system_config_toml_file,
|config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::System {
file: system_config_toml_file.clone(),
},
config_toml,
)
})
.await?;
},
)
.await?;
layers.push(system_layer);
// Add a layer for $CODEX_HOME/config.toml if it exists. Note if the file
// exists, but is malformed, then this error should be propagated to the
// user.
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home);
let user_layer = load_config_toml_for_required_layer(&user_file, |config_toml| {
let user_layer = load_config_toml_for_required_layer(config_store, &user_file, |config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
@@ -247,6 +279,7 @@ pub async fn load_config_layers_state(
}
};
let project_layers = load_project_layers(
config_store,
&cwd,
&project_trust_context.project_root,
&project_trust_context,
@@ -320,17 +353,14 @@ pub async fn load_config_layers_state(
/// - If there is an error reading the file or parsing the TOML, returns an
/// error.
async fn load_config_toml_for_required_layer(
config_toml: impl AsRef<Path>,
config_store: &(impl ConfigDocumentStore + ?Sized),
config_toml: &AbsolutePathBuf,
create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry,
) -> io::Result<ConfigLayerEntry> {
let toml_file = config_toml.as_ref();
let toml_value = match tokio::fs::read_to_string(toml_file).await {
Ok(contents) => {
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
let config_error = config_error_from_toml(toml_file, &contents, err.clone());
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
})?;
let config_parent = toml_file.parent().ok_or_else(|| {
let toml_file = config_toml.as_path();
let toml_value = match read_config_document(config_store, config_toml).await? {
ConfigDocumentRead::Present { value } => {
let config_parent = config_toml.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
@@ -339,23 +369,71 @@ async fn load_config_toml_for_required_layer(
),
)
})?;
resolve_relative_paths_in_config_toml(config, config_parent)
resolve_relative_paths_in_config_toml(value, config_parent.as_path())
}
Err(e) => {
if e.kind() == io::ErrorKind::NotFound {
Ok(TomlValue::Table(toml::map::Map::new()))
} else {
Err(io::Error::new(
e.kind(),
format!("Failed to read config file {}: {e}", toml_file.display()),
))
}
ConfigDocumentRead::Missing => Ok(TomlValue::Table(toml::map::Map::new())),
ConfigDocumentRead::ParseError {
raw_toml,
message,
span,
} => {
let config_error = config_error_from_parse_message(
toml_file,
&raw_toml,
message,
span.map(Into::into),
);
Err(io_error_from_config_error(
io::ErrorKind::InvalidData,
config_error,
/*source*/ None,
))
}
ConfigDocumentRead::ReadError { kind, message } => Err(io::Error::new(
io_error_kind_from_config_document_read_kind(&kind),
format!(
"Failed to read config file {}: {message}",
toml_file.display()
),
)),
}?;
Ok(create_entry(toml_value))
}
async fn read_config_document(
config_store: &(impl ConfigDocumentStore + ?Sized),
path: &AbsolutePathBuf,
) -> io::Result<ConfigDocumentRead> {
config_store
.read_config_document(ReadConfigDocumentParams { path: path.clone() })
.await
.map_err(config_store_error_to_io)
}
fn config_store_error_to_io(error: ConfigStoreError) -> io::Error {
match error {
ConfigStoreError::InvalidRequest { message } => {
io::Error::new(io::ErrorKind::InvalidInput, message)
}
ConfigStoreError::ReadFailed { message } | ConfigStoreError::Internal { message } => {
io::Error::other(message)
}
}
}
fn io_error_kind_from_config_document_read_kind(kind: &str) -> io::ErrorKind {
match kind {
"permission_denied" => io::ErrorKind::PermissionDenied,
"invalid_data" => io::ErrorKind::InvalidData,
"invalid_input" => io::ErrorKind::InvalidInput,
"timed_out" => io::ErrorKind::TimedOut,
"interrupted" => io::ErrorKind::Interrupted,
"unsupported" => io::ErrorKind::Unsupported,
_ => io::ErrorKind::Other,
}
}
/// If available, apply requirements from the platform system
/// `requirements.toml` location to `config_requirements_toml` by filling in
/// any unset fields.
@@ -766,6 +844,7 @@ async fn find_project_root(
/// starting from folders closest to `project_root` (which is the lowest
/// precedence) to those closest to `cwd` (which is the highest precedence).
async fn load_project_layers(
config_store: &(impl ConfigDocumentStore + ?Sized),
cwd: &AbsolutePathBuf,
project_root: &AbsolutePathBuf,
trust_context: &ProjectTrustContext,
@@ -810,32 +889,9 @@ async fn load_project_layers(
continue;
}
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE);
match tokio::fs::read_to_string(&config_file).await {
Ok(contents) => {
let config: TomlValue = match toml::from_str(&contents) {
Ok(config) => config,
Err(e) => {
if decision.is_trusted() {
let config_file_display = config_file.as_path().display();
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Error parsing project config file {config_file_display}: {e}"
),
));
}
layers.push(project_layer_entry(
trust_context,
&dot_codex_abs,
&layer_dir,
TomlValue::Table(toml::map::Map::new()),
/*config_toml_exists*/ true,
));
continue;
}
};
let config =
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
match read_config_document(config_store, &config_file).await? {
ConfigDocumentRead::Present { value } => {
let config = resolve_relative_paths_in_config_toml(value, dot_codex_abs.as_path())?;
let entry = project_layer_entry(
trust_context,
&dot_codex_abs,
@@ -845,25 +901,42 @@ async fn load_project_layers(
);
layers.push(entry);
}
Err(err) => {
if err.kind() == io::ErrorKind::NotFound {
// If there is no config.toml file, record an empty entry
// for this project layer, as this may still have subfolders
// that are significant in the overall ConfigLayerStack.
layers.push(project_layer_entry(
trust_context,
&dot_codex_abs,
&layer_dir,
TomlValue::Table(toml::map::Map::new()),
/*config_toml_exists*/ false,
));
} else {
ConfigDocumentRead::ParseError { message, .. } => {
if decision.is_trusted() {
let config_file_display = config_file.as_path().display();
return Err(io::Error::new(
err.kind(),
format!("Failed to read project config file {config_file_display}: {err}"),
io::ErrorKind::InvalidData,
format!(
"Error parsing project config file {config_file_display}: {message}"
),
));
}
layers.push(project_layer_entry(
trust_context,
&dot_codex_abs,
&layer_dir,
TomlValue::Table(toml::map::Map::new()),
/*config_toml_exists*/ true,
));
}
ConfigDocumentRead::Missing => {
// If there is no config.toml file, record an empty entry
// for this project layer, as this may still have subfolders
// that are significant in the overall ConfigLayerStack.
layers.push(project_layer_entry(
trust_context,
&dot_codex_abs,
&layer_dir,
TomlValue::Table(toml::map::Map::new()),
/*config_toml_exists*/ false,
));
}
ConfigDocumentRead::ReadError { kind, message } => {
let config_file_display = config_file.as_path().display();
return Err(io::Error::new(
io_error_kind_from_config_document_read_kind(&kind),
format!("Failed to read project config file {config_file_display}: {message}"),
));
}
}
}

View File

@@ -11,11 +11,17 @@ use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::config_loader::ConfigRequirementsWithSources;
use crate::config_loader::RequirementSource;
use crate::config_loader::load_config_layers_state_with_store;
use crate::config_loader::load_requirements_toml;
use crate::config_loader::version_for_toml;
use async_trait::async_trait;
use codex_config::CONFIG_TOML_FILE;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_config_store::ConfigDocumentRead;
use codex_config_store::ConfigDocumentStore;
use codex_config_store::ConfigStoreResult;
use codex_config_store::ReadConfigDocumentParams;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::AskForApproval;
@@ -36,6 +42,25 @@ fn config_error_from_io(err: &std::io::Error) -> &super::ConfigError {
.expect("expected ConfigLoadError")
}
#[derive(Default)]
struct MemoryConfigStore {
documents: HashMap<AbsolutePathBuf, ConfigDocumentRead>,
}
#[async_trait]
impl ConfigDocumentStore for MemoryConfigStore {
async fn read_config_document(
&self,
params: ReadConfigDocumentParams,
) -> ConfigStoreResult<ConfigDocumentRead> {
Ok(self
.documents
.get(&params.path)
.cloned()
.unwrap_or(ConfigDocumentRead::Missing))
}
}
async fn make_config_for_test(
codex_home: &Path,
project_path: &Path,
@@ -108,6 +133,99 @@ async fn returns_config_error_for_invalid_user_config_toml() {
assert_eq!(config_error, &expected_config_error);
}
#[tokio::test]
async fn loads_user_config_from_config_store() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let cwd = tempdir()?;
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path());
let user_config = toml::from_str::<TomlValue>(r#"model = "store-user-model""#)?;
let store = MemoryConfigStore {
documents: HashMap::from([(
user_file.clone(),
ConfigDocumentRead::Present { value: user_config },
)]),
};
let state = load_config_layers_state_with_store(
codex_home.path(),
Some(AbsolutePathBuf::try_from(cwd.path())?),
&[] as &[(String, TomlValue)],
LoaderOverrides::without_managed_config_for_tests(),
CloudRequirementsLoader::default(),
&store,
)
.await?;
let expected_user_layer = ConfigLayerEntry::new(
super::ConfigLayerSource::User { file: user_file },
toml::from_str::<TomlValue>(r#"model = "store-user-model""#)?,
);
assert_eq!(state.get_user_layer(), Some(&expected_user_layer));
Ok(())
}
#[tokio::test]
async fn loads_project_config_from_config_store() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let project = tempdir()?;
let dot_codex = project.path().join(".codex");
tokio::fs::create_dir(&dot_codex).await?;
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path());
let project_root = AbsolutePathBuf::try_from(project.path())?;
let project_config_file =
AbsolutePathBuf::from_absolute_path(dot_codex.join(CONFIG_TOML_FILE))?;
let user_config = toml::Value::try_from(ConfigToml {
projects: Some(HashMap::from([(
super::project_trust_key(project.path()),
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
..Default::default()
})?;
let project_config = toml::from_str::<TomlValue>(r#"model = "store-project-model""#)?;
let store = MemoryConfigStore {
documents: HashMap::from([
(
user_file,
ConfigDocumentRead::Present { value: user_config },
),
(
project_config_file.clone(),
ConfigDocumentRead::Present {
value: project_config.clone(),
},
),
]),
};
let state = load_config_layers_state_with_store(
codex_home.path(),
Some(project_root.clone()),
&[] as &[(String, TomlValue)],
LoaderOverrides::without_managed_config_for_tests(),
CloudRequirementsLoader::default(),
&store,
)
.await?;
let project_layer = ConfigLayerEntry::new(
super::ConfigLayerSource::Project {
dot_codex_folder: project_config_file.parent().expect("project config parent"),
},
project_config,
);
assert_eq!(
state
.layers_high_to_low()
.into_iter()
.find(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })),
Some(&project_layer)
);
Ok(())
}
#[tokio::test]
async fn returns_config_error_for_invalid_managed_config_toml() {
let tmp = tempdir().expect("tempdir");