1mod persistence;
2
3use std::collections::HashMap;
4use std::fmt;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, Mutex};
8
9use anyhow::{Context, Result, bail};
10
11use crate::api_keys::{api_key_env_var, credential_metadata_key, store_credential_with_mode};
12use crate::defaults::{self};
13use crate::hooks::{HooksConfig, LifecycleHooksConfig, WorkspaceHookCommand, WorkspaceLifecycleHooks};
14use crate::loader::config::VTCodeConfig;
15use crate::loader::layers::{
16 ConfigLayerEntry, ConfigLayerMetadata, ConfigLayerSource, ConfigLayerStack, LayerDisabledReason,
17};
18use crate::loader::session_override;
19use vtcode_commons::VtCodePaths;
20use vtcode_commons::canonicalize;
21
22type CachedManager = Arc<ConfigManager>;
23
24#[derive(Debug)]
25struct RepositoryProviderSecurityViolation {
26 source: ConfigLayerSource,
27 message: String,
28}
29
30impl fmt::Display for RepositoryProviderSecurityViolation {
31 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32 self.message.fmt(formatter)
33 }
34}
35
36impl std::error::Error for RepositoryProviderSecurityViolation {}
37
38#[cfg(not(test))]
39static WORKSPACE_CACHE: Mutex<Option<HashMap<PathBuf, CachedManager>>> = Mutex::new(None);
40
41#[cfg(not(test))]
42fn with_cache_mut(f: impl FnOnce(&mut HashMap<PathBuf, CachedManager>)) {
43 let mut guard = WORKSPACE_CACHE.lock().expect("config cache lock poisoned");
44 guard.get_or_insert_with(HashMap::new);
45 f(guard.as_mut().expect("cache initialized"))
46}
47
48#[cfg(not(test))]
49fn cache_get(workspace: &Path) -> Option<CachedManager> {
50 WORKSPACE_CACHE
51 .lock()
52 .expect("config cache lock poisoned")
53 .as_ref()
54 .and_then(|map| map.get(workspace).cloned())
55}
56
57#[cfg(not(test))]
58fn cache_insert(workspace: PathBuf, manager: CachedManager) {
59 with_cache_mut(move |map| {
60 map.insert(workspace, manager);
61 });
62}
63
64#[cfg(not(test))]
65fn cache_remove(workspace: &Path) {
66 with_cache_mut(|map| {
67 map.remove(workspace);
68 });
69}
70
71pub(crate) fn canonicalize_workspace_root(path: &Path) -> PathBuf {
72 canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
73}
74
75fn push_unique_layer_source(layer_sources: &mut Vec<ConfigLayerSource>, source: ConfigLayerSource) {
76 let file = match &source {
77 ConfigLayerSource::System { file }
78 | ConfigLayerSource::User { file }
79 | ConfigLayerSource::Project { file }
80 | ConfigLayerSource::Workspace { file } => file,
81 ConfigLayerSource::Runtime => {
82 layer_sources.push(source);
83 return;
84 }
85 };
86 if !layer_sources.iter().any(|existing| {
87 matches!(existing,
88 ConfigLayerSource::System { file: existing_file }
89 | ConfigLayerSource::User { file: existing_file }
90 | ConfigLayerSource::Project { file: existing_file }
91 | ConfigLayerSource::Workspace { file: existing_file }
92 if existing_file == file)
93 }) {
94 layer_sources.push(source);
95 }
96}
97
98fn is_optional_global_layer(source: &ConfigLayerSource) -> bool {
99 matches!(source, ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. })
100}
101
102fn should_skip_optional_global_error(source: &ConfigLayerSource, error: &std::io::Error) -> bool {
103 is_optional_global_layer(source) && error.kind() != std::io::ErrorKind::InvalidData
104}
105
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
108pub struct ConfigPhaseTiming {
109 pub path_resolution_us: u64,
111 pub layer_loading_us: u64,
113 pub merge_and_parse_us: u64,
115 pub validation_us: u64,
117}
118
119#[derive(Clone)]
121pub struct ConfigManager {
122 pub(crate) config: VTCodeConfig,
123 config_path: Option<PathBuf>,
124 canonical_user_config_path: Option<PathBuf>,
125 tracked_user_config_paths: Vec<PathBuf>,
126 write_global_config_to_canonical: bool,
127 explicit_config_is_trusted: bool,
132 workspace_root: Option<PathBuf>,
133 config_file_name: String,
134 pub(crate) layer_stack: ConfigLayerStack,
135 phase_timing: Option<ConfigPhaseTiming>,
136}
137
138impl ConfigManager {
139 pub fn load() -> Result<Self> {
141 if let Some(override_path) = session_override::explicit_config_path() {
142 return Self::load_for_session(std::env::current_dir()?, &override_path).with_context(|| {
143 format!("Failed to load configuration from explicit path {}", override_path.display())
144 });
145 }
146
147 Self::load_from_workspace_with_repository_repair(std::env::current_dir()?)
148 }
149
150 pub fn load_for_session(workspace: impl AsRef<Path>, explicit_path: impl AsRef<Path>) -> Result<Self> {
160 let workspace = workspace.as_ref();
161 let explicit_path = explicit_path.as_ref();
162 #[cfg(not(test))]
163 let canonical_workspace = canonicalize_workspace_root(workspace);
164
165 #[cfg(not(test))]
166 if let Some(cached) = cache_get(&canonical_workspace) {
167 return Ok(cached.as_ref().clone());
168 }
169
170 let mut manager = Self::load_from_file_impl(explicit_path, false).with_context(|| {
171 format!(
172 "Failed to load explicit session config file {} (from --config / VTCODE_CONFIG_PATH)",
173 explicit_path.display()
174 )
175 })?;
176 manager.workspace_root = Some(canonicalize_workspace_root(workspace));
179
180 #[cfg(not(test))]
181 cache_insert(canonical_workspace, Arc::new(manager.clone()));
182
183 Ok(manager)
184 }
185
186 pub fn load_global() -> Result<Self> {
192 let defaults_provider = defaults::current_config_defaults();
193 let config_file_name = defaults_provider.config_file_name().to_string();
194 let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
195 let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
196 if let Some(path) = &canonical_user_config_path
197 && !tracked_user_config_paths.iter().any(|existing| existing == path)
198 {
199 tracked_user_config_paths.push(path.clone());
200 }
201
202 let mut layer_sources = Vec::new();
203 for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
204 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
205 }
206 for home_config_path in &tracked_user_config_paths {
207 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
208 }
209
210 let mut layer_stack = ConfigLayerStack::default();
211 for source in layer_sources {
212 if let Some(layer) = Self::load_optional_layer(source) {
213 layer_stack.push(layer);
214 }
215 }
216
217 if let Some((layer, error)) = layer_stack.first_layer_error() {
218 bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
219 }
220
221 let (config, config_path) = if layer_stack.layers().is_empty() {
222 let config = VTCodeConfig::default();
223 config.validate().context("Default configuration failed validation")?;
224 (config, None)
225 } else {
226 let (effective_toml, origins) = layer_stack.effective_config_with_origins();
227 let mut config: VTCodeConfig = effective_toml
228 .try_into()
229 .context("Failed to deserialize effective global configuration")?;
230 Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
231 Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
232 config.validate().context("Global configuration failed validation")?;
233 config.workspace_lifecycle_hooks =
234 Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
235 let config_path = layer_stack
236 .layers()
237 .iter()
238 .rev()
239 .find(|layer| layer.is_enabled())
240 .and_then(|layer| match &layer.source {
241 ConfigLayerSource::System { file } | ConfigLayerSource::User { file } => Some(file.clone()),
242 _ => None,
243 });
244 (config, config_path)
245 };
246
247 Ok(Self {
248 config,
249 config_path,
250 canonical_user_config_path,
251 tracked_user_config_paths,
252 write_global_config_to_canonical: false,
253 explicit_config_is_trusted: false,
254 workspace_root: None,
255 config_file_name,
256 layer_stack,
257 phase_timing: None,
258 })
259 }
260
261 pub fn invalidate_workspace_cache(workspace: impl AsRef<Path>) {
267 let workspace = workspace.as_ref();
268 #[cfg(not(test))]
269 {
270 let canonical_workspace = canonicalize_workspace_root(workspace);
271 cache_remove(&canonical_workspace);
272 }
273 #[cfg(test)]
274 let _ = workspace;
275 }
276
277 pub fn invalidate_all_workspace_cache() {
284 #[cfg(not(test))]
285 with_cache_mut(|map| map.clear());
286 }
287
288 pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
295 if let Some(override_path) = session_override::explicit_config_path() {
296 return Self::load_for_session(workspace, override_path);
297 }
298
299 let workspace = workspace.as_ref();
300 #[cfg(not(test))]
301 let canonical_workspace = canonicalize_workspace_root(workspace);
302
303 #[cfg(not(test))]
304 if let Some(cached) = cache_get(&canonical_workspace) {
305 return Ok(cached.as_ref().clone());
306 }
307
308 let manager = Self::load_from_workspace_impl(workspace)?;
309
310 #[cfg(not(test))]
311 cache_insert(canonical_workspace, Arc::new(manager.clone()));
312
313 Ok(manager)
314 }
315
316 pub fn load_from_workspace_with_repository_repair(workspace: impl AsRef<Path>) -> Result<Self> {
327 let workspace = workspace.as_ref();
328 let mut error = match Self::load_from_workspace(workspace) {
329 Ok(manager) => return Ok(manager),
330 Err(error) => error,
331 };
332
333 let repository_paths = Self::repository_config_paths(workspace);
337 for _ in 0..=repository_paths.len() {
338 let Some(violation) = error.downcast_ref::<RepositoryProviderSecurityViolation>() else {
339 return Err(error);
340 };
341
342 let source = violation.source.clone();
343 let Some(path) = repository_paths
344 .iter()
345 .find(|candidate| Self::repository_source_matches_path(&source, candidate))
346 else {
347 return Err(error);
348 };
349
350 if !Self::repair_repository_config_file(path)? {
351 return Err(error);
352 }
353
354 tracing::warn!(
355 path = %path.display(),
356 "Removed prohibited provider settings left by an older repository configuration write"
357 );
358
359 Self::invalidate_workspace_cache(workspace);
360 match Self::load_from_workspace(workspace) {
361 Ok(manager) => return Ok(manager),
362 Err(next_error) => error = next_error,
363 }
364 }
365
366 Err(error).context("Failed to load workspace configuration after repairing prohibited provider settings")
367 }
368
369 fn load_from_workspace_impl(workspace: impl AsRef<Path>) -> Result<Self> {
370 let t0 = std::time::Instant::now();
371 let workspace = workspace.as_ref();
372 let defaults_provider = defaults::current_config_defaults();
373 let workspace_paths = defaults_provider.workspace_paths_for(workspace);
374 let workspace_root = canonicalize_workspace_root(workspace_paths.workspace_root());
375 let config_dir = workspace_paths.config_dir();
376 let config_file_name = defaults_provider.config_file_name().to_string();
377 let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
378 let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
379 if let Some(path) = &canonical_user_config_path
380 && !tracked_user_config_paths.iter().any(|existing| existing == path)
381 {
382 tracked_user_config_paths.push(path.clone());
383 }
384 let path_res_duration = t0.elapsed();
385
386 let t1 = std::time::Instant::now();
387
388 let mut layer_sources: Vec<ConfigLayerSource> = Vec::with_capacity(8);
391
392 for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
395 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
396 }
397
398 for home_config_path in &tracked_user_config_paths {
400 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
401 }
402
403 if let Some(project_config_path) = Self::project_config_path(&config_dir, &workspace_root, &config_file_name) {
405 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Project { file: project_config_path });
406 }
407
408 let fallback_path = config_dir.join(&config_file_name);
410 let workspace_config_path = workspace_root.join(&config_file_name);
411 if fallback_path != workspace_config_path {
412 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Workspace { file: fallback_path });
413 }
414
415 push_unique_layer_source(
417 &mut layer_sources,
418 ConfigLayerSource::Workspace { file: workspace_config_path.clone() },
419 );
420
421 let raw_layers: Vec<Option<ConfigLayerEntry>> = std::thread::scope(|s| {
426 let handles = layer_sources
427 .into_iter()
428 .map(|source| s.spawn(move || Self::load_optional_layer(source)))
429 .collect::<Vec<_>>();
430 let joined = handles.into_iter().map(|h| h.join()).collect::<Vec<_>>();
432 joined
433 .into_iter()
434 .map(|r| r.map_err(|_payload| anyhow::anyhow!("config layer loader thread panicked")))
435 .collect::<Result<Vec<_>>>()
436 })?;
437
438 let mut layer_stack = ConfigLayerStack::default();
439 for layer in raw_layers.into_iter().flatten() {
440 layer_stack.push(layer);
441 }
442
443 let layer_load_duration = t1.elapsed();
444
445 if layer_stack.layers().is_empty() {
447 let t_val = std::time::Instant::now();
448 let config = VTCodeConfig::default();
449 config.validate().context("Default configuration failed validation")?;
450 let val_duration = t_val.elapsed();
451
452 let phase_timing = ConfigPhaseTiming {
453 path_resolution_us: path_res_duration.as_micros() as u64,
454 layer_loading_us: layer_load_duration.as_micros() as u64,
455 merge_and_parse_us: 0,
456 validation_us: val_duration.as_micros() as u64,
457 };
458 tracing::debug!(target: "vtcode_config", ?phase_timing, "Default configuration loaded");
459
460 return Ok(Self {
461 config,
462 config_path: None,
463 canonical_user_config_path,
464 tracked_user_config_paths,
465 write_global_config_to_canonical: false,
466 explicit_config_is_trusted: false,
467 workspace_root: Some(workspace_root),
468 config_file_name,
469 layer_stack,
470 phase_timing: Some(phase_timing),
471 });
472 }
473
474 let use_root_config = layer_stack
478 .layers()
479 .iter()
480 .find(|l| {
481 matches!(
482 &l.source,
483 ConfigLayerSource::Workspace { file }
484 if *file == workspace_config_path
485 )
486 })
487 .map(|l| Self::workspace_root_wants_root_config_only(&l.config))
488 .unwrap_or(false);
489 if use_root_config {
490 layer_stack.retain(|layer| {
491 matches!(
492 &layer.source,
493 ConfigLayerSource::Workspace { file }
494 if *file == workspace_config_path
495 ) || matches!(&layer.source, ConfigLayerSource::Runtime)
496 });
497 if layer_stack.layers().is_empty() {
498 bail!(
499 "workspace.use_root_config is true but no workspace root config was found at {}",
500 workspace_config_path.display()
501 );
502 }
503 }
504
505 if let Some((layer, error)) = layer_stack.first_layer_error() {
506 bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
507 }
508
509 let t2 = std::time::Instant::now();
510 let (effective_toml, origins) = layer_stack.effective_config_with_origins();
511 let mut config: VTCodeConfig = effective_toml
512 .try_into()
513 .context("Failed to deserialize effective configuration")?;
514 let merge_duration = t2.elapsed();
515
516 let t3 = std::time::Instant::now();
517 Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
518 Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
519
520 config.validate().context("Configuration failed validation")?;
521 config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
522
523 migrate_custom_api_keys_if_needed(&mut config)?;
525 let val_duration = t3.elapsed();
526
527 let phase_timing = ConfigPhaseTiming {
528 path_resolution_us: path_res_duration.as_micros() as u64,
529 layer_loading_us: layer_load_duration.as_micros() as u64,
530 merge_and_parse_us: merge_duration.as_micros() as u64,
531 validation_us: val_duration.as_micros() as u64,
532 };
533 tracing::debug!(target: "vtcode_config", ?phase_timing, "Workspace configuration loaded");
534
535 let config_path = layer_stack
536 .layers()
537 .iter()
538 .rev()
539 .find(|layer| layer.is_enabled())
540 .and_then(|l| match &l.source {
541 ConfigLayerSource::User { file } => Some(file.clone()),
542 ConfigLayerSource::Project { file } => Some(file.clone()),
543 ConfigLayerSource::Workspace { file } => Some(file.clone()),
544 ConfigLayerSource::System { file } => Some(file.clone()),
545 ConfigLayerSource::Runtime => None,
546 });
547
548 Ok(Self {
549 config,
550 config_path,
551 canonical_user_config_path,
552 tracked_user_config_paths,
553 write_global_config_to_canonical: false,
554 explicit_config_is_trusted: false,
555 workspace_root: Some(workspace_root),
556 config_file_name,
557 layer_stack,
558 phase_timing: Some(phase_timing),
559 })
560 }
561
562 fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
563 let content =
564 fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
565 let mut value: toml::Value =
566 toml::from_str(&content).with_context(|| format!("Failed to parse config file: {}", path.display()))?;
567 crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut value);
568 Ok(value)
569 }
570
571 fn load_optional_layer(source: ConfigLayerSource) -> Option<ConfigLayerEntry> {
572 let file = match &source {
573 ConfigLayerSource::System { file }
574 | ConfigLayerSource::User { file }
575 | ConfigLayerSource::Project { file }
576 | ConfigLayerSource::Workspace { file } => file,
577 ConfigLayerSource::Runtime => {
578 return Some(ConfigLayerEntry::new(source, toml::Value::Table(toml::Table::new())));
579 }
580 };
581
582 let content = match fs::read_to_string(file) {
584 Ok(content) => content,
585 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
586 Err(err) if should_skip_optional_global_error(&source, &err) => {
587 tracing::debug!(path = %file.display(), "skipping inaccessible optional configuration layer");
588 return None;
589 }
590 Err(err) => {
591 let resolved_file = canonicalize_workspace_root(file);
592 let resolved_source = source.with_file(resolved_file);
593 return Some(Self::disabled_layer_from_error(resolved_source, err.into()));
594 }
595 };
596
597 let resolved_file = canonicalize_workspace_root(file);
598 let resolved_source = source.with_file(resolved_file);
599
600 match toml::from_str::<toml::Value>(&content) {
601 Ok(mut toml) => {
602 crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut toml);
603 Some(ConfigLayerEntry::new(resolved_source, toml))
604 }
605 Err(err) => {
606 let error =
607 anyhow::Error::from(err).context(format!("Failed to parse config file: {}", file.display()));
608 Some(Self::disabled_layer_from_error(resolved_source, error))
609 }
610 }
611 }
612
613 fn disabled_layer_from_error(source: ConfigLayerSource, error: anyhow::Error) -> ConfigLayerEntry {
614 let reason = if error.is::<toml::de::Error>() {
615 LayerDisabledReason::ParseError
616 } else {
617 LayerDisabledReason::LoadError
618 };
619 ConfigLayerEntry::disabled(source, reason, format!("{error:#}"))
620 }
621
622 fn workspace_root_wants_root_config_only(config: &toml::Value) -> bool {
627 config
628 .get("workspace")
629 .and_then(|v| v.get("use_root_config"))
630 .and_then(|v| v.as_bool())
631 .unwrap_or(false)
632 }
633
634 pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
636 Self::load_from_file_impl(path, false)
637 }
638
639 fn load_from_file_impl(path: impl AsRef<Path>, write_global_config_to_canonical: bool) -> Result<Self> {
640 let path = path.as_ref();
641 let defaults_provider = defaults::current_config_defaults();
642 let config_file_name = defaults_provider.config_file_name().to_string();
649 let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
650 let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
651 if let Some(path) = &canonical_user_config_path
652 && !tracked_user_config_paths.iter().any(|existing| existing == path)
653 {
654 tracked_user_config_paths.push(path.clone());
655 }
656
657 let mut layer_stack = ConfigLayerStack::default();
658 let mut global_sources = Vec::new();
659
660 for system_config in defaults_provider.system_config_paths(&config_file_name)? {
661 push_unique_layer_source(&mut global_sources, ConfigLayerSource::System { file: system_config });
662 }
663 for home_config_path in &tracked_user_config_paths {
664 push_unique_layer_source(&mut global_sources, ConfigLayerSource::User { file: home_config_path.clone() });
665 }
666 for source in global_sources {
667 if let Some(layer) = Self::load_optional_layer(source) {
668 layer_stack.push(layer);
669 }
670 }
671
672 let file = path.to_path_buf();
674 match Self::load_toml_from_file(path) {
675 Ok(toml) => layer_stack.push(ConfigLayerEntry::new(ConfigLayerSource::Workspace { file }, toml)),
676 Err(error) => {
677 layer_stack.push(Self::disabled_layer_from_error(ConfigLayerSource::Workspace { file }, error))
678 }
679 }
680
681 let use_root_config = layer_stack
684 .layers()
685 .iter()
686 .find(|l| {
687 matches!(
688 &l.source,
689 ConfigLayerSource::Workspace { file }
690 if *file == path
691 )
692 })
693 .map(|l| Self::workspace_root_wants_root_config_only(&l.config))
694 .unwrap_or(false);
695 if use_root_config {
696 layer_stack.retain(|layer| {
697 matches!(
698 &layer.source,
699 ConfigLayerSource::Workspace { file }
700 if *file == path
701 ) || matches!(&layer.source, ConfigLayerSource::Runtime)
702 });
703 }
704
705 if let Some((layer, error)) = layer_stack.first_layer_error() {
706 bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
707 }
708
709 let (effective_toml, origins) = layer_stack.effective_config_with_origins();
710 let mut config: VTCodeConfig = effective_toml
711 .try_into()
712 .with_context(|| format!("Failed to parse effective config with file: {}", path.display()))?;
713 Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
714 Self::validate_provider_security_fields(&layer_stack, &origins, &config, true)?;
718
719 config
720 .validate()
721 .with_context(|| format!("Failed to validate effective config with file: {}", path.display()))?;
722 config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
723
724 Ok(Self {
725 config,
726 config_path: Some(canonicalize_workspace_root(path)),
727 canonical_user_config_path,
728 tracked_user_config_paths,
729 write_global_config_to_canonical,
730 explicit_config_is_trusted: true,
731 workspace_root: path.parent().map(canonicalize_workspace_root),
732 config_file_name,
733 layer_stack,
734 phase_timing: None,
735 })
736 }
737
738 pub fn config(&self) -> &VTCodeConfig {
740 &self.config
741 }
742
743 pub fn phase_timing(&self) -> Option<ConfigPhaseTiming> {
745 self.phase_timing
746 }
747
748 pub fn config_path(&self) -> Option<&Path> {
750 self.config_path.as_deref()
751 }
752
753 #[must_use]
758 pub fn is_repository_controlled_path(&self, path: &Path) -> bool {
759 if self.explicit_config_is_trusted {
760 return false;
761 }
762
763 if self
764 .layer_stack
765 .layers()
766 .iter()
767 .any(|layer| layer.is_enabled() && Self::repository_source_matches_path(&layer.source, path))
768 {
769 return true;
770 }
771
772 self.config_path.is_none()
773 && self.workspace_root.as_deref().is_some_and(|workspace| {
774 Self::repository_source_matches_path(
775 &ConfigLayerSource::Workspace { file: workspace.join(&self.config_file_name) },
776 path,
777 )
778 })
779 }
780
781 pub fn workspace_root(&self) -> Option<&Path> {
783 self.workspace_root.as_deref()
784 }
785
786 pub fn preferred_workspace_config_path(&self, workspace: &Path) -> PathBuf {
794 self.layer_stack
795 .layers()
796 .iter()
797 .rev()
798 .find_map(|layer| match &layer.source {
799 ConfigLayerSource::Workspace { file } if layer.is_enabled() => Some(file.clone()),
800 _ => None,
801 })
802 .unwrap_or_else(|| workspace.join(&self.config_file_name))
803 }
804
805 pub fn config_file_name(&self) -> &str {
807 &self.config_file_name
808 }
809
810 pub fn layer_stack(&self) -> &ConfigLayerStack {
812 &self.layer_stack
813 }
814
815 pub fn preferred_user_config_path(&self) -> Option<PathBuf> {
817 self.canonical_user_config_path.clone()
818 }
819
820 pub fn user_config_paths(&self) -> Vec<PathBuf> {
826 let mut paths = self.tracked_user_config_paths.clone();
827
828 for path in self
829 .layer_stack
830 .layers()
831 .iter()
832 .filter_map(|layer| match (&layer.source, layer.is_enabled()) {
833 (ConfigLayerSource::User { file }, true) => Some(file),
834 _ => None,
835 })
836 {
837 if !paths.iter().any(|existing| existing == path) {
838 paths.push(path.clone());
839 }
840 }
841
842 paths
843 }
844
845 pub fn watched_config_paths(workspace: &Path) -> Vec<PathBuf> {
853 let provider = defaults::current_config_defaults();
854 let config_file_name = provider.config_file_name().to_string();
855 let workspace_paths = provider.workspace_paths_for(workspace);
856 let workspace_root = workspace_paths.workspace_root().to_path_buf();
857 let mut paths = Vec::new();
858
859 let mut push_unique = |path: PathBuf| {
860 if !paths.iter().any(|existing| existing == &path) {
861 paths.push(path);
862 }
863 };
864
865 if let Some(explicit_path) = session_override::explicit_config_path() {
866 push_unique(explicit_path);
867 }
868 if let Ok(system_paths) = provider.system_config_paths(&config_file_name) {
869 for path in system_paths {
870 push_unique(path);
871 }
872 }
873 for path in provider.home_config_paths(&config_file_name) {
874 push_unique(path);
875 }
876 if let Ok(Some(canonical_path)) = provider.canonical_user_config_path(&config_file_name) {
877 push_unique(canonical_path);
878 }
879
880 if let Some(project_name) = Self::current_project_name(&workspace_root) {
881 push_unique(
882 workspace_paths
883 .config_dir()
884 .join("projects")
885 .join(project_name)
886 .join("config")
887 .join(&config_file_name),
888 );
889 }
890
891 push_unique(workspace_paths.config_dir().join(&config_file_name));
892 push_unique(workspace_root.join(&config_file_name));
893 push_unique(workspace_root.join(".vtcode").join("theme.toml"));
894 paths
895 }
896
897 fn repository_config_paths(workspace: &Path) -> Vec<PathBuf> {
898 let provider = defaults::current_config_defaults();
899 let config_file_name = provider.config_file_name().to_string();
900 let workspace_paths = provider.workspace_paths_for(workspace);
901 let workspace_root = canonicalize_workspace_root(workspace_paths.workspace_root());
902 let config_dir = workspace_paths.config_dir();
903 let mut paths = Vec::with_capacity(3);
904
905 let mut push_unique = |path: PathBuf| {
906 if !paths.iter().any(|existing| existing == &path) {
907 paths.push(path);
908 }
909 };
910
911 if let Some(project_path) = Self::project_config_path(&config_dir, &workspace_root, &config_file_name) {
912 push_unique(project_path);
913 }
914 push_unique(config_dir.join(&config_file_name));
915 push_unique(workspace_root.join(&config_file_name));
916 paths
917 }
918
919 fn repository_source_matches_path(source: &ConfigLayerSource, path: &Path) -> bool {
920 let Some(source_file) = (match source {
921 ConfigLayerSource::Project { file } | ConfigLayerSource::Workspace { file } => Some(file),
922 ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. } | ConfigLayerSource::Runtime => None,
923 }) else {
924 return false;
925 };
926
927 canonicalize_workspace_root(source_file) == canonicalize_workspace_root(path)
928 }
929
930 pub fn effective_config(&self) -> toml::Value {
932 self.layer_stack.effective_config()
933 }
934
935 pub fn has_explicit_top_level_key(&self, key: &str) -> bool {
940 self.layer_stack
941 .layers()
942 .iter()
943 .filter(|layer| layer.is_enabled())
944 .any(|layer| layer.config.as_table().is_some_and(|table| table.contains_key(key)))
945 }
946
947 pub fn session_duration(&self) -> std::time::Duration {
949 std::time::Duration::from_secs(60 * 60) }
951
952 fn repair_repository_config_file(path: &Path) -> Result<bool> {
953 let metadata = match fs::symlink_metadata(path) {
954 Ok(metadata) if metadata.file_type().is_symlink() => {
955 bail!("Refusing to repair symlinked config file: {}", path.display())
956 }
957 Ok(metadata) if !metadata.is_file() => {
958 bail!("Config path is not a regular file: {}", path.display())
959 }
960 Ok(metadata) => metadata,
961 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
962 Err(error) => {
963 return Err(error).with_context(|| format!("Failed to inspect config: {}", path.display()));
964 }
965 };
966 debug_assert!(metadata.is_file());
967
968 let content = String::from_utf8(VtCodePaths::read_file_no_follow(path)?)
969 .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
970 let mut doc = content
971 .parse::<toml_edit::DocumentMut>()
972 .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
973 if !Self::remove_repository_provider_settings(&mut doc) {
974 return Ok(false);
975 }
976
977 VtCodePaths::write_private_file_atomic(path, doc.to_string().as_bytes())
978 .with_context(|| format!("Failed to repair config file: {}", path.display()))?;
979 Ok(true)
980 }
981
982 fn remove_repository_provider_settings(doc: &mut toml_edit::DocumentMut) -> bool {
983 let table = doc.as_table_mut();
984 let mut changed = table.remove("custom_providers").is_some();
985
986 let remove_provider_overrides = {
987 let Some(overrides_item) = table.get_mut("provider_overrides") else {
988 return changed;
989 };
990
991 if let Some(overrides) = overrides_item.as_table_mut() {
992 changed |= Self::remove_provider_override_settings_from_table(overrides);
993 overrides.is_empty()
994 } else if let Some(overrides) = overrides_item.as_inline_table_mut() {
995 changed |= Self::remove_provider_override_settings_from_inline_table(overrides);
996 overrides.is_empty()
997 } else {
998 false
999 }
1000 };
1001
1002 if remove_provider_overrides {
1003 changed |= table.remove("provider_overrides").is_some();
1004 }
1005 changed
1006 }
1007
1008 fn remove_provider_override_settings_from_table(overrides: &mut toml_edit::Table) -> bool {
1009 let provider_names = overrides.iter().map(|(name, _)| name.to_string()).collect::<Vec<_>>();
1010 let mut empty_providers = Vec::new();
1011 let mut changed = false;
1012
1013 for provider_name in provider_names {
1014 let Some(provider_item) = overrides.get_mut(&provider_name) else {
1015 continue;
1016 };
1017
1018 let is_empty = if let Some(provider) = provider_item.as_table_mut() {
1019 changed |= provider.remove("base_url").is_some();
1020 changed |= provider.remove("api_key_env").is_some();
1021 provider.is_empty()
1022 } else if let Some(provider) = provider_item.as_inline_table_mut() {
1023 changed |= provider.remove("base_url").is_some();
1024 changed |= provider.remove("api_key_env").is_some();
1025 provider.is_empty()
1026 } else {
1027 false
1028 };
1029
1030 if is_empty {
1031 empty_providers.push(provider_name);
1032 }
1033 }
1034
1035 for provider_name in empty_providers {
1036 changed |= overrides.remove(&provider_name).is_some();
1037 }
1038 changed
1039 }
1040
1041 fn remove_provider_override_settings_from_inline_table(overrides: &mut toml_edit::InlineTable) -> bool {
1042 let provider_names = overrides.iter().map(|(name, _)| name.to_string()).collect::<Vec<_>>();
1043 let mut empty_providers = Vec::new();
1044 let mut changed = false;
1045
1046 for provider_name in provider_names {
1047 let Some(provider) = overrides
1048 .get_mut(&provider_name)
1049 .and_then(toml_edit::Value::as_inline_table_mut)
1050 else {
1051 continue;
1052 };
1053
1054 changed |= provider.remove("base_url").is_some();
1055 changed |= provider.remove("api_key_env").is_some();
1056 if provider.is_empty() {
1057 empty_providers.push(provider_name);
1058 }
1059 }
1060
1061 for provider_name in empty_providers {
1062 changed |= overrides.remove(&provider_name).is_some();
1063 }
1064 changed
1065 }
1066
1067 fn project_config_path(config_dir: &Path, workspace_root: &Path, config_file_name: &str) -> Option<PathBuf> {
1068 let project_name = Self::identify_current_project(workspace_root)?;
1069 Some(
1070 config_dir
1071 .join("projects")
1072 .join(project_name)
1073 .join("config")
1074 .join(config_file_name),
1075 )
1076 }
1077
1078 fn identify_current_project(workspace_root: &Path) -> Option<String> {
1079 let project_file = workspace_root.join(".vtcode-project");
1080 if let Ok(contents) = fs::read_to_string(&project_file) {
1081 let name = contents.trim();
1082 if !name.is_empty() {
1083 return Some(name.to_string());
1084 }
1085 }
1086
1087 workspace_root
1088 .file_name()
1089 .and_then(|name| name.to_str())
1090 .map(|name| name.to_string())
1091 }
1092
1093 pub fn current_project_name(workspace_root: &Path) -> Option<String> {
1095 Self::identify_current_project(workspace_root)
1096 }
1097
1098 fn validate_restricted_agent_fields(
1099 layer_stack: &ConfigLayerStack,
1100 origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
1101 ) -> Result<()> {
1102 if let Some(origin) = origins.get("agent.persistent_memory.directory_override")
1103 && let Some(layer) = layer_stack.layers().iter().find(|layer| layer.metadata == *origin)
1104 {
1105 match layer.source {
1106 ConfigLayerSource::System { .. }
1107 | ConfigLayerSource::User { .. }
1108 | ConfigLayerSource::Project { .. } => {}
1109 ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Runtime => {
1110 bail!(
1111 "agent.persistent_memory.directory_override may only be set in system, user, or project-profile configuration layers"
1112 );
1113 }
1114 }
1115 }
1116
1117 Ok(())
1118 }
1119
1120 fn validate_provider_security_fields(
1129 layer_stack: &ConfigLayerStack,
1130 origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
1131 config: &VTCodeConfig,
1132 explicit_config_is_trusted: bool,
1133 ) -> Result<()> {
1134 if explicit_config_is_trusted {
1135 return Ok(());
1136 }
1137
1138 if !config.custom_providers.is_empty()
1139 && let Some(origin) = origins.get("custom_providers")
1140 && let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin)
1141 && Self::is_repository_controlled_source(&layer.source)
1142 {
1143 return Err(RepositoryProviderSecurityViolation {
1144 source: layer.source.clone(),
1145 message: format!(
1146 "repository-controlled configuration cannot define `custom_providers` (including `auth.command`); move custom provider settings to system, user, or an explicitly selected config file (source: {})",
1147 layer.source.label()
1148 ),
1149 }
1150 .into());
1151 }
1152
1153 for (path, origin) in origins {
1154 if !Self::is_provider_endpoint_or_credential_path(path) {
1155 continue;
1156 }
1157
1158 let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin) else {
1159 continue;
1160 };
1161 if Self::is_repository_controlled_source(&layer.source) {
1162 return Err(RepositoryProviderSecurityViolation {
1163 source: layer.source.clone(),
1164 message: format!(
1165 "repository-controlled configuration cannot set `{path}`; provider endpoints and credential environment variables must be configured in system, user, or an explicitly selected config file (source: {})",
1166 layer.source.label()
1167 ),
1168 }
1169 .into());
1170 }
1171 }
1172
1173 Ok(())
1174 }
1175
1176 fn enabled_layer_for_origin<'a>(
1177 layer_stack: &'a ConfigLayerStack,
1178 origin: &ConfigLayerMetadata,
1179 ) -> Option<&'a ConfigLayerEntry> {
1180 layer_stack
1181 .layers()
1182 .iter()
1183 .find(|layer| layer.is_enabled() && layer.metadata == *origin)
1184 }
1185
1186 fn is_repository_controlled_source(source: &ConfigLayerSource) -> bool {
1187 matches!(source, ConfigLayerSource::Project { .. } | ConfigLayerSource::Workspace { .. })
1188 }
1189
1190 fn is_provider_endpoint_or_credential_path(path: &str) -> bool {
1191 let mut segments = path.split('.');
1192 matches!(segments.next(), Some("provider_overrides"))
1193 && segments.next().is_some()
1194 && matches!(segments.next(), Some("base_url" | "api_key_env"))
1195 && segments.next().is_none()
1196 }
1197
1198 pub(crate) fn collect_workspace_lifecycle_hooks(
1204 layer_stack: &ConfigLayerStack,
1205 hooks: &HooksConfig,
1206 ) -> WorkspaceLifecycleHooks {
1207 let (_, origins) = layer_stack.effective_config_with_origins();
1208 let mut commands = Vec::new();
1209
1210 for event in crate::hooks::LIFECYCLE_HOOK_EVENTS {
1211 let origin_key = format!("hooks.lifecycle.{event}");
1212 let Some(origin) = origins.get(&origin_key) else {
1213 continue;
1214 };
1215 let workspace_controlled = layer_stack.layers().iter().any(|layer| {
1216 layer.is_enabled()
1217 && layer.metadata == *origin
1218 && matches!(layer.source, ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Project { .. })
1219 });
1220 if !workspace_controlled {
1221 continue;
1222 }
1223
1224 let tag = match *event {
1226 "task_completion" | "task_completed" => "stop",
1227 other => other,
1228 };
1229 for group in hooks.lifecycle.groups_for_event(event) {
1230 for command in &group.hooks {
1231 commands.push(WorkspaceHookCommand {
1232 event: tag.to_string(),
1233 matcher: group.matcher.clone(),
1234 command: command.command.clone(),
1235 timeout_seconds: command.timeout_seconds,
1236 });
1237 }
1238 }
1239 }
1240
1241 WorkspaceLifecycleHooks { commands }
1242 }
1243}
1244
1245fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
1255 let storage_mode = config.agent.credential_storage_mode;
1256
1257 let has_plain_text_keys = config.agent.custom_api_keys.values().any(|key| !key.is_empty());
1259
1260 if has_plain_text_keys {
1261 tracing::info!("Detected plain-text API keys in config, migrating to secure storage...");
1262
1263 let mut migrated_count = 0;
1264 let pending = config
1265 .agent
1266 .custom_api_keys
1267 .iter()
1268 .filter(|(_, key)| !key.is_empty())
1269 .map(|(provider, key)| (provider.clone(), key.clone()))
1270 .collect::<Vec<_>>();
1271 for (provider, api_key) in pending {
1272 let key_name = config
1273 .configured_api_key_env(&provider)
1274 .unwrap_or_else(|| api_key_env_var(&provider));
1275 if let Some(identity) = store_credential_with_mode(&provider, &key_name, &api_key, storage_mode)? {
1276 config.agent.custom_api_keys.remove(&provider);
1277 if let Some(metadata_key) = credential_metadata_key(identity.provider(), identity.key_name())? {
1278 config.agent.custom_api_keys.insert(metadata_key, String::new());
1279 }
1280 migrated_count += 1;
1281 }
1282 }
1283
1284 if migrated_count > 0 {
1285 tracing::info!("Successfully migrated {} API key(s) to secure storage", migrated_count);
1286 tracing::warn!(
1287 "Plain-text API keys have been cleared from config file. \
1288 Please commit the updated config to remove sensitive data from version control."
1289 );
1290 }
1291 }
1292
1293 Ok(())
1294}
1295
1296#[cfg(test)]
1297mod sparse_config_tests {
1298 use super::*;
1299
1300 #[test]
1301 fn sparse_config_omits_default_primary_agent_when_unmodified() {
1302 let config = VTCodeConfig::default();
1303 let value = ConfigManager::sparse_config_value(&config).expect("sparse config");
1304
1305 assert!(value.get("default_primary_agent").is_none());
1306 }
1307
1308 #[test]
1309 fn sparse_config_persists_non_default_primary_agent() {
1310 let config = VTCodeConfig {
1311 default_primary_agent: "auto".to_string(),
1312 ..Default::default()
1313 };
1314
1315 let value = ConfigManager::sparse_config_value(&config).expect("sparse config");
1316
1317 assert_eq!(value.get("default_primary_agent").and_then(toml::Value::as_str), Some("auto"));
1318 }
1319
1320 #[test]
1321 fn has_explicit_top_level_key_matches_effective_config() {
1322 let temp_dir = tempfile::tempdir().expect("temp dir");
1323 let workspace = temp_dir.path().join("workspace");
1324 fs::create_dir_all(workspace.join(".vtcode")).expect("workspace dot dir");
1325
1326 let manager = crate::loader::ConfigBuilder::new()
1327 .workspace(workspace.clone())
1328 .build()
1329 .expect("manager");
1330 assert!(!manager.has_explicit_top_level_key("default_primary_agent"));
1331 assert!(
1332 manager
1333 .effective_config()
1334 .as_table()
1335 .is_none_or(|table| !table.contains_key("default_primary_agent"))
1336 );
1337
1338 let overridden = crate::loader::ConfigBuilder::new()
1339 .workspace(workspace)
1340 .cli_override("default_primary_agent".to_owned(), toml::Value::String("duck".to_owned()))
1341 .build()
1342 .expect("overridden manager");
1343 assert!(overridden.has_explicit_top_level_key("default_primary_agent"));
1344 assert!(
1345 overridden
1346 .effective_config()
1347 .as_table()
1348 .is_some_and(|table| table.contains_key("default_primary_agent"))
1349 );
1350 assert!(!overridden.has_explicit_top_level_key("definitely_missing_key"));
1351 }
1352
1353 #[test]
1354 fn save_config_migrates_legacy_permissions_auto_permission_table() {
1355 let temp_dir = tempfile::tempdir().expect("temp dir");
1356 let config_path = temp_dir.path().join("vtcode.toml");
1357 fs::write(
1358 &config_path,
1359 r#"
1360[permissions.auto_permission]
1361model = "legacy-reviewer"
1362max_consecutive_denials = 2
1363"#,
1364 )
1365 .expect("write legacy config");
1366
1367 let mut config = VTCodeConfig::default();
1368 config.permissions.auto_permission.model = "legacy-reviewer".to_string();
1369 config.permissions.auto_permission.max_consecutive_denials = 2;
1370
1371 ConfigManager::save_config_to_path(&config_path, &config).expect("save config");
1372
1373 let saved_content = fs::read_to_string(&config_path).expect("read saved config");
1374 assert!(
1375 saved_content.contains("[permissions.auto]"),
1376 "canonical auto permission table should be persisted. Got:\n{saved_content}"
1377 );
1378 assert!(
1379 !saved_content.contains("auto_permission"),
1380 "legacy auto_permission table should be removed. Got:\n{saved_content}"
1381 );
1382
1383 let reloaded = ConfigManager::load_from_file(&config_path).expect("reload saved config");
1384 assert_eq!(reloaded.config().permissions.auto_permission.model, "legacy-reviewer");
1385 assert_eq!(reloaded.config().permissions.auto_permission.max_consecutive_denials, 2);
1386 }
1387
1388 #[test]
1389 fn save_config_removes_legacy_permissions_auto_permission_when_sparse_default() {
1390 let temp_dir = tempfile::tempdir().expect("temp dir");
1391 let config_path = temp_dir.path().join("vtcode.toml");
1392 fs::write(
1393 &config_path,
1394 r#"
1395[permissions.auto_permission]
1396max_consecutive_denials = 3
1397"#,
1398 )
1399 .expect("write legacy config");
1400
1401 ConfigManager::save_config_to_path(&config_path, &VTCodeConfig::default()).expect("save config");
1402
1403 let saved_content = fs::read_to_string(&config_path).expect("read saved config");
1404 assert!(
1405 !saved_content.contains("auto_permission"),
1406 "legacy auto_permission table should be removed. Got:\n{saved_content}"
1407 );
1408 assert!(
1409 !saved_content.contains("[permissions.auto]"),
1410 "default auto permission config should remain sparse. Got:\n{saved_content}"
1411 );
1412
1413 ConfigManager::load_from_file(&config_path).expect("reload saved config");
1414 }
1415
1416 #[test]
1417 fn inaccessible_optional_global_layers_are_skipped() {
1418 let error = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
1419 assert!(should_skip_optional_global_error(
1420 &ConfigLayerSource::System { file: PathBuf::from("/etc/vtcode/vtcode.toml") },
1421 &error
1422 ));
1423 assert!(should_skip_optional_global_error(
1424 &ConfigLayerSource::User {
1425 file: PathBuf::from("/home/user/.config/vtcode/vtcode.toml")
1426 },
1427 &error
1428 ));
1429 assert!(!should_skip_optional_global_error(
1430 &ConfigLayerSource::Workspace { file: PathBuf::from("/workspace/vtcode.toml") },
1431 &error
1432 ));
1433 }
1434
1435 #[test]
1436 fn disabled_layer_reason_tracks_concrete_error_type() {
1437 let temp_dir = tempfile::tempdir().expect("temp dir");
1438 let parse_path = temp_dir.path().join("invalid.toml");
1439 fs::write(&parse_path, "key = [").expect("write invalid TOML");
1440 let parse_error = ConfigManager::load_toml_from_file(&parse_path).expect_err("invalid TOML should fail");
1441 let parse_layer =
1442 ConfigManager::disabled_layer_from_error(ConfigLayerSource::Project { file: parse_path }, parse_error);
1443
1444 assert_eq!(parse_layer.disabled_reason, Some(LayerDisabledReason::ParseError));
1445
1446 let read_path = temp_dir.path().join("parse");
1447 fs::create_dir(&read_path).expect("create directory at config path");
1448 let read_error = ConfigManager::load_toml_from_file(&read_path).expect_err("reading a directory should fail");
1449 assert!(read_error.to_string().contains("parse"));
1450 let read_layer =
1451 ConfigManager::disabled_layer_from_error(ConfigLayerSource::Project { file: read_path }, read_error);
1452
1453 assert_eq!(read_layer.disabled_reason, Some(LayerDisabledReason::LoadError));
1454 }
1455}
1456
1457#[cfg(test)]
1458mod workspace_lifecycle_hooks_tests {
1459 use super::*;
1460 use crate::hooks::WorkspaceLifecycleHooks;
1461
1462 fn layer(source: ConfigLayerSource, content: &str) -> ConfigLayerEntry {
1463 ConfigLayerEntry::new(source, toml::from_str(content).expect("valid test toml"))
1464 }
1465
1466 fn collect(stack: &ConfigLayerStack) -> WorkspaceLifecycleHooks {
1467 let (effective_toml, _) = stack.effective_config_with_origins();
1468 let config: VTCodeConfig = effective_toml.try_into().expect("effective config parses");
1469 ConfigManager::collect_workspace_lifecycle_hooks(stack, &config.hooks)
1470 }
1471
1472 const WS_SESSION_START: &str = r#"
1473[[hooks.lifecycle.session_start]]
1474matcher = "startup"
1475
1476[[hooks.lifecycle.session_start.hooks]]
1477command = "echo workspace-session"
1478timeout_seconds = 30
1479"#;
1480
1481 const USER_SESSION_END: &str = r#"
1482[[hooks.lifecycle.session_end]]
1483[[hooks.lifecycle.session_end.hooks]]
1484command = "echo user-session-end"
1485"#;
1486
1487 #[test]
1488 fn workspace_layer_hooks_are_collected_user_hooks_are_not() {
1489 let mut stack = ConfigLayerStack::default();
1490 stack.push(layer(ConfigLayerSource::User { file: "/home/u/.vtcode/vtcode.toml".into() }, USER_SESSION_END));
1491 stack.push(layer(ConfigLayerSource::Workspace { file: "/ws/vtcode.toml".into() }, WS_SESSION_START));
1492
1493 let collected = collect(&stack);
1494
1495 assert_eq!(collected.commands.len(), 1, "only workspace hooks should be collected");
1496 let command = &collected.commands[0];
1497 assert_eq!(command.event, "session_start");
1498 assert_eq!(command.command, "echo workspace-session");
1499 assert_eq!(command.matcher.as_deref(), Some("startup"));
1500 assert_eq!(command.timeout_seconds, Some(30));
1501 assert!(!collected.is_empty());
1502 }
1503
1504 #[test]
1505 fn user_only_hooks_are_not_workspace_controlled() {
1506 let mut stack = ConfigLayerStack::default();
1507 stack.push(layer(ConfigLayerSource::User { file: "/home/u/.vtcode/vtcode.toml".into() }, USER_SESSION_END));
1508
1509 let collected = collect(&stack);
1510
1511 assert!(collected.is_empty());
1512 }
1513
1514 #[test]
1515 fn deprecated_task_completion_aliases_fold_into_stop() {
1516 let mut stack = ConfigLayerStack::default();
1517 stack.push(layer(
1518 ConfigLayerSource::Workspace { file: "/ws/vtcode.toml".into() },
1519 r#"
1520[[hooks.lifecycle.task_completion]]
1521[[hooks.lifecycle.task_completion.hooks]]
1522command = "echo ws-task-completion"
1523"#,
1524 ));
1525
1526 let collected = collect(&stack);
1527
1528 assert_eq!(collected.commands.len(), 1);
1529 assert_eq!(collected.commands[0].event, "stop");
1530 assert_eq!(collected.commands[0].command, "echo ws-task-completion");
1531 }
1532
1533 #[test]
1534 fn empty_workspace_hook_arrays_do_not_flag_content() {
1535 let mut stack = ConfigLayerStack::default();
1536 stack.push(layer(
1537 ConfigLayerSource::Workspace { file: "/ws/vtcode.toml".into() },
1538 "[hooks.lifecycle]\nsession_start = []\n",
1539 ));
1540
1541 let collected = collect(&stack);
1542
1543 assert!(collected.is_empty(), "an empty workspace hook array is not executable content");
1544 }
1545
1546 #[test]
1547 fn load_from_file_populates_workspace_lifecycle_hooks() {
1548 let temp_dir = tempfile::tempdir().expect("temp dir");
1549 let config_path = temp_dir.path().join("vtcode.toml");
1550 fs::write(&config_path, WS_SESSION_START).expect("write workspace config");
1551
1552 let manager = ConfigManager::load_from_file(&config_path).expect("load config");
1553 let workspace_hooks = manager
1554 .config()
1555 .workspace_lifecycle_hooks
1556 .as_ref()
1557 .expect("workspace hooks populated at load");
1558
1559 assert!(!workspace_hooks.is_empty());
1560 assert_eq!(workspace_hooks.commands.len(), 1);
1561 assert_eq!(workspace_hooks.commands[0].command, "echo workspace-session");
1562 }
1563}