1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7use toml::Value as TomlValue;
8use vtcode_commons::paths::normalize_path;
9use vtcode_config::defaults;
10use vtcode_config::loader::layers::{ConfigLayerMetadata, ConfigLayerSource};
11use vtcode_config::loader::{
12 ConfigBuilder, ConfigManager, VTCodeConfig, fingerprint_str, merge_toml_values,
13};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ConfigReadRequest {
17 pub workspace: PathBuf,
18 #[serde(default)]
19 pub runtime_overrides: Vec<(String, String)>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ConfigLayerView {
24 pub source: ConfigLayerSource,
25 pub metadata: ConfigLayerMetadata,
26 pub disabled_reason: Option<String>,
27 pub error: Option<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ConfigReadResponse {
32 pub effective_config: serde_json::Value,
33 pub merged_version: String,
34 pub layers: Vec<ConfigLayerView>,
35 pub origins: BTreeMap<String, ConfigLayerMetadata>,
36}
37
38#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum ConfigWriteTarget {
41 User,
42 Workspace,
43 Project,
44}
45
46#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
47#[serde(rename_all = "snake_case")]
48pub enum ConfigWriteStrategy {
49 Replace,
50 Upsert,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ConfigWriteRequest {
55 pub workspace: PathBuf,
56 pub target: ConfigWriteTarget,
57 pub path: String,
58 pub value: TomlValue,
59 pub strategy: ConfigWriteStrategy,
60 #[serde(default)]
61 pub expected_layer_version: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct OverrideMetadata {
66 pub source: ConfigLayerSource,
67 pub metadata: ConfigLayerMetadata,
68 pub effective_value: TomlValue,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ConfigWriteResponse {
73 pub merged_version: String,
74 pub written_layer_version: String,
75 pub effective_value: Option<TomlValue>,
76 pub overridden_metadata: Option<OverrideMetadata>,
77}
78
79pub struct ConfigService;
80
81impl ConfigService {
82 pub fn read(request: ConfigReadRequest) -> Result<ConfigReadResponse> {
83 let mut builder = ConfigBuilder::new().workspace(request.workspace.clone());
84 if !request.runtime_overrides.is_empty() {
85 builder = builder.cli_overrides(&request.runtime_overrides);
86 }
87 let manager = builder.build().context("Failed to build configuration")?;
88 let (effective_toml, origins) = manager.layer_stack().effective_config_with_origins();
89 let effective_config = serde_json::to_value(&effective_toml)
90 .context("Failed to serialize effective configuration to JSON")?;
91 let merged_version = merged_version(manager.layer_stack().layers());
92
93 let layers = manager
94 .layer_stack()
95 .layers()
96 .iter()
97 .map(|layer| ConfigLayerView {
98 source: layer.source.clone(),
99 metadata: layer.metadata.clone(),
100 disabled_reason: layer
101 .disabled_reason
102 .as_ref()
103 .map(|reason| format!("{reason:?}")),
104 error: layer.error.as_ref().map(|error| error.message.clone()),
105 })
106 .collect();
107
108 let origins = origins.into_iter().collect::<BTreeMap<_, _>>();
109 Ok(ConfigReadResponse {
110 effective_config,
111 merged_version,
112 layers,
113 origins,
114 })
115 }
116
117 pub fn write(request: ConfigWriteRequest) -> Result<ConfigWriteResponse> {
118 if request.path.trim().is_empty() {
119 bail!("Config path cannot be empty");
120 }
121
122 let manager =
123 ConfigManager::load_from_workspace(&request.workspace).with_context(|| {
124 format!(
125 "Failed to load workspace config from {}",
126 request.workspace.display()
127 )
128 })?;
129
130 let target_path = resolve_target_path(&manager, &request.workspace, &request.target)?;
131
132 let current_version = manager
133 .layer_stack()
134 .layers()
135 .iter()
136 .find(|layer| source_matches_target(&layer.source, &request.target, &target_path))
137 .map(|layer| layer.metadata.version.clone());
138
139 if let Some(expected) = request.expected_layer_version.as_ref()
140 && current_version.as_ref() != Some(expected)
141 {
142 bail!(
143 "Layer version mismatch for {} (expected {}, got {})",
144 target_path.display(),
145 expected,
146 current_version.unwrap_or_else(|| "<missing>".to_string())
147 );
148 }
149
150 let mut target_toml = load_or_default_toml(&target_path)?;
151 apply_write(
152 &mut target_toml,
153 &request.path,
154 &request.value,
155 request.strategy,
156 )?;
157
158 let updated_config: VTCodeConfig = target_toml.clone().try_into().with_context(|| {
159 format!(
160 "Updated configuration at {} could not be deserialized",
161 target_path.display()
162 )
163 })?;
164 updated_config
165 .validate()
166 .context("Updated configuration failed validation")?;
167
168 ConfigManager::save_config_to_path(&target_path, &updated_config).with_context(|| {
169 format!(
170 "Failed to write updated configuration to {}",
171 target_path.display()
172 )
173 })?;
174
175 let reloaded_manager = ConfigManager::load_from_workspace(&request.workspace)
176 .context("Failed to reload configuration after write")?;
177 let (effective_toml, origins) = reloaded_manager
178 .layer_stack()
179 .effective_config_with_origins();
180
181 let written_layer = reloaded_manager
182 .layer_stack()
183 .layers()
184 .iter()
185 .find(|layer| source_matches_target(&layer.source, &request.target, &target_path))
186 .with_context(|| {
187 format!(
188 "Unable to find written layer {} in reloaded stack",
189 target_path.display()
190 )
191 })?;
192
193 let effective_value = get_value_by_path(&effective_toml, &request.path).cloned();
194 let overridden_metadata = if let Some(origin) = origins.get(&request.path) {
195 if origin.version != written_layer.metadata.version {
196 let source = reloaded_manager
197 .layer_stack()
198 .layers()
199 .iter()
200 .find(|layer| layer.metadata.name == origin.name)
201 .map(|layer| layer.source.clone())
202 .unwrap_or(ConfigLayerSource::Runtime);
203
204 effective_value.clone().map(|value| OverrideMetadata {
205 source,
206 metadata: origin.clone(),
207 effective_value: value,
208 })
209 } else {
210 None
211 }
212 } else {
213 None
214 };
215
216 Ok(ConfigWriteResponse {
217 merged_version: merged_version(reloaded_manager.layer_stack().layers()),
218 written_layer_version: written_layer.metadata.version.clone(),
219 effective_value,
220 overridden_metadata,
221 })
222 }
223}
224
225fn merged_version(layers: &[vtcode_config::loader::layers::ConfigLayerEntry]) -> String {
226 let mut parts = Vec::with_capacity(layers.len());
227 for layer in layers {
228 if !layer.is_enabled() {
229 continue;
230 }
231 parts.push(format!(
232 "{}:{}",
233 layer.metadata.name, layer.metadata.version
234 ));
235 }
236 fingerprint_str(&parts.join("|"))
237}
238
239fn resolve_target_path(
240 manager: &ConfigManager,
241 workspace: &Path,
242 target: &ConfigWriteTarget,
243) -> Result<PathBuf> {
244 match target {
245 ConfigWriteTarget::Workspace => {
246 let root = manager.workspace_root().unwrap_or(workspace).to_path_buf();
247 Ok(root.join(manager.config_file_name()))
248 }
249 ConfigWriteTarget::User => {
250 let provider = defaults::current_config_defaults();
251 let paths = provider.home_config_paths(manager.config_file_name());
252 if let Some(path) = paths.first() {
253 return Ok(path.clone());
254 }
255 let home = dirs::home_dir().context("Could not resolve home directory")?;
256 Ok(home.join(".vtcode").join(manager.config_file_name()))
257 }
258 ConfigWriteTarget::Project => {
259 let provider = defaults::current_config_defaults();
260 let workspace_root = manager.workspace_root().unwrap_or(workspace);
261 let workspace_paths = provider.workspace_paths_for(workspace_root);
262 let config_dir = workspace_paths.config_dir();
263 let project_name = ConfigManager::current_project_name(workspace_root)
264 .context("Could not resolve project name for project-level config")?;
265 Ok(config_dir
266 .join("projects")
267 .join(project_name)
268 .join("config")
269 .join(manager.config_file_name()))
270 }
271 }
272}
273
274fn source_matches_target(
275 source: &ConfigLayerSource,
276 target: &ConfigWriteTarget,
277 path: &Path,
278) -> bool {
279 match (source, target) {
280 (ConfigLayerSource::User { file }, ConfigWriteTarget::User) => same_config_path(file, path),
281 (ConfigLayerSource::Workspace { file }, ConfigWriteTarget::Workspace) => {
282 same_config_path(file, path)
283 }
284 (ConfigLayerSource::Project { file }, ConfigWriteTarget::Project) => {
285 same_config_path(file, path)
286 }
287 _ => false,
288 }
289}
290
291fn same_config_path(left: &Path, right: &Path) -> bool {
292 let left = fs::canonicalize(left).unwrap_or_else(|_| normalize_path(left));
293 let right = fs::canonicalize(right).unwrap_or_else(|_| normalize_path(right));
294 left == right
295}
296
297fn load_or_default_toml(path: &Path) -> Result<TomlValue> {
298 if !path.exists() {
299 return Ok(TomlValue::Table(toml::Table::new()));
300 }
301
302 let content = fs::read_to_string(path)
303 .with_context(|| format!("Failed to read config file {}", path.display()))?;
304 toml::from_str(&content)
305 .with_context(|| format!("Failed to parse config file {}", path.display()))
306}
307
308fn apply_write(
309 root: &mut TomlValue,
310 path: &str,
311 value: &TomlValue,
312 strategy: ConfigWriteStrategy,
313) -> Result<()> {
314 let existing = get_or_create_path_mut(root, path)?;
315 match strategy {
316 ConfigWriteStrategy::Replace => {
317 *existing = value.clone();
318 }
319 ConfigWriteStrategy::Upsert => {
320 if existing.is_table() && value.is_table() {
321 merge_toml_values(existing, value);
322 } else {
323 *existing = value.clone();
324 }
325 }
326 }
327 Ok(())
328}
329
330fn get_or_create_path_mut<'a>(root: &'a mut TomlValue, path: &str) -> Result<&'a mut TomlValue> {
331 let mut current = root;
332 let parts: Vec<&str> = path.split('.').filter(|part| !part.is_empty()).collect();
333 if parts.is_empty() {
334 bail!("Invalid empty config path");
335 }
336
337 for (index, part) in parts.iter().enumerate() {
338 let is_last = index == parts.len() - 1;
339 let table = current
340 .as_table_mut()
341 .ok_or_else(|| anyhow::anyhow!("Path '{}' traverses non-table value", path))?;
342
343 if is_last {
344 let entry = table
345 .entry((*part).to_string())
346 .or_insert_with(|| TomlValue::Table(toml::Table::new()));
347 return Ok(entry);
348 }
349
350 current = table
351 .entry((*part).to_string())
352 .or_insert_with(|| TomlValue::Table(toml::Table::new()));
353 }
354
355 bail!("Could not resolve config path '{}'", path)
356}
357
358fn get_value_by_path<'a>(root: &'a TomlValue, path: &str) -> Option<&'a TomlValue> {
359 let mut current = root;
360 for part in path.split('.').filter(|part| !part.is_empty()) {
361 let table = current.as_table()?;
362 current = table.get(part)?;
363 }
364 Some(current)
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 use std::sync::Arc;
372
373 use serial_test::serial;
374 use vtcode_commons::reference::StaticWorkspacePaths;
375 use vtcode_config::defaults::WorkspacePathsDefaults;
376 use vtcode_config::defaults::provider::with_config_defaults_provider_for_test;
377
378 #[test]
379 #[serial]
380 fn read_returns_layers_and_origins() {
381 let temp = tempfile::tempdir().expect("tempdir");
382 let workspace = temp.path();
383 let home_config = workspace.join("home").join("vtcode.toml");
384 let workspace_config = workspace.join("vtcode.toml");
385 fs::create_dir_all(home_config.parent().expect("home parent")).expect("home dir");
386
387 fs::write(&home_config, "agent.provider = \"openai\"\n").expect("home config");
388 fs::write(
389 &workspace_config,
390 "agent.provider = \"anthropic\"\nagent.default_model = \"claude-sonnet-4-6\"\n",
391 )
392 .expect("workspace config");
393
394 let static_paths = StaticWorkspacePaths::new(workspace, workspace.join(".vtcode"));
395 let provider =
396 WorkspacePathsDefaults::new(Arc::new(static_paths)).with_home_paths(vec![home_config]);
397
398 with_config_defaults_provider_for_test(Arc::new(provider), || {
399 let response = ConfigService::read(ConfigReadRequest {
400 workspace: workspace.to_path_buf(),
401 runtime_overrides: Vec::new(),
402 })
403 .expect("read response");
404
405 assert!(!response.layers.is_empty());
406 assert!(!response.merged_version.is_empty());
407 assert!(response.origins.contains_key("agent.provider"));
408 });
409 }
410
411 #[test]
412 #[serial]
413 fn write_reports_override_when_higher_layer_wins() {
414 let temp = tempfile::tempdir().expect("tempdir");
415 let workspace = temp.path();
416 let home_config = workspace.join("home").join("vtcode.toml");
417 let workspace_config = workspace.join("vtcode.toml");
418 fs::create_dir_all(home_config.parent().expect("home parent")).expect("home dir");
419
420 fs::write(&home_config, "agent.provider = \"openai\"\n").expect("home config");
421 fs::write(&workspace_config, "agent.provider = \"gemini\"\n").expect("workspace config");
422
423 let static_paths = StaticWorkspacePaths::new(workspace, workspace.join(".vtcode"));
424 let provider =
425 WorkspacePathsDefaults::new(Arc::new(static_paths)).with_home_paths(vec![home_config]);
426
427 with_config_defaults_provider_for_test(Arc::new(provider), || {
428 let response = ConfigService::write(ConfigWriteRequest {
429 workspace: workspace.to_path_buf(),
430 target: ConfigWriteTarget::User,
431 path: "agent.provider".to_string(),
432 value: TomlValue::String("anthropic".to_string()),
433 strategy: ConfigWriteStrategy::Replace,
434 expected_layer_version: None,
435 })
436 .expect("write response");
437
438 assert_eq!(
439 response.effective_value,
440 Some(TomlValue::String("gemini".to_string()))
441 );
442 assert!(response.overridden_metadata.is_some());
443 });
444 }
445
446 #[test]
447 #[serial]
448 fn write_rejects_stale_expected_version() {
449 let temp = tempfile::tempdir().expect("tempdir");
450 let workspace = temp.path();
451 let workspace_config = workspace.join("vtcode.toml");
452 fs::write(&workspace_config, "agent.provider = \"openai\"\n").expect("workspace config");
453
454 let response = ConfigService::write(ConfigWriteRequest {
455 workspace: workspace.to_path_buf(),
456 target: ConfigWriteTarget::Workspace,
457 path: "agent.provider".to_string(),
458 value: TomlValue::String("anthropic".to_string()),
459 strategy: ConfigWriteStrategy::Replace,
460 expected_layer_version: Some("stale-version".to_string()),
461 });
462
463 assert!(response.is_err());
464 let error = format!("{:#}", response.expect_err("expected stale version error"));
465 assert!(error.contains("Layer version mismatch"));
466 }
467}