Skip to main content

vtcode_core/config/
api.rs

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}