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(crate) 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 Fn(environment:&crate::Environment::MountainEnvironment::MountainEnvironment) -> Result<(), CommonError> {
111	dev_log!(
112		"config",
113		"[ConfigurationProvider] Re-initializing and merging all configurations..."
114	);
115
116	let default_config = collect_default_configurations(&environment.ApplicationState)?;
117
118	let user_settings_path = environment
119		.ApplicationHandle
120		.path()
121		.app_config_dir()
122		.map(|p| p.join("settings.json"))
123		.ok();
124
125	let workspace_settings_path = environment
126		.ApplicationState
127		.Workspace
128		.WorkspaceConfigurationPath
129		.lock()
130		.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
131		.clone();
132
133	let user_config = read_and_parse_configuration_file(environment, &user_settings_path).await?;
134
135	let workspace_config = read_and_parse_configuration_file(environment, &workspace_settings_path).await?;
136
137	// A true deep merge is required here. The merge order matches the cascade:
138	// Default (base) → User (overrides default) → Workspace (overrides user)
139	let mut merged = default_config.as_object().cloned().unwrap_or_default();
140
141	if let Some(user_map) = user_config.as_object() {
142		for (key, value) in user_map {
143			// Deep merge nested objects, shallow merge at root level
144			if value.is_object() && merged.get(key.as_str()).is_some_and(|v| v.is_object()) {
145				if let (Some(user_value), Some(_base_value)) =
146					(value.as_object(), merged.get(key.as_str()).and_then(|v| v.as_object()))
147				{
148					for (inner_key, inner_value) in user_value {
149						merged.get_mut(key.as_str()).and_then(|v| v.as_object_mut()).map(|m| {
150							m.insert(inner_key.clone(), inner_value.clone());
151						});
152					}
153				}
154			} else {
155				merged.insert(key.clone(), value.clone());
156			}
157		}
158	}
159
160	if let Some(workspace_map) = workspace_config.as_object() {
161		for (key, value) in workspace_map {
162			if value.is_object() && merged.get(key.as_str()).is_some_and(|v| v.is_object()) {
163				if let (Some(workspace_value), Some(_base_value)) =
164					(value.as_object(), merged.get(key.as_str()).and_then(|v| v.as_object()))
165				{
166					for (inner_key, inner_value) in workspace_value {
167						merged.get_mut(key.as_str()).and_then(|v| v.as_object_mut()).map(|m| {
168							m.insert(inner_key.clone(), inner_value.clone());
169						});
170					}
171				}
172			} else {
173				merged.insert(key.clone(), value.clone());
174			}
175		}
176	}
177
178	let configuration_size = merged.len();
179
180	let final_config = MergedConfigurationStateDTO::Create(Value::Object(merged));
181
182	*environment
183		.ApplicationState
184		.Configuration
185		.GlobalConfiguration
186		.lock()
187		.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)? = final_config.Data;
188
189	dev_log!(
190		"config",
191		"[ConfigurationProvider] Configuration merged successfully with {} top-level keys.",
192		configuration_size
193	);
194
195	Ok(())
196}
197
198/// Collects default configurations from all installed extensions.
199///
200/// Reads each extension's `contributes.configuration` entry and pulls
201/// the `default` value out of every property declaration. Stock VS Code
202/// extensions (vscode.git, vscode.npm, gitlens, etc.) declare their
203/// settings via the `properties` map shape:
204///
205/// ```jsonc
206/// "contributes": {
207///   "configuration": {
208///     "title": "Git",
209///     "properties": {
210///       "git.enabled":                 { "type": "boolean", "default": true,  "description": "…" },
211///       "git.path":                    { "type": ["string","array"], "default": null, "description": "…" },
212///       "git.autoRepositoryDetection": { "type": ["boolean","string"], "default": true, "description": "…" }
213///     }
214///   }
215/// }
216/// ```
217///
218/// The previous implementation searched for a `[ {key, value} ]` array
219/// shape that doesn't exist in any real VS Code manifest, so EVERY
220/// `vscode.workspace.getConfiguration(...).get('foo')` lookup fell
221/// through to undefined. Extensions that use the lookup's first arg
222/// alone (no explicit default) saw undefined and silently bailed -
223/// which is the failure mode behind vscode.git activating but never
224/// reaching `vscode.scm.createSourceControl(...)`.
225///
226/// `contributes.configuration` accepts BOTH a single object AND an
227/// array of objects (older multi-section schema), so we walk both
228/// shapes and recursively dive into `properties`. The dotted key
229/// (`git.enabled`) is split into a nested map shape so callers using
230/// `inspect_configuration_value`'s `path.split('.').try_fold(...)`
231/// land on the right node.
232pub(super) fn collect_default_configurations(
233	application_state:&crate::ApplicationState::State::ApplicationState::ApplicationState,
234) -> Result<Value, CommonError> {
235	let mut default_config = Map::new();
236
237	for extension in application_state
238		.Extension
239		.ScannedExtensions
240		.ScannedExtensions
241		.lock()
242		.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
243		.values()
244	{
245		let Some(contributes) = &extension.Contributes else {
246			continue;
247		};
248
249		let Some(configuration) = contributes.get("configuration") else {
250			continue;
251		};
252
253		// Walk EITHER an array of {properties} blocks OR a single one.
254		let blocks:Vec<&Value> = if let Some(array) = configuration.as_array() {
255			array.iter().collect()
256		} else {
257			vec![configuration]
258		};
259
260		for block in blocks {
261			let Some(properties) = block.get("properties").and_then(|p| p.as_object()) else {
262				continue;
263			};
264
265			for (DottedKey, schema) in properties {
266				let Some(default) = schema.get("default") else {
267					continue;
268				};
269
270				InsertDottedDefault(&mut default_config, DottedKey, default.clone());
271			}
272		}
273	}
274
275	Ok(Value::Object(default_config))
276}
277
278/// Insert a value into `target` at the dotted path `git.enabled`,
279/// creating intermediate object nodes as needed. Mirrors
280/// `inspect_configuration_value`'s `try_fold` traversal so a lookup
281/// for `git.enabled` finds `target["git"]["enabled"]`.
282fn InsertDottedDefault(target:&mut Map<String, Value>, dotted:&str, value:Value) {
283	let parts:Vec<&str> = dotted.split('.').collect();
284
285	if parts.is_empty() {
286		return;
287	}
288
289	if parts.len() == 1 {
290		target.insert(parts[0].to_string(), value);
291
292		return;
293	}
294
295	let head = parts[0];
296
297	let entry = target.entry(head.to_string()).or_insert_with(|| Value::Object(Map::new()));
298
299	if !entry.is_object() {
300		// Conflicting prior insert (e.g. another extension declared
301		// `git` as a non-object). Replace with a fresh map so we don't
302		// silently drop the deeper key. Last-writer-wins matches the
303		// merge precedence in `initialize_and_merge_configurations`.
304		*entry = Value::Object(Map::new());
305	}
306
307	if let Some(child) = entry.as_object_mut() {
308		// Walk the rest of the dotted path recursively. Re-build a
309		// `Map<String, Value>` and insert from there, then move it
310		// back. (Borrow-checker-friendly variant of in-place
311		// recursion.)
312		let mut sub = std::mem::take(child);
313
314		let RemainingDotted = parts[1..].join(".");
315
316		InsertDottedDefault(&mut sub, &RemainingDotted, value);
317
318		*child = sub;
319	}
320}