Skip to main content

vtcode_config/loader/
layers.rs

1use crate::loader::merge::{merge_toml_values, merge_toml_values_with_origins};
2use hashbrown::HashMap;
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use toml::Value as TomlValue;
6
7use super::fingerprint::fingerprint_toml_value;
8
9/// Source of a configuration layer.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub enum ConfigLayerSource {
12    /// System-wide configuration (e.g., /etc/vtcode/vtcode.toml)
13    System { file: PathBuf },
14    /// User-specific configuration (the canonical platform config directory;
15    /// the legacy `~/.vtcode` directory remains a read-compatibility source).
16    User { file: PathBuf },
17    /// Project-specific configuration (e.g., .vtcode/projects/foo/config/vtcode.toml)
18    Project { file: PathBuf },
19    /// Workspace-specific configuration (e.g., vtcode.toml in workspace root)
20    Workspace { file: PathBuf },
21    /// Runtime overrides (e.g., CLI flags)
22    Runtime,
23}
24
25impl ConfigLayerSource {
26    /// Lower numbers are lower precedence.
27    const fn precedence(&self) -> i16 {
28        match self {
29            Self::System { .. } => 10,
30            Self::User { .. } => 20,
31            Self::Project { .. } => 25,
32            Self::Workspace { .. } => 30,
33            Self::Runtime => 40,
34        }
35    }
36
37    pub fn label(&self) -> String {
38        match self {
39            Self::System { file } => format!("system:{}", file.display()),
40            Self::User { file } => format!("user:{}", file.display()),
41            Self::Project { file } => format!("project:{}", file.display()),
42            Self::Workspace { file } => format!("workspace:{}", file.display()),
43            Self::Runtime => "runtime".to_string(),
44        }
45    }
46
47    /// Return a copy of this source with an updated file path.
48    pub(crate) fn with_file(&self, new_file: PathBuf) -> Self {
49        match self {
50            Self::System { .. } => Self::System { file: new_file },
51            Self::User { .. } => Self::User { file: new_file },
52            Self::Project { .. } => Self::Project { file: new_file },
53            Self::Workspace { .. } => Self::Workspace { file: new_file },
54            Self::Runtime => Self::Runtime,
55        }
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ConfigLayerMetadata {
61    pub name: String,
62    pub version: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum LayerDisabledReason {
68    ParseError,
69    LoadError,
70    UntrustedWorkspace,
71    PolicyDisabled,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ConfigLayerLoadError {
76    pub message: String,
77}
78
79/// A single layer of configuration.
80#[derive(Debug, Clone, PartialEq)]
81pub struct ConfigLayerEntry {
82    /// Source of this layer
83    pub source: ConfigLayerSource,
84    /// Stable metadata for this layer
85    pub metadata: ConfigLayerMetadata,
86    /// Parsed TOML content
87    pub(crate) config: TomlValue,
88    /// Optional reason the layer was disabled
89    pub disabled_reason: Option<LayerDisabledReason>,
90    /// Optional error attached to this layer
91    pub error: Option<ConfigLayerLoadError>,
92}
93
94impl ConfigLayerEntry {
95    /// Create a new configuration layer entry.
96    pub(crate) fn new(source: ConfigLayerSource, config: TomlValue) -> Self {
97        let metadata = ConfigLayerMetadata {
98            name: source.label(),
99            version: fingerprint_toml_value(&config),
100        };
101        Self {
102            source,
103            metadata,
104            config,
105            disabled_reason: None,
106            error: None,
107        }
108    }
109
110    /// Create a disabled layer entry while retaining layer metadata.
111    pub(crate) fn disabled(source: ConfigLayerSource, reason: LayerDisabledReason, message: impl Into<String>) -> Self {
112        let message = message.into();
113        let config = TomlValue::Table(toml::Table::new());
114        let metadata = ConfigLayerMetadata {
115            name: source.label(),
116            version: fingerprint_toml_value(&TomlValue::String(format!("{}:{}", source.label(), message))),
117        };
118        Self {
119            source,
120            metadata,
121            config,
122            disabled_reason: Some(reason),
123            error: Some(ConfigLayerLoadError { message }),
124        }
125    }
126
127    pub fn is_enabled(&self) -> bool {
128        self.disabled_reason.is_none() && self.error.is_none()
129    }
130}
131
132/// A stack of configuration layers, ordered from lowest to highest precedence.
133#[derive(Debug, Clone, Default)]
134pub struct ConfigLayerStack {
135    layers: Vec<ConfigLayerEntry>,
136}
137
138impl ConfigLayerStack {
139    /// Create a new configuration layer stack.
140    pub fn new(layers: Vec<ConfigLayerEntry>) -> Self {
141        Self { layers }
142    }
143
144    /// Add a layer to the stack.
145    pub(crate) fn push(&mut self, layer: ConfigLayerEntry) {
146        self.layers.push(layer);
147    }
148
149    /// Retain only layers matching the predicate.
150    pub(crate) fn retain<F: FnMut(&ConfigLayerEntry) -> bool>(&mut self, mut f: F) {
151        self.layers.retain(|layer| f(layer));
152    }
153
154    /// Merge all layers into a single effective configuration.
155    pub(crate) fn effective_config(&self) -> TomlValue {
156        self.effective_config_with_origins().0
157    }
158
159    /// Merge all layers and return an origin map (`path -> winning layer metadata`).
160    pub fn effective_config_with_origins(&self) -> (TomlValue, HashMap<String, ConfigLayerMetadata>) {
161        let mut merged = TomlValue::Table(toml::Table::new());
162        let mut origins = HashMap::new();
163        for layer in self.ordered_enabled_layers() {
164            merge_toml_values_with_origins(&mut merged, &layer.config, &mut origins, &layer.metadata);
165        }
166        (merged, origins)
167    }
168
169    /// Return the first layer error in precedence order.
170    pub(crate) fn first_layer_error(&self) -> Option<(&ConfigLayerEntry, &ConfigLayerLoadError)> {
171        for layer in self.ordered_layers() {
172            if let Some(error) = layer.error.as_ref() {
173                return Some((layer, error));
174            }
175        }
176        None
177    }
178
179    /// Merge all enabled layers without origin tracking.
180    pub fn effective_config_without_origins(&self) -> TomlValue {
181        let mut merged = TomlValue::Table(toml::Table::new());
182        for layer in self.ordered_enabled_layers() {
183            merge_toml_values(&mut merged, &layer.config);
184        }
185        merged
186    }
187
188    fn ordered_layers(&self) -> Vec<&ConfigLayerEntry> {
189        let mut with_index: Vec<(usize, &ConfigLayerEntry)> = self.layers.iter().enumerate().collect();
190        with_index.sort_by(|(left_idx, left), (right_idx, right)| {
191            left.source
192                .precedence()
193                .cmp(&right.source.precedence())
194                .then(left_idx.cmp(right_idx))
195        });
196        with_index.into_iter().map(|(_, layer)| layer).collect()
197    }
198
199    fn ordered_enabled_layers(&self) -> Vec<&ConfigLayerEntry> {
200        self.ordered_layers().into_iter().filter(|layer| layer.is_enabled()).collect()
201    }
202
203    /// Get all layers in the stack.
204    pub fn layers(&self) -> &[ConfigLayerEntry] {
205        &self.layers
206    }
207}