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::VtCodePaths;
9use vtcode_commons::canonicalize;
10use vtcode_commons::paths::normalize_path;
11use vtcode_config::defaults;
12use vtcode_config::loader::layers::{ConfigLayerMetadata, ConfigLayerSource};
13use vtcode_config::loader::{
14    ConfigBuilder, ConfigManager, VTCodeConfig, explicit_config_path, fingerprint_str, merge_toml_values,
15};
16
17/// Request to read the effective configuration for a workspace.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ConfigReadRequest {
20    /// Root directory of the workspace to read configuration for.
21    pub workspace: PathBuf,
22    /// Key-value overrides applied at runtime (e.g. CLI flags).
23    #[serde(default)]
24    pub runtime_overrides: Vec<(String, String)>,
25}
26
27/// View of a single configuration layer for API responses.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ConfigLayerView {
30    /// Origin of this layer (e.g. user, workspace, project).
31    pub source: ConfigLayerSource,
32    /// Metadata including name, version, and timestamps.
33    pub metadata: ConfigLayerMetadata,
34    /// Reason this layer was skipped during merging, if disabled.
35    pub disabled_reason: Option<String>,
36    /// Error message if this layer failed to load.
37    pub error: Option<String>,
38}
39
40/// Response containing the merged effective configuration and layer details.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ConfigReadResponse {
43    /// The fully merged configuration as a JSON value.
44    pub effective_config: serde_json::Value,
45    /// Fingerprint of the merged layer stack for change detection.
46    pub merged_version: String,
47    /// Ordered list of all configuration layers.
48    pub layers: Vec<ConfigLayerView>,
49    /// Map of config path to the layer that provides its effective value.
50    pub origins: BTreeMap<String, ConfigLayerMetadata>,
51}
52
53/// The configuration layer target for a write operation.
54#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "snake_case")]
56pub enum ConfigWriteTarget {
57    /// User-level configuration in the canonical platform config directory.
58    User,
59    /// Workspace-level configuration (workspace root).
60    Workspace,
61    /// Project-level configuration (`.vtcode/projects/<name>/config/`).
62    Project,
63}
64
65impl ConfigWriteTarget {
66    /// Stable lower-case name used in user-facing reset diagnostics.
67    #[must_use]
68    pub const fn layer_name(self) -> &'static str {
69        match self {
70            Self::User => "user",
71            Self::Workspace => "workspace",
72            Self::Project => "project",
73        }
74    }
75}
76
77/// Strategy for applying a value to the configuration.
78#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(rename_all = "snake_case")]
80pub enum ConfigWriteStrategy {
81    /// Replace the value at the given path unconditionally.
82    Replace,
83    /// Merge into existing tables; replace non-table values.
84    Upsert,
85}
86
87/// Request to write a value to a specific configuration layer.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ConfigWriteRequest {
90    /// Root directory of the workspace.
91    pub workspace: PathBuf,
92    /// Which configuration layer to write to.
93    pub target: ConfigWriteTarget,
94    /// Dot-separated path to the configuration key (e.g. "agent.provider").
95    pub path: String,
96    /// The TOML value to write.
97    pub value: TomlValue,
98    /// How to apply the value to the existing configuration.
99    pub strategy: ConfigWriteStrategy,
100    /// Optional expected version of the target layer for optimistic concurrency.
101    #[serde(default)]
102    pub expected_layer_version: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct OverrideMetadata {
107    pub source: ConfigLayerSource,
108    pub metadata: ConfigLayerMetadata,
109    pub effective_value: TomlValue,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ConfigWriteResponse {
114    pub merged_version: String,
115    pub written_layer_version: String,
116    pub effective_value: Option<TomlValue>,
117    pub overridden_metadata: Option<OverrideMetadata>,
118}
119
120/// Request to clear one configuration layer.
121///
122/// Resetting a layer writes an empty TOML document to that layer's resolved
123/// path. Lower-precedence layers remain active, and credential storage is not
124/// touched. The optional expected version provides the same optimistic
125/// concurrency guard as [`ConfigWriteRequest`].
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ConfigResetRequest {
128    /// Root directory of the workspace whose effective configuration should be
129    /// reloaded after the reset.
130    pub workspace: PathBuf,
131    /// Layer to clear. `User` is the target selected by the CLI's `--global`
132    /// flag; `Project` is selected by `--project`.
133    pub target: ConfigWriteTarget,
134    /// Optional expected version of the selected layer.
135    #[serde(default)]
136    pub expected_layer_version: Option<String>,
137    /// Optional exact path supplied by an already-open settings palette. The
138    /// service validates it against the resolved layer path before clearing it.
139    #[serde(default)]
140    pub path: Option<PathBuf>,
141}
142
143/// Result of clearing one configuration layer.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ConfigResetResponse {
146    /// Layer that was selected for reset.
147    pub target: ConfigWriteTarget,
148    /// Resolved configuration file that was cleared.
149    pub path: PathBuf,
150    /// Whether the target file existed before the reset.
151    pub had_file: bool,
152    /// Version of the selected layer before it was cleared, when loaded.
153    pub previous_layer_version: Option<String>,
154    /// Fingerprint of the effective layer stack after the reset.
155    pub merged_version: String,
156    /// Effective configuration after the reset.
157    pub effective_config: serde_json::Value,
158}
159
160pub struct ConfigService;
161
162impl ConfigService {
163    /// Resolve the file used by a configuration layer for a workspace.
164    ///
165    /// This is shared by interactive settings and non-interactive commands so
166    /// an explicit `--config` path, project profile, or canonical user path is
167    /// never handled by a second path-resolution implementation.
168    pub fn resolve_target_path(workspace: &Path, target: &ConfigWriteTarget) -> Result<PathBuf> {
169        match ConfigManager::load_from_workspace_with_repository_repair(workspace) {
170            Ok(manager) => resolve_target_path_from_manager(&manager, workspace, target),
171            Err(error) => resolve_target_path_without_manager(workspace, target)
172                .with_context(|| format!("Failed to resolve configuration target after load error: {error:#}")),
173        }
174    }
175
176    pub fn read(request: ConfigReadRequest) -> Result<ConfigReadResponse> {
177        let mut builder = ConfigBuilder::new().workspace(request.workspace.clone());
178        if !request.runtime_overrides.is_empty() {
179            builder = builder.cli_overrides(&request.runtime_overrides);
180        }
181        let manager = builder.build().context("Failed to build configuration")?;
182        let (effective_toml, origins) = manager.layer_stack().effective_config_with_origins();
183        let effective_config =
184            serde_json::to_value(&effective_toml).context("Failed to serialize effective configuration to JSON")?;
185        let merged_version = merged_version(manager.layer_stack().layers());
186
187        let layers = manager
188            .layer_stack()
189            .layers()
190            .iter()
191            .map(|layer| ConfigLayerView {
192                source: layer.source.clone(),
193                metadata: layer.metadata.clone(),
194                disabled_reason: layer.disabled_reason.as_ref().map(|reason| format!("{reason:?}")),
195                error: layer.error.as_ref().map(|error| error.message.clone()),
196            })
197            .collect();
198
199        let origins = origins.into_iter().collect::<BTreeMap<_, _>>();
200        Ok(ConfigReadResponse { effective_config, merged_version, layers, origins })
201    }
202
203    pub fn write(request: ConfigWriteRequest) -> Result<ConfigWriteResponse> {
204        if request.path.trim().is_empty() {
205            bail!("Config path cannot be empty");
206        }
207
208        let manager = ConfigManager::load_from_workspace_with_repository_repair(&request.workspace)
209            .with_context(|| format!("Failed to load workspace config from {}", request.workspace.display()))?;
210
211        let target_path = resolve_target_path_from_manager(&manager, &request.workspace, &request.target)?;
212
213        let current_version = manager
214            .layer_stack()
215            .layers()
216            .iter()
217            .find(|layer| source_matches_target(&layer.source, &request.target, &target_path))
218            .map(|layer| layer.metadata.version.clone());
219
220        if let Some(expected) = request.expected_layer_version.as_ref()
221            && current_version.as_ref() != Some(expected)
222        {
223            bail!(
224                "Layer version mismatch for {} (expected {}, got {})",
225                target_path.display(),
226                expected,
227                current_version.unwrap_or_else(|| "<missing>".to_string())
228            );
229        }
230
231        let mut target_toml = load_or_default_toml(&target_path)?;
232        apply_write(&mut target_toml, &request.path, &request.value, request.strategy)?;
233
234        let updated_config: VTCodeConfig = target_toml
235            .clone()
236            .try_into()
237            .with_context(|| format!("Updated configuration at {} could not be deserialized", target_path.display()))?;
238        updated_config.validate().context("Updated configuration failed validation")?;
239
240        save_config_for_target(request.target, &target_path, &updated_config)
241            .with_context(|| format!("Failed to write updated configuration to {}", target_path.display()))?;
242        ConfigManager::invalidate_workspace_cache(&request.workspace);
243
244        let reloaded_manager = ConfigManager::load_from_workspace_with_repository_repair(&request.workspace)
245            .context("Failed to reload configuration after write")?;
246        let (effective_toml, origins) = reloaded_manager.layer_stack().effective_config_with_origins();
247
248        let written_layer = reloaded_manager
249            .layer_stack()
250            .layers()
251            .iter()
252            .find(|layer| source_matches_target(&layer.source, &request.target, &target_path))
253            .with_context(|| format!("Unable to find written layer {} in reloaded stack", target_path.display()))?;
254
255        let effective_value = get_value_by_path(&effective_toml, &request.path).cloned();
256        let overridden_metadata = if let Some(origin) = origins.get(&request.path) {
257            if origin.version != written_layer.metadata.version {
258                let source = reloaded_manager
259                    .layer_stack()
260                    .layers()
261                    .iter()
262                    .find(|layer| layer.metadata.name == origin.name)
263                    .map(|layer| layer.source.clone())
264                    .unwrap_or(ConfigLayerSource::Runtime);
265
266                effective_value.clone().map(|value| OverrideMetadata {
267                    source,
268                    metadata: origin.clone(),
269                    effective_value: value,
270                })
271            } else {
272                None
273            }
274        } else {
275            None
276        };
277
278        Ok(ConfigWriteResponse {
279            merged_version: merged_version(reloaded_manager.layer_stack().layers()),
280            written_layer_version: written_layer.metadata.version.clone(),
281            effective_value,
282            overridden_metadata,
283        })
284    }
285
286    /// Clear one configuration layer and reload the effective configuration.
287    ///
288    /// The selected file is replaced with an empty TOML document rather than
289    /// deleting a directory or touching credential storage. Existing symlinks
290    /// and non-regular files are rejected by the same private atomic writer
291    /// used for normal configuration writes.
292    pub fn reset(request: ConfigResetRequest) -> Result<ConfigResetResponse> {
293        ConfigManager::invalidate_workspace_cache(&request.workspace);
294        let manager_result = ConfigManager::load_from_workspace_with_repository_repair(&request.workspace);
295        let (target, target_path) = match request.path.as_deref() {
296            Some(requested_path) => {
297                resolve_requested_reset_path(&request.workspace, manager_result.as_ref().ok(), requested_path)?
298            }
299            None => {
300                let path = match &manager_result {
301                    Ok(manager) => resolve_target_path_from_manager(manager, &request.workspace, &request.target)?,
302                    Err(_) => resolve_target_path_without_manager(&request.workspace, &request.target)?,
303                };
304                (request.target, path)
305            }
306        };
307
308        let previous_layer_version = manager_result.as_ref().ok().and_then(|manager| {
309            manager
310                .layer_stack()
311                .layers()
312                .iter()
313                .find(|layer| source_matches_target(&layer.source, &target, &target_path))
314                .map(|layer| layer.metadata.version.clone())
315        });
316
317        if let Some(expected) = request.expected_layer_version.as_ref()
318            && previous_layer_version.as_ref() != Some(expected)
319        {
320            bail!(
321                "Layer version mismatch for {} (expected {}, got {})",
322                target_path.display(),
323                expected,
324                previous_layer_version.clone().unwrap_or_else(|| "<missing>".to_string())
325            );
326        }
327
328        let had_file = match fs::symlink_metadata(&target_path) {
329            Ok(metadata) if metadata.file_type().is_symlink() => {
330                bail!("Refusing to reset symlinked config file: {}", target_path.display())
331            }
332            Ok(metadata) if !metadata.is_file() => {
333                bail!("Config path is not a regular file: {}", target_path.display())
334            }
335            Ok(_) => true,
336            Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
337            Err(error) => {
338                return Err(error).with_context(|| format!("Failed to inspect config: {}", target_path.display()));
339            }
340        };
341
342        let is_explicit_session_path = explicit_config_path()
343            .as_deref()
344            .is_some_and(|explicit_path| same_config_path(explicit_path, &target_path));
345        if had_file || is_explicit_session_path {
346            VtCodePaths::write_private_file_atomic(&target_path, b"")
347                .with_context(|| format!("Failed to reset configuration file {}", target_path.display()))?;
348        }
349
350        ConfigManager::invalidate_workspace_cache(&request.workspace);
351        let reloaded_manager = ConfigManager::load_from_workspace_with_repository_repair(&request.workspace)
352            .context("Failed to reload configuration after reset")?;
353        let (effective_toml, _) = reloaded_manager.layer_stack().effective_config_with_origins();
354        let effective_config =
355            serde_json::to_value(&effective_toml).context("Failed to serialize effective configuration after reset")?;
356
357        Ok(ConfigResetResponse {
358            target,
359            path: target_path,
360            had_file,
361            previous_layer_version,
362            merged_version: merged_version(reloaded_manager.layer_stack().layers()),
363            effective_config,
364        })
365    }
366}
367
368fn merged_version(layers: &[vtcode_config::loader::layers::ConfigLayerEntry]) -> String {
369    let mut parts = Vec::with_capacity(layers.len());
370    for layer in layers {
371        if !layer.is_enabled() {
372            continue;
373        }
374        parts.push(format!("{}:{}", layer.metadata.name, layer.metadata.version));
375    }
376    fingerprint_str(&parts.join("|"))
377}
378
379fn resolve_target_path_from_manager(
380    manager: &ConfigManager,
381    workspace: &Path,
382    target: &ConfigWriteTarget,
383) -> Result<PathBuf> {
384    match target {
385        ConfigWriteTarget::Workspace => {
386            // Keep the configured spelling of the path instead of using the
387            // canonicalized layer metadata. That preserves the final symlink
388            // boundary for the no-follow writer to validate.
389            if let Some(path) = explicit_config_path() {
390                return Ok(path);
391            }
392
393            let provider = defaults::current_config_defaults();
394            let workspace_root = manager.workspace_root().unwrap_or(workspace);
395            let workspace_paths = provider.workspace_paths_for(workspace_root);
396            let fallback = workspace_paths.config_dir().join(manager.config_file_name());
397            let root = workspace_root.join(manager.config_file_name());
398            if path_entry_exists(&root) || !path_entry_exists(&fallback) {
399                Ok(root)
400            } else {
401                Ok(fallback)
402            }
403        }
404        ConfigWriteTarget::User => {
405            let provider = defaults::current_config_defaults();
406            provider
407                .canonical_user_config_path(manager.config_file_name())?
408                .context("Could not resolve the canonical user configuration path")
409        }
410        ConfigWriteTarget::Project => {
411            let provider = defaults::current_config_defaults();
412            let workspace_root = manager.workspace_root().unwrap_or(workspace);
413            let workspace_paths = provider.workspace_paths_for(workspace_root);
414            let config_dir = workspace_paths.config_dir();
415            let project_name = ConfigManager::current_project_name(workspace_root)
416                .context("Could not resolve project name for project-level config")?;
417            Ok(config_dir
418                .join("projects")
419                .join(project_name)
420                .join("config")
421                .join(manager.config_file_name()))
422        }
423    }
424}
425
426fn resolve_target_path_without_manager(workspace: &Path, target: &ConfigWriteTarget) -> Result<PathBuf> {
427    let provider = defaults::current_config_defaults();
428    let config_file_name = provider.config_file_name().to_string();
429
430    match target {
431        ConfigWriteTarget::Workspace => {
432            if let Some(path) = explicit_config_path() {
433                return Ok(path);
434            }
435
436            let workspace_paths = provider.workspace_paths_for(workspace);
437            let fallback = workspace_paths.config_dir().join(&config_file_name);
438            let root = workspace.join(&config_file_name);
439            if path_entry_exists(&root) || !path_entry_exists(&fallback) {
440                Ok(root)
441            } else {
442                Ok(fallback)
443            }
444        }
445        ConfigWriteTarget::User => provider
446            .canonical_user_config_path(&config_file_name)?
447            .context("Could not resolve the canonical user configuration path"),
448        ConfigWriteTarget::Project => {
449            let workspace_paths = provider.workspace_paths_for(workspace);
450            let project_name = ConfigManager::current_project_name(workspace)
451                .context("Could not resolve project name for project-level config")?;
452            Ok(workspace_paths
453                .config_dir()
454                .join("projects")
455                .join(project_name)
456                .join("config")
457                .join(config_file_name))
458        }
459    }
460}
461
462fn resolve_requested_reset_path(
463    workspace: &Path,
464    manager: Option<&ConfigManager>,
465    requested_path: &Path,
466) -> Result<(ConfigWriteTarget, PathBuf)> {
467    let mut candidates = Vec::new();
468    let provider = defaults::current_config_defaults();
469    let config_file_name = manager
470        .map(|loaded| loaded.config_file_name().to_string())
471        .unwrap_or_else(|| provider.config_file_name().to_string());
472
473    if let Some(explicit_path) = explicit_config_path() {
474        candidates.push((ConfigWriteTarget::Workspace, explicit_path));
475    }
476
477    let workspace_root = manager.and_then(ConfigManager::workspace_root).unwrap_or(workspace);
478    let workspace_paths = provider.workspace_paths_for(workspace_root);
479    let fallback_path = workspace_paths.config_dir().join(&config_file_name);
480    let workspace_path = workspace_root.join(&config_file_name);
481    candidates.push((ConfigWriteTarget::Workspace, fallback_path));
482    candidates.push((ConfigWriteTarget::Workspace, workspace_path));
483
484    if let Some(project_name) = ConfigManager::current_project_name(workspace_root) {
485        candidates.push((
486            ConfigWriteTarget::Project,
487            workspace_paths
488                .config_dir()
489                .join("projects")
490                .join(project_name)
491                .join("config")
492                .join(&config_file_name),
493        ));
494    }
495
496    let mut user_paths = provider.home_config_paths(&config_file_name);
497    if let Some(canonical_path) = provider.canonical_user_config_path(&config_file_name)?
498        && !user_paths.iter().any(|path| same_config_path(path, &canonical_path))
499    {
500        user_paths.push(canonical_path);
501    }
502    if let Some(loaded) = manager {
503        user_paths.extend(loaded.user_config_paths());
504    }
505    for user_path in user_paths {
506        if !candidates.iter().any(|(_, existing)| same_config_path(existing, &user_path)) {
507            candidates.push((ConfigWriteTarget::User, user_path));
508        }
509    }
510
511    candidates
512        .into_iter()
513        .find(|(_, candidate)| same_config_path(requested_path, candidate))
514        .ok_or_else(|| {
515            anyhow::anyhow!(
516                "Requested reset path {} is not a known writable VT Code configuration layer",
517                requested_path.display()
518            )
519        })
520}
521
522fn source_matches_target(source: &ConfigLayerSource, target: &ConfigWriteTarget, path: &Path) -> bool {
523    match (source, target) {
524        (ConfigLayerSource::User { file }, ConfigWriteTarget::User) => same_config_path(file, path),
525        (ConfigLayerSource::Workspace { file }, ConfigWriteTarget::Workspace) => same_config_path(file, path),
526        (ConfigLayerSource::Project { file }, ConfigWriteTarget::Project) => same_config_path(file, path),
527        _ => false,
528    }
529}
530
531fn save_config_for_target(target: ConfigWriteTarget, path: &Path, config: &VTCodeConfig) -> Result<()> {
532    let explicit_session_path = explicit_config_path()
533        .as_deref()
534        .is_some_and(|explicit_path| same_config_path(explicit_path, path));
535    let repository_controlled = match target {
536        ConfigWriteTarget::Project => true,
537        ConfigWriteTarget::Workspace => !explicit_session_path,
538        ConfigWriteTarget::User => false,
539    };
540
541    if repository_controlled {
542        ConfigManager::save_repository_config_to_path(path, config)
543    } else {
544        ConfigManager::save_config_to_path(path, config)
545    }
546}
547
548fn same_config_path(left: &Path, right: &Path) -> bool {
549    let left = canonicalize(left).unwrap_or_else(|_| normalize_path(left));
550    let right = canonicalize(right).unwrap_or_else(|_| normalize_path(right));
551    left == right
552}
553
554fn path_entry_exists(path: &Path) -> bool {
555    match fs::symlink_metadata(path) {
556        Ok(_) => true,
557        Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
558        Err(_) => true,
559    }
560}
561
562fn load_or_default_toml(path: &Path) -> Result<TomlValue> {
563    if !path.exists() {
564        return Ok(TomlValue::Table(toml::Table::new()));
565    }
566
567    let content = fs::read_to_string(path).with_context(|| format!("Failed to read config file {}", path.display()))?;
568    toml::from_str(&content).with_context(|| format!("Failed to parse config file {}", path.display()))
569}
570
571fn apply_write(root: &mut TomlValue, path: &str, value: &TomlValue, strategy: ConfigWriteStrategy) -> Result<()> {
572    let existing = get_or_create_path_mut(root, path)?;
573    match strategy {
574        ConfigWriteStrategy::Replace => {
575            *existing = value.clone();
576        }
577        ConfigWriteStrategy::Upsert => {
578            if existing.is_table() && value.is_table() {
579                merge_toml_values(existing, value);
580            } else {
581                *existing = value.clone();
582            }
583        }
584    }
585    Ok(())
586}
587
588fn get_or_create_path_mut<'a>(root: &'a mut TomlValue, path: &str) -> Result<&'a mut TomlValue> {
589    let mut current = root;
590    let parts: Vec<&str> = path.split('.').filter(|part| !part.is_empty()).collect();
591    if parts.is_empty() {
592        bail!("Invalid empty config path");
593    }
594
595    for (index, part) in parts.iter().enumerate() {
596        let is_last = index == parts.len() - 1;
597        let table = current
598            .as_table_mut()
599            .ok_or_else(|| anyhow::anyhow!("Path '{path}' traverses non-table value"))?;
600
601        if is_last {
602            let entry = table
603                .entry((*part).to_string())
604                .or_insert_with(|| TomlValue::Table(toml::Table::new()));
605            return Ok(entry);
606        }
607
608        current = table
609            .entry((*part).to_string())
610            .or_insert_with(|| TomlValue::Table(toml::Table::new()));
611    }
612
613    bail!("Could not resolve config path '{path}'")
614}
615
616fn get_value_by_path<'a>(root: &'a TomlValue, path: &str) -> Option<&'a TomlValue> {
617    let mut current = root;
618    for part in path.split('.').filter(|part| !part.is_empty()) {
619        let table = current.as_table()?;
620        current = table.get(part)?;
621    }
622    Some(current)
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    use std::sync::Arc;
630
631    use serial_test::serial;
632    use vtcode_commons::reference::StaticWorkspacePaths;
633    use vtcode_config::defaults::WorkspacePathsDefaults;
634    use vtcode_config::defaults::provider::with_config_defaults_provider_for_test;
635    use vtcode_config::loader::set_explicit_config_path;
636
637    #[test]
638    #[serial]
639    fn read_returns_layers_and_origins() {
640        let temp = tempfile::tempdir().expect("tempdir");
641        let workspace = temp.path();
642        let home_config = workspace.join("home").join("vtcode.toml");
643        let workspace_config = workspace.join("vtcode.toml");
644        fs::create_dir_all(home_config.parent().expect("home parent")).expect("home dir");
645
646        fs::write(&home_config, "agent.provider = \"openai\"\n").expect("home config");
647        fs::write(&workspace_config, "agent.provider = \"anthropic\"\nagent.default_model = \"claude-sonnet-4-6\"\n")
648            .expect("workspace config");
649
650        let static_paths = StaticWorkspacePaths::new(workspace, workspace.join(".vtcode"));
651        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths)).with_home_paths(vec![home_config]);
652
653        with_config_defaults_provider_for_test(Arc::new(provider), || {
654            let response = ConfigService::read(ConfigReadRequest {
655                workspace: workspace.to_path_buf(),
656                runtime_overrides: Vec::new(),
657            })
658            .expect("read response");
659
660            assert!(!response.layers.is_empty());
661            assert!(!response.merged_version.is_empty());
662            assert!(response.origins.contains_key("agent.provider"));
663        });
664    }
665
666    #[test]
667    #[serial]
668    fn write_reports_override_when_higher_layer_wins() {
669        let temp = tempfile::tempdir().expect("tempdir");
670        let workspace = temp.path();
671        let home_config = workspace.join("home").join("vtcode.toml");
672        let workspace_config = workspace.join("vtcode.toml");
673        fs::create_dir_all(home_config.parent().expect("home parent")).expect("home dir");
674
675        fs::write(&home_config, "agent.provider = \"openai\"\n").expect("home config");
676        fs::write(&workspace_config, "agent.provider = \"gemini\"\n").expect("workspace config");
677
678        let static_paths = StaticWorkspacePaths::new(workspace, workspace.join(".vtcode"));
679        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths)).with_home_paths(vec![home_config]);
680
681        with_config_defaults_provider_for_test(Arc::new(provider), || {
682            let response = ConfigService::write(ConfigWriteRequest {
683                workspace: workspace.to_path_buf(),
684                target: ConfigWriteTarget::User,
685                path: "agent.provider".to_string(),
686                value: TomlValue::String("anthropic".to_string()),
687                strategy: ConfigWriteStrategy::Replace,
688                expected_layer_version: None,
689            })
690            .expect("write response");
691
692            assert_eq!(response.effective_value, Some(TomlValue::String("gemini".to_string())));
693            assert!(response.overridden_metadata.is_some());
694        });
695    }
696
697    #[test]
698    #[serial]
699    fn repository_target_write_does_not_persist_provider_endpoint_or_credentials() {
700        let temp = tempfile::tempdir().expect("tempdir");
701        let workspace = temp.path();
702        let home_config = workspace.join("home/vtcode.toml");
703        let workspace_config = workspace.join("vtcode.toml");
704        fs::create_dir_all(home_config.parent().expect("home parent")).expect("home dir");
705
706        fs::write(
707            &home_config,
708            "[provider_overrides.openai]\nbase_url = \"https://trusted.example/v1\"\napi_key_env = \"TRUSTED_API_KEY\"\n",
709        )
710        .expect("home config");
711        fs::write(&workspace_config, "agent.provider = \"openai\"\n").expect("workspace config");
712
713        let static_paths = StaticWorkspacePaths::new(workspace, workspace.join(".vtcode"));
714        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
715            .with_home_paths(vec![home_config])
716            .with_system_config_paths(Vec::new());
717
718        with_config_defaults_provider_for_test(Arc::new(provider), || {
719            let response = ConfigService::write(ConfigWriteRequest {
720                workspace: workspace.to_path_buf(),
721                target: ConfigWriteTarget::Workspace,
722                path: "provider_overrides.openai.base_url".to_string(),
723                value: TomlValue::String("https://attacker.example/v1".to_string()),
724                strategy: ConfigWriteStrategy::Replace,
725                expected_layer_version: None,
726            })
727            .expect("repository write should complete");
728
729            assert_eq!(response.effective_value, Some(TomlValue::String("https://trusted.example/v1".to_string())));
730            let workspace_content = fs::read_to_string(&workspace_config).expect("workspace config");
731            assert!(!workspace_content.contains("attacker.example"));
732            assert!(!workspace_content.contains("base_url"));
733            assert!(!workspace_content.contains("api_key_env"));
734        });
735    }
736
737    #[test]
738    #[serial]
739    fn user_writes_target_the_canonical_path_not_the_legacy_fallback() {
740        let temp = tempfile::tempdir().expect("tempdir");
741        let workspace = temp.path();
742        let legacy_path = workspace.join("legacy").join("vtcode.toml");
743        let canonical_path = workspace.join("xdg").join("vtcode.toml");
744        fs::create_dir_all(legacy_path.parent().expect("legacy parent")).expect("legacy dir");
745        fs::write(&legacy_path, "agent.provider = \"openai\"\n").expect("legacy config");
746
747        let static_paths = StaticWorkspacePaths::new(workspace, workspace.join(".vtcode"));
748        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
749            .with_home_paths(vec![legacy_path.clone(), canonical_path.clone()]);
750
751        with_config_defaults_provider_for_test(Arc::new(provider), || {
752            ConfigService::write(ConfigWriteRequest {
753                workspace: workspace.to_path_buf(),
754                target: ConfigWriteTarget::User,
755                path: "agent.provider".to_string(),
756                value: TomlValue::String("anthropic".to_string()),
757                strategy: ConfigWriteStrategy::Replace,
758                expected_layer_version: None,
759            })
760            .expect("write response");
761
762            assert!(canonical_path.exists(), "the canonical user config should be created");
763            assert_eq!(fs::read_to_string(&legacy_path).expect("legacy config"), "agent.provider = \"openai\"\n");
764        });
765    }
766
767    #[test]
768    #[serial]
769    fn write_rejects_stale_expected_version() {
770        let temp = tempfile::tempdir().expect("tempdir");
771        let workspace = temp.path();
772        let workspace_config = workspace.join("vtcode.toml");
773        fs::write(&workspace_config, "agent.provider = \"openai\"\n\n[workspace]\nuse_root_config = true\n")
774            .expect("workspace config");
775
776        let response = ConfigService::write(ConfigWriteRequest {
777            workspace: workspace.to_path_buf(),
778            target: ConfigWriteTarget::Workspace,
779            path: "agent.provider".to_string(),
780            value: TomlValue::String("anthropic".to_string()),
781            strategy: ConfigWriteStrategy::Replace,
782            expected_layer_version: Some("stale-version".to_string()),
783        });
784
785        assert!(response.is_err());
786        let error = format!("{:#}", response.expect_err("expected stale version error"));
787        assert!(error.contains("Layer version mismatch"));
788    }
789
790    #[test]
791    #[serial]
792    fn reset_workspace_layer_preserves_lower_precedence_user_values() {
793        let temp = tempfile::tempdir().expect("tempdir");
794        let workspace = temp.path();
795        let user_config = workspace.join("home").join("vtcode.toml");
796        let workspace_config = workspace.join("vtcode.toml");
797        fs::create_dir_all(user_config.parent().expect("user parent")).expect("user dir");
798        fs::write(&user_config, "agent.provider = \"openai\"\n").expect("user config");
799        fs::write(&workspace_config, "agent.provider = \"anthropic\"\n").expect("workspace config");
800
801        let static_paths = StaticWorkspacePaths::new(workspace, workspace.join(".vtcode"));
802        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths)).with_home_paths(vec![user_config]);
803
804        with_config_defaults_provider_for_test(Arc::new(provider), || {
805            let response = ConfigService::reset(ConfigResetRequest {
806                workspace: workspace.to_path_buf(),
807                target: ConfigWriteTarget::Workspace,
808                expected_layer_version: None,
809                path: None,
810            })
811            .expect("workspace reset");
812
813            assert_eq!(response.target, ConfigWriteTarget::Workspace);
814            assert!(response.had_file);
815            assert!(fs::read_to_string(&workspace_config).expect("reset file").trim().is_empty());
816
817            let effective: VTCodeConfig = serde_json::from_value(response.effective_config).expect("effective config");
818            assert_eq!(effective.agent.provider, "openai");
819        });
820    }
821
822    #[test]
823    #[serial]
824    fn reset_canonical_user_layer_preserves_legacy_user_layer() {
825        let temp = tempfile::tempdir().expect("tempdir");
826        let workspace = temp.path();
827        let legacy_config = workspace.join("legacy").join("vtcode.toml");
828        let canonical_config = workspace.join("canonical").join("vtcode.toml");
829        fs::create_dir_all(legacy_config.parent().expect("legacy parent")).expect("legacy dir");
830        fs::create_dir_all(canonical_config.parent().expect("canonical parent")).expect("canonical dir");
831        fs::write(&legacy_config, "agent.provider = \"openai\"\n").expect("legacy config");
832        fs::write(&canonical_config, "agent.provider = \"anthropic\"\n").expect("canonical config");
833
834        let static_paths = StaticWorkspacePaths::new(workspace, workspace.join(".vtcode"));
835        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
836            .with_home_paths(vec![legacy_config.clone(), canonical_config.clone()]);
837
838        with_config_defaults_provider_for_test(Arc::new(provider), || {
839            let response = ConfigService::reset(ConfigResetRequest {
840                workspace: workspace.to_path_buf(),
841                target: ConfigWriteTarget::User,
842                expected_layer_version: None,
843                path: None,
844            })
845            .expect("user reset");
846
847            assert_eq!(response.path, canonical_config);
848            assert!(canonical_config.exists());
849            assert_eq!(fs::read_to_string(&legacy_config).expect("legacy config"), "agent.provider = \"openai\"\n");
850            let effective: VTCodeConfig = serde_json::from_value(response.effective_config).expect("effective config");
851            assert_eq!(effective.agent.provider, "openai");
852        });
853    }
854
855    #[test]
856    #[serial]
857    fn reset_project_layer_preserves_workspace_values() {
858        let temp = tempfile::tempdir().expect("tempdir");
859        let workspace = temp.path().join("workspace");
860        let config_dir = workspace.join(".vtcode");
861        let project_dir = config_dir.join("projects").join("demo").join("config");
862        fs::create_dir_all(&project_dir).expect("project config dir");
863        fs::write(workspace.join(".vtcode-project"), "demo\n").expect("project marker");
864        fs::write(workspace.join("vtcode.toml"), "agent.provider = \"openai\"\n").expect("workspace config");
865        let project_config = project_dir.join("vtcode.toml");
866        fs::write(&project_config, "agent.default_model = \"project-model\"\n").expect("project config");
867
868        let static_paths = StaticWorkspacePaths::new(&workspace, &config_dir);
869        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
870            .with_home_paths(Vec::new())
871            .with_system_config_paths(Vec::new());
872
873        with_config_defaults_provider_for_test(Arc::new(provider), || {
874            let response = ConfigService::reset(ConfigResetRequest {
875                workspace: workspace.clone(),
876                target: ConfigWriteTarget::Project,
877                expected_layer_version: None,
878                path: None,
879            })
880            .expect("project reset");
881
882            assert_eq!(response.target, ConfigWriteTarget::Project);
883            assert_eq!(response.path, project_config);
884            assert!(
885                fs::read_to_string(&project_config)
886                    .expect("reset project file")
887                    .trim()
888                    .is_empty()
889            );
890            let effective: VTCodeConfig = serde_json::from_value(response.effective_config).expect("effective config");
891            assert_eq!(effective.agent.provider, "openai");
892        });
893    }
894
895    #[test]
896    #[serial]
897    fn reset_workspace_target_uses_explicit_session_config_path() {
898        let temp = tempfile::tempdir().expect("tempdir");
899        let workspace = temp.path().join("workspace");
900        fs::create_dir_all(&workspace).expect("workspace dir");
901        let explicit_path = temp.path().join("night.toml");
902        fs::write(&explicit_path, "agent.provider = \"openai\"\n").expect("explicit config");
903
904        let static_paths = StaticWorkspacePaths::new(&workspace, workspace.join(".vtcode"));
905        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
906            .with_home_paths(Vec::new())
907            .with_system_config_paths(Vec::new());
908
909        with_config_defaults_provider_for_test(Arc::new(provider), || {
910            struct ExplicitConfigGuard(Option<PathBuf>);
911
912            impl Drop for ExplicitConfigGuard {
913                fn drop(&mut self) {
914                    set_explicit_config_path(self.0.take());
915                }
916            }
917
918            let _override = ExplicitConfigGuard(explicit_config_path());
919            set_explicit_config_path(Some(explicit_path.clone()));
920            let response = ConfigService::reset(ConfigResetRequest {
921                workspace,
922                target: ConfigWriteTarget::Workspace,
923                expected_layer_version: None,
924                path: None,
925            })
926            .expect("explicit config reset");
927
928            assert_eq!(response.target, ConfigWriteTarget::Workspace);
929            assert_eq!(response.path, explicit_path);
930            assert!(
931                fs::read_to_string(response.path)
932                    .expect("reset explicit file")
933                    .trim()
934                    .is_empty()
935            );
936        });
937    }
938
939    #[test]
940    #[serial]
941    fn reset_creates_missing_explicit_session_config_path() {
942        let temp = tempfile::tempdir().expect("tempdir");
943        let workspace = temp.path().join("workspace");
944        fs::create_dir_all(&workspace).expect("workspace dir");
945        let explicit_path = temp.path().join("new-session.toml");
946
947        let static_paths = StaticWorkspacePaths::new(&workspace, workspace.join(".vtcode"));
948        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
949            .with_home_paths(Vec::new())
950            .with_system_config_paths(Vec::new());
951
952        with_config_defaults_provider_for_test(Arc::new(provider), || {
953            struct ExplicitConfigGuard(Option<PathBuf>);
954
955            impl Drop for ExplicitConfigGuard {
956                fn drop(&mut self) {
957                    set_explicit_config_path(self.0.take());
958                }
959            }
960
961            let _override = ExplicitConfigGuard(explicit_config_path());
962            set_explicit_config_path(Some(explicit_path.clone()));
963            let response = ConfigService::reset(ConfigResetRequest {
964                workspace,
965                target: ConfigWriteTarget::Workspace,
966                expected_layer_version: None,
967                path: None,
968            })
969            .expect("missing explicit config reset");
970
971            assert!(!response.had_file);
972            assert_eq!(fs::read_to_string(&explicit_path).expect("created config"), "");
973        });
974    }
975
976    #[cfg(unix)]
977    #[test]
978    #[serial]
979    fn reset_rejects_symlinked_workspace_config() {
980        use std::os::unix::fs::symlink;
981
982        let temp = tempfile::tempdir().expect("tempdir");
983        let workspace = temp.path().join("workspace");
984        let outside = temp.path().join("outside.toml");
985        fs::create_dir_all(&workspace).expect("workspace");
986        fs::write(&outside, "agent.provider = \"openai\"\n").expect("outside config");
987        symlink(&outside, workspace.join("vtcode.toml")).expect("config symlink");
988
989        let response = ConfigService::reset(ConfigResetRequest {
990            workspace,
991            target: ConfigWriteTarget::Workspace,
992            expected_layer_version: None,
993            path: None,
994        });
995
996        let error = format!("{:#}", response.expect_err("symlink reset must fail"));
997        assert!(error.contains("symlink"));
998        assert!(outside.exists(), "the symlink target must remain untouched");
999    }
1000
1001    #[cfg(unix)]
1002    #[test]
1003    #[serial]
1004    fn reset_rejects_dangling_workspace_symlink_instead_of_using_fallback() {
1005        use std::os::unix::fs::symlink;
1006
1007        let temp = tempfile::tempdir().expect("tempdir");
1008        let workspace = temp.path().join("workspace");
1009        let config_dir = workspace.join(".vtcode");
1010        let fallback = config_dir.join("vtcode.toml");
1011        let dangling_target = temp.path().join("missing.toml");
1012        fs::create_dir_all(&config_dir).expect("workspace config dir");
1013        fs::write(&fallback, "agent.provider = \"openai\"\n").expect("fallback config");
1014        symlink(&dangling_target, workspace.join("vtcode.toml")).expect("dangling config symlink");
1015
1016        let static_paths = StaticWorkspacePaths::new(&workspace, &config_dir);
1017        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
1018            .with_home_paths(Vec::new())
1019            .with_system_config_paths(Vec::new());
1020
1021        with_config_defaults_provider_for_test(Arc::new(provider), || {
1022            let response = ConfigService::reset(ConfigResetRequest {
1023                workspace: workspace.clone(),
1024                target: ConfigWriteTarget::Workspace,
1025                expected_layer_version: None,
1026                path: None,
1027            });
1028
1029            let error = format!("{:#}", response.expect_err("dangling symlink reset must fail"));
1030            assert!(error.contains("symlink"));
1031            assert_eq!(fs::read_to_string(&fallback).expect("fallback config"), "agent.provider = \"openai\"\n");
1032        });
1033    }
1034}