Skip to main content

Mountain/Environment/ConfigurationProvider/
Loading.rs

1//! Configuration loading, caching, and merging.
2//!
3//! Provides the three public entry points consumed by the rest of the
4//! `ConfigurationProvider` module:
5//!
6//! - `read_and_parse_configuration_file` - reads a single `settings.json` from
7//!   disk via the async `ApplicationRunTime`, with a 250 ms TTL parse cache to
8//!   avoid redundant disk reads during burst `Inspect` calls.
9//! - `initialize_and_merge_configurations` - rebuilds the merged
10//!   `GlobalConfiguration` by layering Default → User → Workspace in precedence
11//!   order (deep-merge for nested objects, shallow for root keys).
12//! - `collect_default_configurations` - walks every scanned extension's
13//!   `contributes.configuration.properties` map and extracts `default` values,
14//!   inserting them into a nested map keyed by dotted path.
15//! - `ClearSettingsFileCache` - invalidates the parse cache; called by
16//!   `UpdateValue` after any write so the next read sees fresh content.
17
18use std::{
19	collections::HashMap,
20	path::PathBuf,
21	sync::{Arc, Mutex, OnceLock},
22	time::{Duration, Instant},
23};
24
25use CommonLibrary::{
26	Effect::ApplicationRunTime::ApplicationRunTime as _,
27	Error::CommonError::CommonError,
28	FileSystem::ReadFile::ReadFile,
29};
30use serde_json::{Map, Value};
31use tauri::Manager;
32
33use crate::{
34	ApplicationState::DTO::MergedConfigurationStateDTO::MergedConfigurationStateDTO,
35	Environment::Utility,
36	RunTime::ApplicationRunTime::ApplicationRunTime,
37	dev_log,
38};
39
40/// Short TTL cache for parsed `settings.json` reads. The
41/// `InspectConfigurationValue` handler reads BOTH the user
42/// settings.json and the workspace settings.json on every call;
43/// log audit `20260501T053137` shows ~57 Inspect calls per session
44/// = 114 disk reads of the same one or two files. With this cache,
45/// repeated reads within `TTL_MS` reuse the parsed `Value` and a
46/// burst of Inspects collapses to ~1 disk read per file. TTL is
47/// short enough (250ms) that user edits to settings.json show up
48/// within a quarter-second.
49const SETTINGS_FILE_CACHE_TTL_MS:u64 = 250;
50
51struct CachedSettingsValue {
52	StoredAt:Instant,
53
54	Parsed:Value,
55}
56
57fn SettingsFileCache() -> &'static Mutex<HashMap<PathBuf, CachedSettingsValue>> {
58	static CACHE:OnceLock<Mutex<HashMap<PathBuf, CachedSettingsValue>>> = OnceLock::new();
59
60	CACHE.get_or_init(|| Mutex::new(HashMap::new()))
61}
62
63/// Drop every cached settings.json parse. Caller: any code path
64/// that mutates settings (`UpdateConfigurationValue`,
65/// `initialize_and_merge_configurations`).
66pub fn ClearSettingsFileCache() {
67	if let Ok(mut Guard) = SettingsFileCache().lock() {
68		Guard.clear();
69	}
70}
71
72/// An internal helper to read and parse a single JSON configuration file.
73pub(super) async fn read_and_parse_configuration_file(
74	environment:&crate::Environment::MountainEnvironment::MountainEnvironment,
75
76	path:&Option<PathBuf>,
77) -> Result<Value, CommonError> {
78	if let Some(p) = path {
79		// Cache check: return a clone of the parsed value if the same
80		// file was read within the TTL window.
81		if let Ok(Guard) = SettingsFileCache().lock() {
82			if let Some(Entry) = Guard.get(p) {
83				if Entry.StoredAt.elapsed() < Duration::from_millis(SETTINGS_FILE_CACHE_TTL_MS) {
84					return Ok(Entry.Parsed.clone());
85				}
86			}
87		}
88
89		let runtime = environment.ApplicationHandle.state::<Arc<ApplicationRunTime>>().inner().clone();
90
91		if let Ok(bytes) = runtime.Run(ReadFile(p.clone())).await {
92			let Parsed = serde_json::from_slice(&bytes).unwrap_or_else(|_| Value::Object(Map::new()));
93
94			if let Ok(mut Guard) = SettingsFileCache().lock() {
95				Guard.insert(
96					p.clone(),
97					CachedSettingsValue { StoredAt:Instant::now(), Parsed:Parsed.clone() },
98				);
99			}
100
101			return Ok(Parsed);
102		}
103	}
104
105	Ok(Value::Object(Map::new()))
106}
107
108/// Logic to load and merge all configuration files into the effective
109/// configuration stored in `ApplicationState`.
110pub async fn initialize_and_merge_configurations(
111	environment:&crate::Environment::MountainEnvironment::MountainEnvironment,
112) -> Result<(), CommonError> {
113	dev_log!(
114		"config",
115		"[ConfigurationProvider] Re-initializing and merging all configurations..."
116	);
117
118	let default_config = collect_default_configurations(&environment.ApplicationState)?;
119
120	let user_settings_path = environment
121		.ApplicationHandle
122		.path()
123		.app_config_dir()
124		.map(|p| p.join("settings.json"))
125		.ok();
126
127	let workspace_settings_path = environment
128		.ApplicationState
129		.Workspace
130		.WorkspaceConfigurationPath
131		.lock()
132		.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
133		.clone();
134
135	let user_config = read_and_parse_configuration_file(environment, &user_settings_path).await?;
136
137	let workspace_config = read_and_parse_configuration_file(environment, &workspace_settings_path).await?;
138
139	// A true deep merge is required here. The merge order matches the cascade:
140	// Default (base) → User (overrides default) → Workspace (overrides user)
141	let mut merged = default_config.as_object().cloned().unwrap_or_default();
142
143	if let Some(user_map) = user_config.as_object() {
144		for (key, value) in user_map {
145			// Deep merge nested objects, shallow merge at root level
146			if value.is_object() && merged.get(key.as_str()).is_some_and(|v| v.is_object()) {
147				if let (Some(user_value), Some(_base_value)) =
148					(value.as_object(), merged.get(key.as_str()).and_then(|v| v.as_object()))
149				{
150					for (inner_key, inner_value) in user_value {
151						merged.get_mut(key.as_str()).and_then(|v| v.as_object_mut()).map(|m| {
152							m.insert(inner_key.clone(), inner_value.clone());
153						});
154					}
155				}
156			} else {
157				merged.insert(key.clone(), value.clone());
158			}
159		}
160	}
161
162	if let Some(workspace_map) = workspace_config.as_object() {
163		for (key, value) in workspace_map {
164			if value.is_object() && merged.get(key.as_str()).is_some_and(|v| v.is_object()) {
165				if let (Some(workspace_value), Some(_base_value)) =
166					(value.as_object(), merged.get(key.as_str()).and_then(|v| v.as_object()))
167				{
168					for (inner_key, inner_value) in workspace_value {
169						merged.get_mut(key.as_str()).and_then(|v| v.as_object_mut()).map(|m| {
170							m.insert(inner_key.clone(), inner_value.clone());
171						});
172					}
173				}
174			} else {
175				merged.insert(key.clone(), value.clone());
176			}
177		}
178	}
179
180	let configuration_size = merged.len();
181
182	let final_config = MergedConfigurationStateDTO::Create(Value::Object(merged));
183
184	*environment
185		.ApplicationState
186		.Configuration
187		.GlobalConfiguration
188		.lock()
189		.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)? = final_config.Data;
190
191	dev_log!(
192		"config",
193		"[ConfigurationProvider] Configuration merged successfully with {} top-level keys.",
194		configuration_size
195	);
196
197	Ok(())
198}
199
200/// Collects default configurations from all installed extensions.
201///
202/// Reads each extension's `contributes.configuration` entry and pulls
203/// the `default` value out of every property declaration. Stock VS Code
204/// extensions (vscode.git, vscode.npm, gitlens, etc.) declare their
205/// settings via the `properties` map shape:
206///
207/// ```jsonc
208/// "contributes": {
209///   "configuration": {
210///     "title": "Git",
211///     "properties": {
212///       "git.enabled":                 { "type": "boolean", "default": true,  "description": "…" },
213///       "git.path":                    { "type": ["string","array"], "default": null, "description": "…" },
214///       "git.autoRepositoryDetection": { "type": ["boolean","string"], "default": true, "description": "…" }
215///     }
216///   }
217/// }
218/// ```
219///
220/// The previous implementation searched for a `[ {key, value} ]` array
221/// shape that doesn't exist in any real VS Code manifest, so EVERY
222/// `vscode.workspace.getConfiguration(...).get('foo')` lookup fell
223/// through to undefined. Extensions that use the lookup's first arg
224/// alone (no explicit default) saw undefined and silently bailed -
225/// which is the failure mode behind vscode.git activating but never
226/// reaching `vscode.scm.createSourceControl(...)`.
227///
228/// `contributes.configuration` accepts BOTH a single object AND an
229/// array of objects (older multi-section schema), so we walk both
230/// shapes and recursively dive into `properties`. The dotted key
231/// (`git.enabled`) is split into a nested map shape so callers using
232/// `inspect_configuration_value`'s `path.split('.').try_fold(...)`
233/// land on the right node.
234pub(super) fn collect_default_configurations(
235	application_state:&crate::ApplicationState::State::ApplicationState::ApplicationState,
236) -> Result<Value, CommonError> {
237	let mut default_config = Map::new();
238
239	for extension in application_state
240		.Extension
241		.ScannedExtensions
242		.ScannedExtensions
243		.lock()
244		.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
245		.values()
246	{
247		let Some(contributes) = &extension.Contributes else {
248			continue;
249		};
250
251		let Some(configuration) = contributes.get("configuration") else {
252			continue;
253		};
254
255		// Walk EITHER an array of {properties} blocks OR a single one.
256		let blocks:Vec<&Value> = if let Some(array) = configuration.as_array() {
257			array.iter().collect()
258		} else {
259			vec![configuration]
260		};
261
262		for block in blocks {
263			let Some(properties) = block.get("properties").and_then(|p| p.as_object()) else {
264				continue;
265			};
266
267			for (DottedKey, schema) in properties {
268				let Some(default) = schema.get("default") else {
269					continue;
270				};
271
272				InsertDottedDefault(&mut default_config, DottedKey, default.clone());
273			}
274		}
275	}
276
277	Ok(Value::Object(default_config))
278}
279
280/// Insert a value into `target` at the dotted path `git.enabled`,
281/// creating intermediate object nodes as needed. Mirrors
282/// `inspect_configuration_value`'s `try_fold` traversal so a lookup
283/// for `git.enabled` finds `target["git"]["enabled"]`.
284fn InsertDottedDefault(target:&mut Map<String, Value>, dotted:&str, value:Value) {
285	let parts:Vec<&str> = dotted.split('.').collect();
286
287	if parts.is_empty() {
288		return;
289	}
290
291	if parts.len() == 1 {
292		target.insert(parts[0].to_string(), value);
293
294		return;
295	}
296
297	let head = parts[0];
298
299	let entry = target.entry(head.to_string()).or_insert_with(|| Value::Object(Map::new()));
300
301	if !entry.is_object() {
302		// Conflicting prior insert (e.g. another extension declared
303		// `git` as a non-object). Replace with a fresh map so we don't
304		// silently drop the deeper key. Last-writer-wins matches the
305		// merge precedence in `initialize_and_merge_configurations`.
306		*entry = Value::Object(Map::new());
307	}
308
309	if let Some(child) = entry.as_object_mut() {
310		// Walk the rest of the dotted path recursively. Re-build a
311		// `Map<String, Value>` and insert from there, then move it
312		// back. (Borrow-checker-friendly variant of in-place
313		// recursion.)
314		let mut sub = std::mem::take(child);
315
316		let RemainingDotted = parts[1..].join(".");
317
318		InsertDottedDefault(&mut sub, &RemainingDotted, value);
319
320		*child = sub;
321	}
322}