diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ea79776bdc..bde19a6e05 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index e4985e4582..13f7f89e7a 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -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" } diff --git a/codex-rs/config-store/BUILD.bazel b/codex-rs/config-store/BUILD.bazel new file mode 100644 index 0000000000..9f56f4bded --- /dev/null +++ b/codex-rs/config-store/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "config-store", + crate_name = "codex_config_store", +) diff --git a/codex-rs/config-store/Cargo.toml b/codex-rs/config-store/Cargo.toml new file mode 100644 index 0000000000..bdc4ef7b3f --- /dev/null +++ b/codex-rs/config-store/Cargo.toml @@ -0,0 +1,24 @@ +[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 } +toml = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/codex-rs/config-store/src/error.rs b/codex-rs/config-store/src/error.rs new file mode 100644 index 0000000000..dcb3720c37 --- /dev/null +++ b/codex-rs/config-store/src/error.rs @@ -0,0 +1,27 @@ +/// Result type returned by config-store operations. +pub type ConfigStoreResult = Result; + +/// 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, + }, +} diff --git a/codex-rs/config-store/src/lib.rs b/codex-rs/config-store/src/lib.rs new file mode 100644 index 0000000000..7d02ead69c --- /dev/null +++ b/codex-rs/config-store/src/lib.rs @@ -0,0 +1,19 @@ +//! 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 store; +mod types; + +pub use error::ConfigStoreError; +pub use error::ConfigStoreResult; +pub use store::ConfigDocumentStore; +pub use types::ConfigDocumentErrorSpan; +pub use types::ConfigDocumentRead; +pub use types::ReadConfigDocumentParams; diff --git a/codex-rs/config-store/src/store.rs b/codex-rs/config-store/src/store.rs new file mode 100644 index 0000000000..d8d11a0479 --- /dev/null +++ b/codex-rs/config-store/src/store.rs @@ -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; +} diff --git a/codex-rs/config-store/src/types.rs b/codex-rs/config-store/src/types.rs new file mode 100644 index 0000000000..5779978309 --- /dev/null +++ b/codex-rs/config-store/src/types.rs @@ -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> for ConfigDocumentErrorSpan { + fn from(span: Range) -> Self { + Self { + start: span.start, + end: span.end, + } + } +} + +impl From for Range { + 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, + }, + + /// 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 { + 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); + } +}