Skip to content

Commit 02aafa2

Browse files
committed
changes
1 parent c7e2079 commit 02aafa2

File tree

2 files changed

+140
-89
lines changed

2 files changed

+140
-89
lines changed

codex-rs/app-server/src/config_api.rs

Lines changed: 117 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use crate::error_code::INTERNAL_ERROR_CODE;
22
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
3-
use anyhow::anyhow;
43
use codex_app_server_protocol::ConfigBatchWriteParams;
54
use codex_app_server_protocol::ConfigLayer;
65
use codex_app_server_protocol::ConfigLayerMetadata;
@@ -16,7 +15,7 @@ use codex_app_server_protocol::OverriddenMetadata;
1615
use codex_app_server_protocol::WriteStatus;
1716
use codex_core::config::ConfigToml;
1817
use codex_core::config::edit::ConfigEdit;
19-
use codex_core::config::edit::apply_blocking;
18+
use codex_core::config::edit::ConfigEditsBuilder;
2019
use codex_core::config_loader::LoadedConfigLayers;
2120
use codex_core::config_loader::LoaderOverrides;
2221
use codex_core::config_loader::load_config_layers_with_overrides;
@@ -28,7 +27,6 @@ use sha2::Sha256;
2827
use std::collections::HashMap;
2928
use std::path::Path;
3029
use std::path::PathBuf;
31-
use tokio::task;
3230
use toml::Value as TomlValue;
3331
use toml_edit::Item as TomlItem;
3432

@@ -143,7 +141,6 @@ impl ConfigApi {
143141
}
144142

145143
let mut user_config = layers.user.config.clone();
146-
let mut mutated = false;
147144
let mut parsed_segments = Vec::new();
148145
let mut config_edits = Vec::new();
149146

@@ -156,30 +153,33 @@ impl ConfigApi {
156153
config_write_error(ConfigWriteErrorCode::ConfigValidationError, message)
157154
})?;
158155

159-
let changed = apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy)
160-
.map_err(|err| match err {
156+
apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err(
157+
|err| match err {
161158
MergeError::PathNotFound => config_write_error(
162159
ConfigWriteErrorCode::ConfigPathNotFound,
163160
"Path not found",
164161
),
165162
MergeError::Validation(message) => {
166163
config_write_error(ConfigWriteErrorCode::ConfigValidationError, message)
167164
}
168-
})?;
169-
170-
if changed {
171-
let updated_value = value_at_path(&user_config, &segments).cloned();
172-
let mut path = segments.clone();
173-
collect_config_edits(
174-
&mut path,
175-
original_value.as_ref(),
176-
updated_value.as_ref(),
177-
&mut config_edits,
178-
)
179-
.map_err(|err| internal_error("failed to build config edits", err))?;
165+
},
166+
)?;
167+
168+
let updated_value = value_at_path(&user_config, &segments).cloned();
169+
if original_value != updated_value {
170+
let edit = match updated_value {
171+
Some(value) => ConfigEdit::SetPath {
172+
segments: segments.clone(),
173+
value: toml_value_to_item(&value)
174+
.map_err(|err| internal_error("failed to build config edits", err))?,
175+
},
176+
None => ConfigEdit::ClearPath {
177+
segments: segments.clone(),
178+
},
179+
};
180+
config_edits.push(edit);
180181
}
181182

182-
mutated |= changed;
183183
parsed_segments.push(segments);
184184
}
185185

@@ -199,8 +199,10 @@ impl ConfigApi {
199199
)
200200
})?;
201201

