Skip to main content

Mountain/Environment/ConfigurationProvider/
Loading.rs

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