1#[cfg(feature = "bootstrap")]
2pub mod bootstrap;
3pub mod layers;
4
5use crate::acp::AgentClientProtocolConfig;
6use crate::context::ContextFeaturesConfig;
7use crate::core::{
8 AgentConfig, AnthropicConfig, AutomationConfig, CommandsConfig, ModelConfig, PermissionsConfig,
9 PromptCachingConfig, SandboxConfig, SecurityConfig, SkillsConfig, ToolsConfig,
10};
11use crate::debug::DebugConfig;
12use crate::defaults::{self, ConfigDefaultsProvider, SyntaxHighlightingDefaults};
13use crate::hooks::HooksConfig;
14use crate::mcp::McpClientConfig;
15use crate::optimization::OptimizationConfig;
16use crate::output_styles::OutputStyleConfig;
17use crate::root::{PtyConfig, UiConfig};
18use crate::telemetry::TelemetryConfig;
19use crate::timeouts::TimeoutsConfig;
20use anyhow::{Context, Result, ensure};
21use serde::{Deserialize, Serialize};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[derive(Debug, Clone, Deserialize, Serialize)]
28pub struct SyntaxHighlightingConfig {
29 #[serde(default = "defaults::syntax_highlighting::enabled")]
31 pub enabled: bool,
32
33 #[serde(default = "defaults::syntax_highlighting::theme")]
35 pub theme: String,
36
37 #[serde(default = "defaults::syntax_highlighting::cache_themes")]
39 pub cache_themes: bool,
40
41 #[serde(default = "defaults::syntax_highlighting::max_file_size_mb")]
43 pub max_file_size_mb: usize,
44
45 #[serde(default = "defaults::syntax_highlighting::enabled_languages")]
47 pub enabled_languages: Vec<String>,
48
49 #[serde(default = "defaults::syntax_highlighting::highlight_timeout_ms")]
51 pub highlight_timeout_ms: u64,
52}
53
54impl Default for SyntaxHighlightingConfig {
55 fn default() -> Self {
56 Self {
57 enabled: defaults::syntax_highlighting::enabled(),
58 theme: defaults::syntax_highlighting::theme(),
59 cache_themes: defaults::syntax_highlighting::cache_themes(),
60 max_file_size_mb: defaults::syntax_highlighting::max_file_size_mb(),
61 enabled_languages: defaults::syntax_highlighting::enabled_languages(),
62 highlight_timeout_ms: defaults::syntax_highlighting::highlight_timeout_ms(),
63 }
64 }
65}
66
67impl SyntaxHighlightingConfig {
68 pub fn validate(&self) -> Result<()> {
69 if !self.enabled {
70 return Ok(());
71 }
72
73 ensure!(
74 self.max_file_size_mb >= SyntaxHighlightingDefaults::min_file_size_mb(),
75 "Syntax highlighting max_file_size_mb must be at least {} MB",
76 SyntaxHighlightingDefaults::min_file_size_mb()
77 );
78
79 ensure!(
80 self.highlight_timeout_ms >= SyntaxHighlightingDefaults::min_highlight_timeout_ms(),
81 "Syntax highlighting highlight_timeout_ms must be at least {} ms",
82 SyntaxHighlightingDefaults::min_highlight_timeout_ms()
83 );
84
85 ensure!(
86 !self.theme.trim().is_empty(),
87 "Syntax highlighting theme must not be empty"
88 );
89
90 ensure!(
91 self.enabled_languages
92 .iter()
93 .all(|lang| !lang.trim().is_empty()),
94 "Syntax highlighting languages must not contain empty entries"
95 );
96
97 Ok(())
98 }
99}
100
101#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103#[derive(Debug, Clone, Deserialize, Serialize, Default)]
104pub struct ProviderConfig {
105 #[serde(default)]
107 pub anthropic: AnthropicConfig,
108}
109
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112#[derive(Debug, Clone, Deserialize, Serialize, Default)]
113pub struct VTCodeConfig {
114 #[serde(default)]
116 pub agent: AgentConfig,
117
118 #[serde(default)]
120 pub tools: ToolsConfig,
121
122 #[serde(default)]
124 pub commands: CommandsConfig,
125
126 #[serde(default)]
128 pub permissions: PermissionsConfig,
129
130 #[serde(default)]
132 pub security: SecurityConfig,
133
134 #[serde(default)]
136 pub sandbox: SandboxConfig,
137
138 #[serde(default)]
140 pub ui: UiConfig,
141
142 #[serde(default)]
144 pub pty: PtyConfig,
145
146 #[serde(default)]
148 pub debug: DebugConfig,
149
150 #[serde(default)]
152 pub context: ContextFeaturesConfig,
153
154 #[serde(default)]
156 pub telemetry: TelemetryConfig,
157
158 #[serde(default)]
160 pub optimization: OptimizationConfig,
161
162 #[serde(default)]
164 pub syntax_highlighting: SyntaxHighlightingConfig,
165
166 #[serde(default)]
168 pub timeouts: TimeoutsConfig,
169
170 #[serde(default)]
172 pub automation: AutomationConfig,
173
174 #[serde(default)]
176 pub prompt_cache: PromptCachingConfig,
177
178 #[serde(default)]
180 pub mcp: McpClientConfig,
181
182 #[serde(default)]
184 pub acp: AgentClientProtocolConfig,
185
186 #[serde(default)]
188 pub hooks: HooksConfig,
189
190 #[serde(default)]
192 pub model: ModelConfig,
193
194 #[serde(default)]
196 pub provider: ProviderConfig,
197
198 #[serde(default)]
200 pub skills: SkillsConfig,
201
202 #[serde(default)]
204 pub output_style: OutputStyleConfig,
205}
206
207impl VTCodeConfig {
208 pub fn validate(&self) -> Result<()> {
209 self.syntax_highlighting
210 .validate()
211 .context("Invalid syntax_highlighting configuration")?;
212
213 self.context
214 .validate()
215 .context("Invalid context configuration")?;
216
217 self.hooks
218 .validate()
219 .context("Invalid hooks configuration")?;
220
221 self.timeouts
222 .validate()
223 .context("Invalid timeouts configuration")?;
224
225 self.prompt_cache
226 .validate()
227 .context("Invalid prompt_cache configuration")?;
228
229 self.ui
230 .keyboard_protocol
231 .validate()
232 .context("Invalid keyboard_protocol configuration")?;
233
234 Ok(())
235 }
236
237 #[cfg(feature = "bootstrap")]
238 pub fn bootstrap_project<P: AsRef<Path>>(workspace: P, force: bool) -> Result<Vec<String>> {
240 Self::bootstrap_project_with_options(workspace, force, false)
241 }
242
243 #[cfg(feature = "bootstrap")]
244 pub fn bootstrap_project_with_options<P: AsRef<Path>>(
246 workspace: P,
247 force: bool,
248 use_home_dir: bool,
249 ) -> Result<Vec<String>> {
250 let workspace = workspace.as_ref().to_path_buf();
251 defaults::with_config_defaults(|provider| {
252 Self::bootstrap_project_with_provider(&workspace, force, use_home_dir, provider)
253 })
254 }
255
256 #[cfg(feature = "bootstrap")]
257 pub fn bootstrap_project_with_provider<P: AsRef<Path>>(
259 workspace: P,
260 force: bool,
261 use_home_dir: bool,
262 defaults_provider: &dyn ConfigDefaultsProvider,
263 ) -> Result<Vec<String>> {
264 let workspace = workspace.as_ref();
265 let config_file_name = defaults_provider.config_file_name().to_string();
266 let (config_path, gitignore_path) = bootstrap::determine_bootstrap_targets(
267 workspace,
268 use_home_dir,
269 &config_file_name,
270 defaults_provider,
271 )?;
272
273 bootstrap::ensure_parent_dir(&config_path)?;
274 bootstrap::ensure_parent_dir(&gitignore_path)?;
275
276 let mut created_files = Vec::new();
277
278 if !config_path.exists() || force {
279 let config_content = Self::default_vtcode_toml_template();
280
281 fs::write(&config_path, config_content).with_context(|| {
282 format!("Failed to write config file: {}", config_path.display())
283 })?;
284
285 if let Some(file_name) = config_path.file_name().and_then(|name| name.to_str()) {
286 created_files.push(file_name.to_string());
287 }
288 }
289
290 if !gitignore_path.exists() || force {
291 let gitignore_content = Self::default_vtcode_gitignore();
292 fs::write(&gitignore_path, gitignore_content).with_context(|| {
293 format!(
294 "Failed to write gitignore file: {}",
295 gitignore_path.display()
296 )
297 })?;
298
299 if let Some(file_name) = gitignore_path.file_name().and_then(|name| name.to_str()) {
300 created_files.push(file_name.to_string());
301 }
302 }
303
304 Ok(created_files)
305 }
306
307 #[cfg(feature = "bootstrap")]
308 fn default_vtcode_toml_template() -> String {
310 r#"# VT Code Configuration File (Example)
311# Getting-started reference; see docs/config/CONFIGURATION_PRECEDENCE.md for override order.
312# Copy this file to vtcode.toml and customize as needed.
313
314# Core agent behavior; see docs/config/CONFIGURATION_PRECEDENCE.md.
315[agent]
316# Primary LLM provider to use (e.g., "openai", "gemini", "anthropic", "openrouter")
317provider = "openai"
318
319# Environment variable containing the API key for the provider
320api_key_env = "OPENAI_API_KEY"
321
322# Default model to use when no specific model is specified
323default_model = "gpt-5-nano"
324
325# Visual theme for the terminal interface
326theme = "ciapre-dark"
327
328# Enable TODO planning helper mode for structured task management
329todo_planning_mode = true
330
331# UI surface to use ("auto", "alternate", "inline")
332ui_surface = "auto"
333
334# Maximum number of conversation turns before rotating context (affects memory usage)
335# Lower values reduce memory footprint but may lose context; higher values preserve context but use more memory
336max_conversation_turns = 50
337
338# Reasoning effort level ("low", "medium", "high") - affects model usage and response speed
339reasoning_effort = "low"
340
341# Enable self-review loop to check and improve responses (increases API calls)
342enable_self_review = false
343
344# Maximum number of review passes when self-review is enabled
345max_review_passes = 1
346
347# Enable prompt refinement loop for improved prompt quality (increases processing time)
348refine_prompts_enabled = false
349
350# Maximum passes for prompt refinement when enabled
351refine_prompts_max_passes = 1
352
353# Optional alternate model for refinement (leave empty to use default)
354refine_prompts_model = ""
355
356# Maximum size of project documentation to include in context (in bytes)
357project_doc_max_bytes = 16384
358
359# Maximum size of instruction files to process (in bytes)
360instruction_max_bytes = 16384
361
362# List of additional instruction files to include in context
363instruction_files = []
364
365# Default editing mode on startup: "edit" or "plan"
366# "edit" - Full tool access for file modifications and command execution (default)
367# "plan" - Read-only mode that produces implementation plans without making changes
368# Toggle during session with Shift+Tab or /plan command
369default_editing_mode = "edit"
370
371# Onboarding configuration - Customize the startup experience
372[agent.onboarding]
373# Enable the onboarding welcome message on startup
374enabled = true
375
376# Custom introduction text shown on startup
377intro_text = "Let's get oriented. I preloaded workspace context so we can move fast."
378
379# Include project overview information in welcome
380include_project_overview = true
381
382# Include language summary information in welcome
383include_language_summary = false
384
385# Include key guideline highlights from AGENTS.md
386include_guideline_highlights = true
387
388# Include usage tips in the welcome message
389include_usage_tips_in_welcome = false
390
391# Include recommended actions in the welcome message
392include_recommended_actions_in_welcome = false
393
394# Maximum number of guideline highlights to show
395guideline_highlight_limit = 3
396
397# List of usage tips shown during onboarding
398usage_tips = [
399 "Describe your current coding goal or ask for a quick status overview.",
400 "Reference AGENTS.md guidelines when proposing changes.",
401 "Prefer asking for targeted file reads or diffs before editing.",
402]
403
404# List of recommended actions shown during onboarding
405recommended_actions = [
406 "Review the highlighted guidelines and share the task you want to tackle.",
407 "Ask for a workspace tour if you need more context.",
408]
409
410# Custom prompts configuration - Define personal assistant commands
411[agent.custom_prompts]
412# Enable the custom prompts feature with /prompt:<name> syntax
413enabled = true
414
415# Directory where custom prompt files are stored
416directory = "~/.vtcode/prompts"
417
418# Additional directories to search for custom prompts
419extra_directories = []
420
421# Maximum file size for custom prompts (in kilobytes)
422max_file_size_kb = 64
423
424# Custom API keys for specific providers
425[agent.custom_api_keys]
426# Moonshot AI API key (for specific provider access)
427moonshot = "sk-sDj3JUXDbfARCYKNL4q7iGWRtWuhL1M4O6zzgtDpN3Yxt9EA"
428
429# Checkpointing configuration for session persistence
430[agent.checkpointing]
431# Enable automatic session checkpointing
432enabled = false
433
434# Maximum number of checkpoints to keep on disk
435max_snapshots = 50
436
437# Maximum age of checkpoints to keep (in days)
438max_age_days = 30
439
440# Tool security configuration
441[tools]
442# Default policy when no specific policy is defined ("allow", "prompt", "deny")
443# "allow" - Execute without confirmation
444# "prompt" - Ask for confirmation
445# "deny" - Block the tool
446default_policy = "prompt"
447
448# Maximum number of tool loops allowed per turn (prevents infinite loops)
449# Higher values allow more complex operations but risk performance issues
450# Recommended: 20 for most tasks, 50 for complex multi-step workflows
451max_tool_loops = 20
452
453# Maximum number of repeated identical tool calls (prevents stuck loops)
454max_repeated_tool_calls = 2
455
456# Specific tool policies - Override default policy for individual tools
457[tools.policies]
458apply_patch = "prompt" # Apply code patches (requires confirmation)
459close_pty_session = "allow" # Close PTY sessions (no confirmation needed)
460create_pty_session = "allow" # Create PTY sessions (no confirmation needed)
461edit_file = "allow" # Edit files directly (no confirmation needed)
462grep_file = "allow" # Sole content-search tool (ripgrep-backed)
463list_files = "allow" # List directory contents (no confirmation needed)
464list_pty_sessions = "allow" # List PTY sessions (no confirmation needed)
465read_file = "allow" # Read files (no confirmation needed)
466read_pty_session = "allow" # Read PTY session output (no no confirmation needed)
467resize_pty_session = "allow" # Resize PTY sessions (no confirmation needed)
468run_pty_cmd = "prompt" # Run commands in PTY (requires confirmation)
469exec_command = "prompt" # Execute command in unified session (requires confirmation)
470write_stdin = "prompt" # Write to stdin in unified session (requires confirmation)
471
472send_pty_input = "prompt" # Send input to PTY (requires confirmation)
473write_file = "allow" # Write files (no confirmation needed)
474
475# Command security - Define safe and dangerous command patterns
476[commands]
477# Commands that are always allowed without confirmation
478allow_list = [
479 "ls", # List directory contents
480 "pwd", # Print working directory
481 "git status", # Show git status
482 "git diff", # Show git differences
483 "cargo check", # Check Rust code
484 "echo", # Print text
485]
486
487# Commands that are never allowed
488deny_list = [
489 "rm -rf /", # Delete root directory (dangerous)
490 "rm -rf ~", # Delete home directory (dangerous)
491 "shutdown", # Shut down system (dangerous)
492 "reboot", # Reboot system (dangerous)
493 "sudo *", # Any sudo command (dangerous)
494 ":(){ :|:& };:", # Fork bomb (dangerous)
495]
496
497# Command patterns that are allowed (supports glob patterns)
498allow_glob = [
499 "git *", # All git commands
500 "cargo *", # All cargo commands
501 "python -m *", # Python module commands
502]
503
504# Command patterns that are denied (supports glob patterns)
505deny_glob = [
506 "rm *", # All rm commands
507 "sudo *", # All sudo commands
508 "chmod *", # All chmod commands
509 "chown *", # All chown commands
510 "kubectl *", # All kubectl commands (admin access)
511]
512
513# Regular expression patterns for allowed commands (if needed)
514allow_regex = []
515
516# Regular expression patterns for denied commands (if needed)
517deny_regex = []
518
519# Security configuration - Safety settings for automated operations
520[security]
521# Require human confirmation for potentially dangerous actions
522human_in_the_loop = true
523
524# Require explicit write tool usage for claims about file modifications
525require_write_tool_for_claims = true
526
527# Auto-apply patches without prompting (DANGEROUS - disable for safety)
528auto_apply_detected_patches = false
529
530# UI configuration - Terminal and display settings
531[ui]
532# Tool output display mode
533# "compact" - Concise tool output
534# "full" - Detailed tool output
535tool_output_mode = "compact"
536
537# Maximum number of lines to display in tool output (prevents transcript flooding)
538# Lines beyond this limit are truncated to a tail preview
539tool_output_max_lines = 600
540
541# Maximum bytes threshold for spooling tool output to disk
542# Output exceeding this size is written to .vtcode/tool-output/*.log
543tool_output_spool_bytes = 200000
544
545# Optional custom directory for spooled tool output logs
546# If not set, defaults to .vtcode/tool-output/
547# tool_output_spool_dir = "/path/to/custom/spool/dir"
548
549# Allow ANSI escape sequences in tool output (enables colors but may cause layout issues)
550allow_tool_ansi = false
551
552# Number of rows to allocate for inline UI viewport
553inline_viewport_rows = 16
554
555# Show timeline navigation panel
556show_timeline_pane = false
557
558# Status line configuration
559[ui.status_line]
560# Status line mode ("auto", "command", "hidden")
561mode = "auto"
562
563# How often to refresh status line (milliseconds)
564refresh_interval_ms = 2000
565
566# Timeout for command execution in status line (milliseconds)
567command_timeout_ms = 200
568
569# PTY (Pseudo Terminal) configuration - For interactive command execution
570[pty]
571# Enable PTY support for interactive commands
572enabled = true
573
574# Default number of terminal rows for PTY sessions
575default_rows = 24
576
577# Default number of terminal columns for PTY sessions
578default_cols = 80
579
580# Maximum number of concurrent PTY sessions
581max_sessions = 10
582
583# Command timeout in seconds (prevents hanging commands)
584command_timeout_seconds = 300
585
586# Number of recent lines to show in PTY output
587stdout_tail_lines = 20
588
589# Total lines to keep in PTY scrollback buffer
590scrollback_lines = 400
591
592# Context management configuration - Controls conversation memory
593[context]
594# Maximum number of tokens to keep in context (affects model cost and performance)
595# Higher values preserve more context but cost more and may hit token limits
596max_context_tokens = 90000
597
598# Percentage to trim context to when it gets too large
599trim_to_percent = 60
600
601# Number of recent conversation turns to always preserve
602preserve_recent_turns = 6
603
604# Decision ledger configuration - Track important decisions
605[context.ledger]
606# Enable decision tracking and persistence
607enabled = true
608
609# Maximum number of decisions to keep in ledger
610max_entries = 12
611
612# Include ledger summary in model prompts
613include_in_prompt = true
614
615# Preserve ledger during context compression
616preserve_in_compression = true
617
618# AI model routing - Intelligent model selection
619# Telemetry and analytics
620[telemetry]
621# Enable trajectory logging for usage analysis
622trajectory_enabled = true
623
624# Syntax highlighting configuration
625[syntax_highlighting]
626# Enable syntax highlighting for code in tool output
627enabled = true
628
629# Theme for syntax highlighting
630theme = "base16-ocean.dark"
631
632# Cache syntax highlighting themes for performance
633cache_themes = true
634
635# Maximum file size for syntax highlighting (in MB)
636max_file_size_mb = 10
637
638# Programming languages to enable syntax highlighting for
639enabled_languages = [
640 "rust",
641 "python",
642 "javascript",
643 "typescript",
644 "go",
645 "java",
646]
647
648# Timeout for syntax highlighting operations (milliseconds)
649highlight_timeout_ms = 1000
650
651# Automation features - Full-auto mode settings
652[automation.full_auto]
653# Enable full automation mode (DANGEROUS - requires careful oversight)
654enabled = false
655
656# Maximum number of turns before asking for human input
657max_turns = 30
658
659# Tools allowed in full automation mode
660allowed_tools = [
661 "write_file",
662 "read_file",
663 "list_files",
664 "grep_file",
665]
666
667# Require profile acknowledgment before using full auto
668require_profile_ack = true
669
670# Path to full auto profile configuration
671profile_path = "automation/full_auto_profile.toml"
672
673# Prompt caching - Cache model responses for efficiency
674[prompt_cache]
675# Enable prompt caching (reduces API calls for repeated prompts)
676enabled = false
677
678# Directory for cache storage
679cache_dir = "~/.vtcode/cache/prompts"
680
681# Maximum number of cache entries to keep
682max_entries = 1000
683
684# Maximum age of cache entries (in days)
685max_age_days = 30
686
687# Enable automatic cache cleanup
688enable_auto_cleanup = true
689
690# Minimum quality threshold to keep cache entries
691min_quality_threshold = 0.7
692
693# Prompt cache configuration for OpenAI
694 [prompt_cache.providers.openai]
695 enabled = true
696 min_prefix_tokens = 1024
697 idle_expiration_seconds = 3600
698 surface_metrics = true
699 # Optional: server-side prompt cache retention for OpenAI Responses API
700 # Example: "24h" (leave commented out for default behavior)
701 # prompt_cache_retention = "24h"
702
703# Prompt cache configuration for Anthropic
704[prompt_cache.providers.anthropic]
705enabled = true
706default_ttl_seconds = 300
707extended_ttl_seconds = 3600
708max_breakpoints = 4
709cache_system_messages = true
710cache_user_messages = true
711
712# Prompt cache configuration for Gemini
713[prompt_cache.providers.gemini]
714enabled = true
715mode = "implicit"
716min_prefix_tokens = 1024
717explicit_ttl_seconds = 3600
718
719# Prompt cache configuration for OpenRouter
720[prompt_cache.providers.openrouter]
721enabled = true
722propagate_provider_capabilities = true
723report_savings = true
724
725# Prompt cache configuration for Moonshot
726[prompt_cache.providers.moonshot]
727enabled = true
728
729# Prompt cache configuration for xAI
730[prompt_cache.providers.xai]
731enabled = true
732
733# Prompt cache configuration for DeepSeek
734[prompt_cache.providers.deepseek]
735enabled = true
736surface_metrics = true
737
738# Prompt cache configuration for Z.AI
739[prompt_cache.providers.zai]
740enabled = false
741
742# Model Context Protocol (MCP) - Connect external tools and services
743[mcp]
744# Enable Model Context Protocol (may impact startup time if services unavailable)
745enabled = true
746max_concurrent_connections = 5
747request_timeout_seconds = 30
748retry_attempts = 3
749
750# MCP UI configuration
751[mcp.ui]
752mode = "compact"
753max_events = 50
754show_provider_names = true
755
756# MCP renderer profiles for different services
757[mcp.ui.renderers]
758sequential-thinking = "sequential-thinking"
759context7 = "context7"
760
761# MCP provider configuration - External services that connect via MCP
762[[mcp.providers]]
763name = "time"
764command = "uvx"
765args = ["mcp-server-time"]
766enabled = true
767max_concurrent_requests = 3
768[mcp.providers.env]
769
770# Agent Client Protocol (ACP) - IDE integration
771[acp]
772enabled = true
773
774[acp.zed]
775enabled = true
776transport = "stdio"
777workspace_trust = "full_auto"
778
779[acp.zed.tools]
780read_file = true
781list_files = true"#.to_string()
782 }
783
784 #[cfg(feature = "bootstrap")]
785 fn default_vtcode_gitignore() -> String {
786 r#"# Security-focused exclusions
787.env, .env.local, secrets/, .aws/, .ssh/
788
789# Development artifacts
790target/, build/, dist/, node_modules/, vendor/
791
792# Database files
793*.db, *.sqlite, *.sqlite3
794
795# Binary files
796*.exe, *.dll, *.so, *.dylib, *.bin
797
798# IDE files (comprehensive)
799.vscode/, .idea/, *.swp, *.swo
800"#
801 .to_string()
802 }
803
804 #[cfg(feature = "bootstrap")]
805 pub fn create_sample_config<P: AsRef<Path>>(output: P) -> Result<()> {
807 let output = output.as_ref();
808 let config_content = Self::default_vtcode_toml_template();
809
810 fs::write(output, config_content)
811 .with_context(|| format!("Failed to write config file: {}", output.display()))?;
812
813 Ok(())
814 }
815}
816
817use crate::loader::layers::{ConfigLayerEntry, ConfigLayerSource, ConfigLayerStack};
818
819#[derive(Clone)]
821pub struct ConfigManager {
822 config: VTCodeConfig,
823 config_path: Option<PathBuf>,
824 workspace_root: Option<PathBuf>,
825 config_file_name: String,
826 layer_stack: ConfigLayerStack,
827}
828
829impl ConfigManager {
830 pub fn load() -> Result<Self> {
832 Self::load_from_workspace(std::env::current_dir()?)
833 }
834
835 pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
837 let workspace = workspace.as_ref();
838 let defaults_provider = defaults::current_config_defaults();
839 let workspace_paths = defaults_provider.workspace_paths_for(workspace);
840 let workspace_root = workspace_paths.workspace_root().to_path_buf();
841 let config_dir = workspace_paths.config_dir();
842 let config_file_name = defaults_provider.config_file_name().to_string();
843
844 let mut layer_stack = ConfigLayerStack::default();
845
846 #[cfg(unix)]
848 {
849 let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
850 if system_config.exists()
851 && let Ok(toml) = Self::load_toml_from_file(&system_config)
852 {
853 layer_stack.push(ConfigLayerEntry::new(
854 ConfigLayerSource::System {
855 file: system_config,
856 },
857 toml,
858 ));
859 }
860 }
861
862 for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
864 if home_config_path.exists()
865 && let Ok(toml) = Self::load_toml_from_file(&home_config_path)
866 {
867 layer_stack.push(ConfigLayerEntry::new(
868 ConfigLayerSource::User {
869 file: home_config_path,
870 },
871 toml,
872 ));
873 }
874 }
875
876 if let Some(project_config_path) =
878 Self::project_config_path(&config_dir, &workspace_root, &config_file_name)
879 && let Ok(toml) = Self::load_toml_from_file(&project_config_path)
880 {
881 layer_stack.push(ConfigLayerEntry::new(
882 ConfigLayerSource::Project {
883 file: project_config_path,
884 },
885 toml,
886 ));
887 }
888
889 let fallback_path = config_dir.join(&config_file_name);
891 let workspace_config_path = workspace_root.join(&config_file_name);
892 if fallback_path.exists()
893 && fallback_path != workspace_config_path
894 && let Ok(toml) = Self::load_toml_from_file(&fallback_path)
895 {
896 layer_stack.push(ConfigLayerEntry::new(
897 ConfigLayerSource::Workspace {
898 file: fallback_path,
899 },
900 toml,
901 ));
902 }
903
904 if workspace_config_path.exists()
906 && let Ok(toml) = Self::load_toml_from_file(&workspace_config_path)
907 {
908 layer_stack.push(ConfigLayerEntry::new(
909 ConfigLayerSource::Workspace {
910 file: workspace_config_path.clone(),
911 },
912 toml,
913 ));
914 }
915
916 if layer_stack.layers().is_empty() {
918 let config = VTCodeConfig::default();
919 config
920 .validate()
921 .context("Default configuration failed validation")?;
922
923 return Ok(Self {
924 config,
925 config_path: None,
926 workspace_root: Some(workspace_root),
927 config_file_name,
928 layer_stack,
929 });
930 }
931
932 let effective_toml = layer_stack.effective_config();
933 let config: VTCodeConfig = effective_toml
934 .try_into()
935 .context("Failed to deserialize effective configuration")?;
936
937 config
938 .validate()
939 .context("Configuration failed validation")?;
940
941 let config_path = layer_stack.layers().last().and_then(|l| match &l.source {
942 ConfigLayerSource::User { file } => Some(file.clone()),
943 ConfigLayerSource::Project { file } => Some(file.clone()),
944 ConfigLayerSource::Workspace { file } => Some(file.clone()),
945 ConfigLayerSource::System { file } => Some(file.clone()),
946 ConfigLayerSource::Runtime => None,
947 });
948
949 Ok(Self {
950 config,
951 config_path,
952 workspace_root: Some(workspace_root),
953 config_file_name,
954 layer_stack,
955 })
956 }
957
958 fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
959 let content = fs::read_to_string(path)
960 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
961 let value: toml::Value = toml::from_str(&content)
962 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
963 Ok(value)
964 }
965
966 pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
968 let path = path.as_ref();
969 let defaults_provider = defaults::current_config_defaults();
970 let config_file_name = path
971 .file_name()
972 .and_then(|name| name.to_str().map(ToOwned::to_owned))
973 .unwrap_or_else(|| defaults_provider.config_file_name().to_string());
974
975 let mut layer_stack = ConfigLayerStack::default();
976
977 #[cfg(unix)]
979 {
980 let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
981 if system_config.exists()
982 && let Ok(toml) = Self::load_toml_from_file(&system_config)
983 {
984 layer_stack.push(ConfigLayerEntry::new(
985 ConfigLayerSource::System {
986 file: system_config,
987 },
988 toml,
989 ));
990 }
991 }
992
993 for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
995 if home_config_path.exists()
996 && let Ok(toml) = Self::load_toml_from_file(&home_config_path)
997 {
998 layer_stack.push(ConfigLayerEntry::new(
999 ConfigLayerSource::User {
1000 file: home_config_path,
1001 },
1002 toml,
1003 ));
1004 }
1005 }
1006
1007 let toml = Self::load_toml_from_file(path)?;
1009 layer_stack.push(ConfigLayerEntry::new(
1010 ConfigLayerSource::Workspace {
1011 file: path.to_path_buf(),
1012 },
1013 toml,
1014 ));
1015
1016 let effective_toml = layer_stack.effective_config();
1017 let config: VTCodeConfig = effective_toml.try_into().with_context(|| {
1018 format!(
1019 "Failed to parse effective config with file: {}",
1020 path.display()
1021 )
1022 })?;
1023
1024 config.validate().with_context(|| {
1025 format!(
1026 "Failed to validate effective config with file: {}",
1027 path.display()
1028 )
1029 })?;
1030
1031 Ok(Self {
1032 config,
1033 config_path: Some(path.to_path_buf()),
1034 workspace_root: path.parent().map(Path::to_path_buf),
1035 config_file_name,
1036 layer_stack,
1037 })
1038 }
1039
1040 pub fn config(&self) -> &VTCodeConfig {
1042 &self.config
1043 }
1044
1045 pub fn config_path(&self) -> Option<&Path> {
1047 self.config_path.as_deref()
1048 }
1049
1050 pub fn layer_stack(&self) -> &ConfigLayerStack {
1052 &self.layer_stack
1053 }
1054
1055 pub fn effective_config(&self) -> toml::Value {
1057 self.layer_stack.effective_config()
1058 }
1059
1060 pub fn session_duration(&self) -> std::time::Duration {
1062 std::time::Duration::from_secs(60 * 60) }
1064
1065 pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
1067 let path = path.as_ref();
1068
1069 if path.exists() {
1071 let original_content = fs::read_to_string(path)
1072 .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
1073
1074 let mut doc = original_content
1075 .parse::<toml_edit::DocumentMut>()
1076 .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
1077
1078 let new_value =
1080 toml::to_string_pretty(config).context("Failed to serialize configuration")?;
1081 let new_doc: toml_edit::DocumentMut = new_value
1082 .parse()
1083 .context("Failed to parse serialized configuration")?;
1084
1085 Self::merge_toml_documents(&mut doc, &new_doc);
1087
1088 fs::write(path, doc.to_string())
1089 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
1090 } else {
1091 let content =
1093 toml::to_string_pretty(config).context("Failed to serialize configuration")?;
1094 fs::write(path, content)
1095 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
1096 }
1097
1098 Ok(())
1099 }
1100
1101 fn merge_toml_documents(original: &mut toml_edit::DocumentMut, new: &toml_edit::DocumentMut) {
1103 for (key, new_value) in new.iter() {
1104 if let Some(original_value) = original.get_mut(key) {
1105 Self::merge_toml_items(original_value, new_value);
1106 } else {
1107 original[key] = new_value.clone();
1108 }
1109 }
1110 }
1111
1112 fn merge_toml_items(original: &mut toml_edit::Item, new: &toml_edit::Item) {
1114 match (original, new) {
1115 (toml_edit::Item::Table(orig_table), toml_edit::Item::Table(new_table)) => {
1116 for (key, new_value) in new_table.iter() {
1117 if let Some(orig_value) = orig_table.get_mut(key) {
1118 Self::merge_toml_items(orig_value, new_value);
1119 } else {
1120 orig_table[key] = new_value.clone();
1121 }
1122 }
1123 }
1124 (orig, new) => {
1125 *orig = new.clone();
1126 }
1127 }
1128 }
1129
1130 fn project_config_path(
1131 config_dir: &Path,
1132 workspace_root: &Path,
1133 config_file_name: &str,
1134 ) -> Option<PathBuf> {
1135 let project_name = Self::identify_current_project(workspace_root)?;
1136 let project_config_path = config_dir
1137 .join("projects")
1138 .join(project_name)
1139 .join("config")
1140 .join(config_file_name);
1141
1142 if project_config_path.exists() {
1143 Some(project_config_path)
1144 } else {
1145 None
1146 }
1147 }
1148
1149 fn identify_current_project(workspace_root: &Path) -> Option<String> {
1150 let project_file = workspace_root.join(".vtcode-project");
1151 if let Ok(contents) = fs::read_to_string(&project_file) {
1152 let name = contents.trim();
1153 if !name.is_empty() {
1154 return Some(name.to_string());
1155 }
1156 }
1157
1158 workspace_root
1159 .file_name()
1160 .and_then(|name| name.to_str())
1161 .map(|name| name.to_string())
1162 }
1163}
1164
1165#[derive(Debug, Clone, Default)]
1167pub struct ConfigBuilder {
1168 workspace: Option<PathBuf>,
1169 config_file: Option<PathBuf>,
1170 cli_overrides: Vec<(String, toml::Value)>,
1171}
1172
1173impl ConfigBuilder {
1174 pub fn new() -> Self {
1176 Self::default()
1177 }
1178
1179 pub fn workspace(mut self, path: PathBuf) -> Self {
1181 self.workspace = Some(path);
1182 self
1183 }
1184
1185 pub fn config_file(mut self, path: PathBuf) -> Self {
1187 self.config_file = Some(path);
1188 self
1189 }
1190
1191 pub fn cli_override(mut self, key: String, value: toml::Value) -> Self {
1193 self.cli_overrides.push((key, value));
1194 self
1195 }
1196
1197 pub fn cli_overrides(mut self, overrides: &[(String, String)]) -> Self {
1201 for (key, value) in overrides {
1202 let toml_value = value
1203 .parse::<toml::Value>()
1204 .unwrap_or_else(|_| toml::Value::String(value.clone()));
1205 self.cli_overrides.push((key.clone(), toml_value));
1206 }
1207 self
1208 }
1209
1210 pub fn build(self) -> Result<ConfigManager> {
1212 let workspace = self
1213 .workspace
1214 .clone()
1215 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1216
1217 let mut manager = if let Some(config_file) = self.config_file {
1218 ConfigManager::load_from_file(config_file)?
1219 } else {
1220 ConfigManager::load_from_workspace(workspace)?
1221 };
1222
1223 if !self.cli_overrides.is_empty() {
1224 let mut runtime_toml = toml::Table::new();
1225 for (key, value) in self.cli_overrides {
1226 Self::insert_dotted_key(&mut runtime_toml, &key, value);
1227 }
1228
1229 let runtime_layer =
1230 ConfigLayerEntry::new(ConfigLayerSource::Runtime, toml::Value::Table(runtime_toml));
1231
1232 manager.layer_stack.push(runtime_layer);
1233
1234 let effective_toml = manager.layer_stack.effective_config();
1236 manager.config = effective_toml
1237 .try_into()
1238 .context("Failed to deserialize effective configuration after runtime overrides")?;
1239 manager
1240 .config
1241 .validate()
1242 .context("Configuration failed validation after runtime overrides")?;
1243 }
1244
1245 Ok(manager)
1246 }
1247
1248 fn insert_dotted_key(table: &mut toml::Table, key: &str, value: toml::Value) {
1249 let parts: Vec<&str> = key.split('.').collect();
1250 let mut current = table;
1251 for (i, part) in parts.iter().enumerate() {
1252 if i == parts.len() - 1 {
1253 current.insert(part.to_string(), value);
1254 return;
1255 }
1256
1257 if !current.contains_key(*part) || !current[*part].is_table() {
1258 current.insert(part.to_string(), toml::Value::Table(toml::Table::new()));
1259 }
1260
1261 current = current
1262 .get_mut(*part)
1263 .and_then(|v| v.as_table_mut())
1264 .expect("Value must be a table");
1265 }
1266 }
1267}
1268
1269pub fn merge_toml_values(base: &mut toml::Value, overlay: &toml::Value) {
1274 match (base, overlay) {
1275 (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
1276 for (key, value) in overlay_table {
1277 if let Some(base_value) = base_table.get_mut(key) {
1278 merge_toml_values(base_value, value);
1279 } else {
1280 base_table.insert(key.clone(), value.clone());
1281 }
1282 }
1283 }
1284 (base, overlay) => {
1285 *base = overlay.clone();
1286 }
1287 }
1288}
1289
1290impl ConfigManager {
1291 pub fn save_config(&self, config: &VTCodeConfig) -> Result<()> {
1293 if let Some(path) = &self.config_path {
1294 return Self::save_config_to_path(path, config);
1295 }
1296
1297 if let Some(workspace_root) = &self.workspace_root {
1298 let path = workspace_root.join(&self.config_file_name);
1299 return Self::save_config_to_path(path, config);
1300 }
1301
1302 let cwd = std::env::current_dir().context("Failed to resolve current directory")?;
1303 let path = cwd.join(&self.config_file_name);
1304 Self::save_config_to_path(path, config)
1305 }
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310 use super::*;
1311 use crate::defaults::WorkspacePathsDefaults;
1312 use std::io::Write;
1313 use std::sync::Arc;
1314 use tempfile::NamedTempFile;
1315 use vtcode_commons::reference::StaticWorkspacePaths;
1316
1317 #[test]
1318 fn test_layered_config_loading() {
1319 let workspace = assert_fs::TempDir::new().expect("failed to create workspace");
1320 let workspace_root = workspace.path();
1321
1322 let home_dir = workspace_root.join("home");
1324 fs::create_dir_all(&home_dir).expect("failed to create home dir");
1325 let user_config_path = home_dir.join("vtcode.toml");
1326 fs::write(&user_config_path, "agent.provider = \"anthropic\"")
1327 .expect("failed to write user config");
1328
1329 let workspace_config_path = workspace_root.join("vtcode.toml");
1331 fs::write(
1332 &workspace_config_path,
1333 "agent.default_model = \"claude-3-sonnet\"",
1334 )
1335 .expect("failed to write workspace config");
1336
1337 let static_paths =
1338 StaticWorkspacePaths::new(workspace_root, &workspace_root.join(".vtcode"));
1339 let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
1340 .with_home_paths(vec![user_config_path.clone()]);
1341
1342 defaults::provider::with_config_defaults_provider_for_test(Arc::new(provider), || {
1343 let manager =
1344 ConfigManager::load_from_workspace(workspace_root).expect("failed to load config");
1345
1346 assert_eq!(manager.config().agent.provider, "anthropic");
1347 assert_eq!(manager.config().agent.default_model, "claude-3-sonnet");
1348
1349 let layers = manager.layer_stack().layers();
1350 assert_eq!(layers.len(), 2);
1352 assert!(matches!(layers[0].source, ConfigLayerSource::User { .. }));
1353 assert!(matches!(
1354 layers[1].source,
1355 ConfigLayerSource::Workspace { .. }
1356 ));
1357 });
1358 }
1359
1360 #[test]
1361 fn test_config_builder_overrides() {
1362 let workspace = assert_fs::TempDir::new().expect("failed to create workspace");
1363 let workspace_root = workspace.path();
1364
1365 let workspace_config_path = workspace_root.join("vtcode.toml");
1366 fs::write(&workspace_config_path, "agent.provider = \"openai\"")
1367 .expect("failed to write workspace config");
1368
1369 let static_paths =
1370 StaticWorkspacePaths::new(workspace_root, &workspace_root.join(".vtcode"));
1371 let provider = WorkspacePathsDefaults::new(Arc::new(static_paths)).with_home_paths(vec![]);
1372
1373 defaults::provider::with_config_defaults_provider_for_test(Arc::new(provider), || {
1374 let manager = ConfigBuilder::new()
1375 .workspace(workspace_root.to_path_buf())
1376 .cli_override(
1377 "agent.provider".to_string(),
1378 toml::Value::String("gemini".to_string()),
1379 )
1380 .cli_override(
1381 "agent.default_model".to_string(),
1382 toml::Value::String("gemini-1.5-pro".to_string()),
1383 )
1384 .build()
1385 .expect("failed to build config");
1386
1387 assert_eq!(manager.config().agent.provider, "gemini");
1388 assert_eq!(manager.config().agent.default_model, "gemini-1.5-pro");
1389
1390 let layers = manager.layer_stack().layers();
1391 assert_eq!(layers.len(), 2);
1393 assert!(matches!(
1394 layers[0].source,
1395 ConfigLayerSource::Workspace { .. }
1396 ));
1397 assert!(matches!(layers[1].source, ConfigLayerSource::Runtime));
1398 });
1399 }
1400
1401 #[test]
1402 fn test_insert_dotted_key() {
1403 let mut table = toml::Table::new();
1404 ConfigBuilder::insert_dotted_key(
1405 &mut table,
1406 "a.b.c",
1407 toml::Value::String("value".to_string()),
1408 );
1409
1410 let a = table.get("a").unwrap().as_table().unwrap();
1411 let b = a.get("b").unwrap().as_table().unwrap();
1412 let c = b.get("c").unwrap().as_str().unwrap();
1413 assert_eq!(c, "value");
1414 }
1415
1416 #[test]
1417 fn test_merge_toml_values() {
1418 let mut base = toml::from_str::<toml::Value>(
1419 r#"
1420 [agent]
1421 provider = "openai"
1422 [tools]
1423 default_policy = "prompt"
1424 "#,
1425 )
1426 .unwrap();
1427
1428 let overlay = toml::from_str::<toml::Value>(
1429 r#"
1430 [agent]
1431 provider = "anthropic"
1432 default_model = "claude-3"
1433 "#,
1434 )
1435 .unwrap();
1436
1437 merge_toml_values(&mut base, &overlay);
1438
1439 let agent = base.get("agent").unwrap().as_table().unwrap();
1440 assert_eq!(
1441 agent.get("provider").unwrap().as_str().unwrap(),
1442 "anthropic"
1443 );
1444 assert_eq!(
1445 agent.get("default_model").unwrap().as_str().unwrap(),
1446 "claude-3"
1447 );
1448
1449 let tools = base.get("tools").unwrap().as_table().unwrap();
1450 assert_eq!(
1451 tools.get("default_policy").unwrap().as_str().unwrap(),
1452 "prompt"
1453 );
1454 }
1455
1456 #[test]
1457 fn syntax_highlighting_defaults_are_valid() {
1458 let config = SyntaxHighlightingConfig::default();
1459 config
1460 .validate()
1461 .expect("default syntax highlighting config should be valid");
1462 }
1463
1464 #[test]
1465 fn vtcode_config_validation_fails_for_invalid_highlight_timeout() {
1466 let mut config = VTCodeConfig::default();
1467 config.syntax_highlighting.highlight_timeout_ms = 0;
1468 let error = config
1469 .validate()
1470 .expect_err("validation should fail for zero highlight timeout");
1471 assert!(
1472 format!("{:#}", error).contains("highlight"),
1473 "expected error to mention highlight, got: {:#}",
1474 error
1475 );
1476 }
1477
1478 #[test]
1479 fn load_from_file_rejects_invalid_syntax_highlighting() {
1480 let mut temp_file = NamedTempFile::new().expect("failed to create temp file");
1481 writeln!(
1482 temp_file,
1483 "[syntax_highlighting]\nhighlight_timeout_ms = 0\n"
1484 )
1485 .expect("failed to write temp config");
1486
1487 let result = ConfigManager::load_from_file(temp_file.path());
1488 assert!(result.is_err(), "expected validation error");
1489 let error = format!("{:?}", result.err().unwrap());
1490 assert!(
1491 error.contains("validate"),
1492 "expected validation context in error, got: {}",
1493 error
1494 );
1495 }
1496
1497 #[test]
1498 fn loader_loads_prompt_cache_retention_from_toml() {
1499 use std::fs::File;
1500 use std::io::Write;
1501
1502 let temp = tempfile::tempdir().unwrap();
1503 let path = temp.path().join("vtcode.toml");
1504 let mut file = File::create(&path).unwrap();
1505 let contents = r#"
1506[prompt_cache]
1507enabled = true
1508[prompt_cache.providers.openai]
1509prompt_cache_retention = "24h"
1510"#;
1511 file.write_all(contents.as_bytes()).unwrap();
1512
1513 let manager = ConfigManager::load_from_file(&path).unwrap();
1514 let config = manager.config();
1515 assert_eq!(
1516 config.prompt_cache.providers.openai.prompt_cache_retention,
1517 Some("24h".to_string())
1518 );
1519 }
1520
1521 #[test]
1522 fn save_config_preserves_comments() {
1523 use std::io::Write;
1524
1525 let mut temp_file = NamedTempFile::new().expect("failed to create temp file");
1526 let config_with_comments = r#"# This is a test comment
1527[agent]
1528# Provider comment
1529provider = "openai"
1530default_model = "gpt-5-nano"
1531
1532# Tools section comment
1533[tools]
1534default_policy = "prompt"
1535"#;
1536
1537 write!(temp_file, "{}", config_with_comments).expect("failed to write temp config");
1538 temp_file.flush().expect("failed to flush");
1539
1540 let manager =
1542 ConfigManager::load_from_file(temp_file.path()).expect("failed to load config");
1543
1544 let mut modified_config = manager.config().clone();
1546 modified_config.agent.default_model = "gpt-5".to_string();
1547
1548 ConfigManager::save_config_to_path(temp_file.path(), &modified_config)
1549 .expect("failed to save config");
1550
1551 let saved_content =
1553 fs::read_to_string(temp_file.path()).expect("failed to read saved config");
1554
1555 assert!(
1556 saved_content.contains("# This is a test comment"),
1557 "top-level comment should be preserved"
1558 );
1559 assert!(
1560 saved_content.contains("# Provider comment"),
1561 "inline comment should be preserved"
1562 );
1563 assert!(
1564 saved_content.contains("# Tools section comment"),
1565 "section comment should be preserved"
1566 );
1567 assert!(
1568 saved_content.contains("gpt-5"),
1569 "modified value should be present"
1570 );
1571 }
1572
1573 #[test]
1574 fn config_defaults_provider_overrides_paths_and_theme() {
1575 let workspace = assert_fs::TempDir::new().expect("failed to create workspace");
1576 let workspace_root = workspace.path();
1577 let config_dir = workspace_root.join("config-root");
1578 fs::create_dir_all(&config_dir).expect("failed to create config directory");
1579
1580 let config_file_name = "custom-config.toml";
1581 let config_path = config_dir.join(config_file_name);
1582 let serialized =
1583 toml::to_string(&VTCodeConfig::default()).expect("failed to serialize default config");
1584 fs::write(&config_path, serialized).expect("failed to write config file");
1585
1586 let static_paths = StaticWorkspacePaths::new(workspace_root, &config_dir);
1587 let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
1588 .with_config_file_name(config_file_name)
1589 .with_home_paths(Vec::new())
1590 .with_syntax_theme("custom-theme")
1591 .with_syntax_languages(vec!["zig".to_string()]);
1592
1593 defaults::provider::with_config_defaults_provider_for_test(Arc::new(provider), || {
1594 let manager = ConfigManager::load_from_workspace(workspace_root)
1595 .expect("failed to load workspace config");
1596
1597 let resolved_path = manager
1598 .config_path()
1599 .expect("config path should be resolved");
1600 assert_eq!(resolved_path, config_path);
1601
1602 assert_eq!(SyntaxHighlightingDefaults::theme(), "custom-theme");
1603 assert_eq!(
1604 SyntaxHighlightingDefaults::enabled_languages(),
1605 vec!["zig".to_string()]
1606 );
1607 });
1608 }
1609}