202-
if mutated {
203-
self.persist_user_config(config_edits)
202+
if !config_edits.is_empty() {
203+
ConfigEditsBuilder::new(&self.codex_home)
204+
.with_edits(config_edits)
205+
.apply()
204206
.await
205207
.map_err(|err| internal_error("failed to persist config.toml", err))?;
206208
}
@@ -269,22 +271,6 @@ impl ConfigApi {
269271
mdm,
270272
})
271273
}
272-
273-
async fn persist_user_config(&self, edits: Vec<ConfigEdit>) -> anyhow::Result<()> {
274-
if edits.is_empty() {
275-
return Ok(());
276-
}
277-
278-
let codex_home = self.codex_home.clone();
279-
280-
task::spawn_blocking(move || -> anyhow::Result<()> {
281-
apply_blocking(&codex_home, None, &edits)
282-
})
283-
.await
284-
.map_err(|err| anyhow!("config persistence task panicked: {err}"))??;
285-
286-
Ok(())
287-
}
288274
}
289275

290276
fn parse_value(value: JsonValue) -> Result<Option<TomlValue>, String> {
@@ -435,57 +421,6 @@ fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result<bool, MergeEr
435421
Ok(parent.remove(last).is_some())
436422
}
437423

438-
fn collect_config_edits(
439-
path: &mut Vec<String>,
440-
original: Option<&TomlValue>,
441-
updated: Option<&TomlValue>,
442-
edits: &mut Vec<ConfigEdit>,
443-
) -> anyhow::Result<()> {
444-
match (original, updated) {
445-
(Some(old_value), Some(new_value)) if old_value == new_value => {}
446-
(Some(TomlValue::Table(old_table)), Some(TomlValue::Table(new_table))) => {
447-
for (key, old_value) in old_table.iter() {
448-
if new_table.contains_key(key) {
449-
let mut nested = path.clone();
450-
nested.push(key.clone());
451-
collect_config_edits(&mut nested, Some(old_value), new_table.get(key), edits)?;
452-
} else {
453-
let mut nested = path.clone();
454-
nested.push(key.clone());
455-
edits.push(ConfigEdit::ClearPath { segments: nested });
456-
}
457-
}
458-
459-
for (key, new_value) in new_table.iter() {
460-
if old_table.contains_key(key) {
461-
continue;
462-
}
463-
464-
let mut nested = path.clone();
465-
nested.push(key.clone());
466-
edits.push(ConfigEdit::SetPath {
467-
segments: nested,
468-
value: toml_value_to_item(new_value)?,
469-
});
470-
}
471-
}
472-
(_, Some(new_value)) => {
473-
edits.push(ConfigEdit::SetPath {
474-
segments: path.clone(),
475-
value: toml_value_to_item(new_value)?,
476-
});
477-
}
478-
(Some(_), None) => {
479-
edits.push(ConfigEdit::ClearPath {
480-
segments: path.clone(),
481-
});
482-
}
483-
(None, None) => {}
484-
}
485-
486-
Ok(())
487-
}
488-
489424
fn toml_value_to_item(value: &TomlValue) -> anyhow::Result<TomlItem> {
490425
match value {
491426
TomlValue::Table(table) => {
@@ -506,7 +441,7 @@ fn toml_value_to_value(value: &TomlValue) -> anyhow::Result<toml_edit::Value> {
506441
TomlValue::Integer(val) => Ok(toml_edit::Value::from(*val)),
507442
TomlValue::Float(val) => Ok(toml_edit::Value::from(*val)),
508443
TomlValue::Boolean(val) => Ok(toml_edit::Value::from(*val)),
509-
TomlValue::Datetime(val) => Ok(toml_edit::Value::from(val.clone())),
444+
TomlValue::Datetime(val) => Ok(toml_edit::Value::from(*val)),
510445
TomlValue::Array(items) => {
511446
let mut array = toml_edit::Array::new();
512447
for item in items {
@@ -840,6 +775,99 @@ mod tests {
840775
use pretty_assertions::assert_eq;
841776
use tempfile::tempdir;
842777

778+
#[test]
779+
fn toml_value_to_item_handles_nested_config_tables() {
780+
let config = r#"
781+
[mcp_servers.docs]
782+
command = "docs-server"
783+
784+
[mcp_servers.docs.http_headers]
785+
X-Doc = "42"
786+
"#;
787+
788+
let value: TomlValue = toml::from_str(config).expect("parse config example");
789+
let item = toml_value_to_item(&value).expect("convert to toml_edit item");
790+
791+
let root = item.as_table().expect("root table");
792+
assert!(!root.is_implicit(), "root table should be explicit");
793+
794+
let mcp_servers = root
795+
.get("mcp_servers")
796+
.and_then(TomlItem::as_table)
797+
.expect("mcp_servers table");
798+
assert!(
799+
!mcp_servers.is_implicit(),
800+
"mcp_servers table should be explicit"
801+
);
802+
803+
let docs = mcp_servers
804+
.get("docs")
805+
.and_then(TomlItem::as_table)
806+
.expect("docs table");
807+
assert_eq!(
808+
docs.get("command")
809+
.and_then(TomlItem::as_value)
810+
.and_then(toml_edit::Value::as_str),
811+
Some("docs-server")
812+
);
813+
814+
let http_headers = docs
815+
.get("http_headers")
816+
.and_then(TomlItem::as_table)
817+
.expect("http_headers table");
818+
assert_eq!(
819+
http_headers
820+
.get("X-Doc")
821+
.and_then(TomlItem::as_value)
822+
.and_then(toml_edit::Value::as_str),
823+
Some("42")
824+
);
825+
}
826+
827+
#[tokio::test]
828+
async fn write_value_preserves_comments_and_order() {
829+
let tmp = tempdir().expect("tempdir");
830+
let original = r#"# Codex user configuration
831+
model = "gpt-5"
832+
approval_policy = "on-request"
833+
834+
[notice]
835+
# Preserve this comment
836+
hide_full_access_warning = true
837+
838+
[features]
839+
unified_exec = true
840+
"#;
841+
std::fs::write(tmp.path().join(CONFIG_FILE_NAME), original).unwrap();
842+
843+
let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]);
844+
api.write_value(ConfigValueWriteParams {
845+
file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()),
846+
key_path: "features.remote_compaction".to_string(),
847+
value: json!(true),
848+
merge_strategy: MergeStrategy::Replace,
849+
expected_version: None,
850+
})
851+
.await
852+
.expect("write succeeds");
853+
854+
let updated =
855+
std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config");
856+
let expected = r#"# Codex user configuration
857+
model = "gpt-5"
858+
approval_policy = "on-request"
859+
860+
[notice]
861+
# Preserve this comment
862+
hide_full_access_warning = true
863+
864+
[features]
865+
unified_exec = true
866+
remote_compaction = true
867+
"#;
868+
assert_eq!(updated, expected);
869+
}
870+
843871
#[tokio::test]
844872
async fn read_includes_origins_and_layers() {
845873
let tmp = tempdir().expect("tempdir");

codex-rs/core/src/config/edit.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,11 @@ impl ConfigEditsBuilder {
555555
self
556556
}
557557

558+
pub fn with_edits(mut self, edits: Vec<ConfigEdit>) -> Self {
559+
self.edits.extend(edits);
560+
self
561+
}
562+
558563
/// Apply edits on a blocking thread.
559564
pub fn apply_blocking(self) -> anyhow::Result<()> {
560565
apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits)
@@ -603,6 +608,24 @@ model_reasoning_effort = "high"
603608
assert_eq!(contents, expected);
604609
}
605610

611+
#[test]
612+
fn builder_with_edits_applies_custom_paths() {
613+
let tmp = tempdir().expect("tmpdir");
614+
let codex_home = tmp.path();
615+
616+
ConfigEditsBuilder::new(codex_home)
617+
.with_edits(vec![ConfigEdit::SetPath {
618+
segments: vec!["enabled".to_string()],
619+
value: value(true),
620+
}])
621+
.apply_blocking()
622+
.expect("persist");
623+
624+
let contents =
625+
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
626+
assert_eq!(contents, "enabled = true\n");
627+
}
628+
606629
#[test]
607630
fn blocking_set_model_preserves_inline_table_contents() {
608631
let tmp = tempdir().expect("tmpdir");

0 commit comments

Comments
 (0)