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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ConfigReadRequest {
20 pub workspace: PathBuf,
22 #[serde(default)]
24 pub runtime_overrides: Vec<(String, String)>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ConfigLayerView {
30 pub source: ConfigLayerSource,
32 pub metadata: ConfigLayerMetadata,
34 pub disabled_reason: Option<String>,
36 pub error: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ConfigReadResponse {
43 pub effective_config: serde_json::Value,
45 pub merged_version: String,
47 pub layers: Vec<ConfigLayerView>,
49 pub origins: BTreeMap<String, ConfigLayerMetadata>,
51}
52
53#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "snake_case")]
56pub enum ConfigWriteTarget {
57 User,
59 Workspace,
61 Project,
63}
64
65impl ConfigWriteTarget {
66 #[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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(rename_all = "snake_case")]
80pub enum ConfigWriteStrategy {
81 Replace,
83 Upsert,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ConfigWriteRequest {
90 pub workspace: PathBuf,
92 pub target: ConfigWriteTarget,
94 pub path: String,
96 pub value: TomlValue,
98 pub strategy: ConfigWriteStrategy,
100 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ConfigResetRequest {
128 pub workspace: PathBuf,
131 pub target: ConfigWriteTarget,
134 #[serde(default)]
136 pub expected_layer_version: Option<String>,
137 #[serde(default)]
140 pub path: Option<PathBuf>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ConfigResetResponse {
146 pub target: ConfigWriteTarget,
148 pub path: PathBuf,
150 pub had_file: bool,
152 pub previous_layer_version: Option<String>,
154 pub merged_version: String,
156 pub effective_config: serde_json::Value,
158}
159
160pub struct ConfigService;
161
162impl ConfigService {
163 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 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 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}