Skip to main content

Mountain/ExtensionManagement/
Scanner.rs

1//! # Extension Scanner (ExtensionManagement)
2//!
3//! Contains the logic for scanning directories on the filesystem to discover
4//! installed extensions by reading their `package.json` manifests, and for
5//! collecting default configuration values from all discovered extensions.
6//!
7//! ## RESPONSIBILITIES
8//!
9//! ### 1. Extension Discovery
10//! - Scan registered extension paths for valid extensions
11//! - Read and parse `package.json` manifest files
12//! - Validate extension metadata and structure
13//! - Build `ExtensionDescriptionStateDTO` for each discovered extension
14//!
15//! ### 2. Configuration Collection
16//! - Extract default configuration values from extension
17//!   `contributes.configuration`
18//! - Merge configuration properties from all extensions
19//! - Handle nested configuration objects recursively
20//! - Detect and prevent circular references
21//!
22//! ### 3. Error Handling
23//! - Gracefully handle unreadable directories
24//! - Skip extensions with invalid package.json
25//! - Log warnings for partial scan failures
26//! - Continue scanning even when some paths fail
27//!
28//! ## ARCHITECTURAL ROLE
29//!
30//! The Extension Scanner is part of the **Extension Management** subsystem:
31//!
32//! ```text
33//! Startup ──► ScanPaths ──► Scanner ──► Extensions Map ──► ApplicationState
34//! ```
35//!
36//! ### Position in Mountain
37//! - `ExtensionManagement` module: Extension discovery and metadata
38//! - Used during application startup to populate extension registry
39//! - Provides data to `Cocoon` for extension host initialization
40//!
41//! ### Dependencies
42//! - `CommonLibrary::FileSystem`: ReadDirectory and ReadFile effects
43//! - `CommonLibrary::Error::CommonError`: Error handling
44//! - `ApplicationRunTime`: Effect execution
45//! - `ApplicationState`: Extension storage
46//!
47//! ### Dependents
48//! - `InitializationData::ConstructExtensionHostInitializationData`: Sends
49//!   extensions to Cocoon
50//! - `MountainEnvironment::ScanForExtensions`: Public API for extension
51//!   scanning
52//! - `ApplicationState::Internal::ScanExtensionsWithRecovery`: Robust scanning
53//!   wrapper
54//!
55//! ## SCANNING PROCESS
56//!
57//! 1. **Path Resolution**: Get scan paths from
58//!    `ApplicationState.Extension.Registry.ExtensionScanPaths`
59//! 2. **Directory Enumeration**: For each path, read directory entries
60//! 3. **Manifest Detection**: Look for `package.json` in each subdirectory
61//! 4. **Parsing**: Deserialize `package.json` into
62//!    `ExtensionDescriptionStateDTO`
63//! 5. **Augmentation**: Add `ExtensionLocation` (disk path) to metadata
64//! 6. **Storage**: Insert into `ApplicationState.Extension.ScannedExtensions`
65//!    map
66//!
67//! ## CONFIGURATION MERGING
68//!
69//! `CollectDefaultConfigurations()` extracts default values from all
70//! extensions' `contributes.configuration.properties` and merges them into a
71//! single JSON object:
72//!
73//! - Handles nested `.` notation (e.g., `editor.fontSize`)
74//! - Recursively processes nested `properties` objects
75//! - Detects circular references to prevent infinite loops
76//! - Returns a flat map of configuration keys to default values
77//!
78//! ## ERROR HANDLING
79//!
80//! - **Directory Read Failures**: Logged as warnings, scanning continues
81//! - **Invalid package.json**: Skipped with warning, scanning continues
82//! - **IO Errors**: Logged, operation continues or fails gracefully
83//!
84//! ## PERFORMANCE
85//!
86//! - Scans are performed asynchronously via `ApplicationRunTime`
87//! - Each directory read is a separate filesystem operation
88//! - Large extension directories may impact startup time
89//! - Consider caching scan results for development workflows
90//!
91//! ## VS CODE REFERENCE
92//!
93//! Borrowed from VS Code's extension management:
94//! - `vs/workbench/services/extensions/common/extensionPoints.ts` -
95//!   Configuration contribution
96//! - `vs/platform/extensionManagement/common/extensionManagementService.ts` -
97//!   Extension scanning
98//!
99//! ## TODO
100//!
101//! - [ ] Implement concurrent scanning for multiple paths
102//! - [ ] Add extension scan caching with invalidation
103//! - [ ] Implement extension validation rules (required fields, etc.)
104//! - [ ] Add scan progress reporting for UI feedback
105//! - [ ] Support extension scanning in subdirectories (recursive)
106//!
107//! ## MODULE CONTENTS
108//!
109//! - [`ScanDirectoryForExtensions`]: Scan a single directory for extensions
110//! - [`CollectDefaultConfigurations`]: Merge configuration defaults from all
111//!   extensions
112//! - `process_configuration_properties`: Recursive configuration property
113//! processor
114
115use std::{path::PathBuf, sync::Arc};
116
117use CommonLibrary::{
118	Effect::ApplicationRunTime::ApplicationRunTime as _,
119	Error::CommonError::CommonError,
120	FileSystem::{DTO::FileTypeDTO::FileTypeDTO, ReadDirectory::ReadDirectory, ReadFile::ReadFile},
121};
122use serde_json::{Map, Value};
123use tauri::Manager;
124
125use crate::{
126	ApplicationState::{
127		DTO::ExtensionDescriptionStateDTO::ExtensionDescriptionStateDTO,
128		State::ApplicationState::ApplicationState,
129	},
130	Environment::Utility,
131	RunTime::ApplicationRunTime::ApplicationRunTime,
132	dev_log,
133};
134
135/// Directory names that are never extensions themselves even though they
136/// sit at the top level of `extensions/`. VS Code's shipped tree keeps
137/// TypeScript type declarations in `types/`, build output in `out/`, and a
138/// flat `node_modules/` for shared dependencies. Scanning into those emits
139/// noise like `[ExtensionScanner] Could not read package.json at
140/// .../out/package.json` on every boot; callers use `ExtensionScanDenyList` to
141/// skip them without losing the ability to scan *nested* `node_modules` inside
142/// a real extension (e.g. a language server's bundled deps).
143const EXTENSION_SCAN_DENY_LIST:&[&str] = &["types", "out", "node_modules", "test", ".vscode-test", ".git"];
144
145/// Test-only extensions that only serve the upstream VS Code test harness.
146/// Excluded unless `Test=1` is set, because they
147/// pollute the registry with events nobody listens for and drag down boot
148/// time on every user session.
149const TEST_ONLY_EXTENSIONS:&[&str] = &[
150	"vscode-api-tests",
151	"vscode-test-resolver",
152	"vscode-colorize-tests",
153	"vscode-colorize-perf-tests",
154	"vscode-notebook-tests",
155];
156
157fn IncludeTestExtensions() -> bool { matches!(std::env::var("Test").as_deref(), Ok("1") | Ok("true")) }
158
159fn IsDeniedDirectory(Name:&str) -> bool { EXTENSION_SCAN_DENY_LIST.iter().any(|Denied| *Denied == Name) }
160
161fn IsTestOnlyExtension(Name:&str) -> bool { TEST_ONLY_EXTENSIONS.iter().any(|TestOnly| *TestOnly == Name) }
162
163/// Return `true` if the given scan path represents a user-writable extension
164/// directory (i.e. where `extensions:install` drops VSIX payloads), not a
165/// bundled "built-in" path that ships with the app.
166///
167/// VS Code's sidebar categorises installed extensions by `IsBuiltin`:
168/// `true` appears under **Built-in**, `false` under **Installed**
169/// (accessible via `@installed`). Previously this classifier was
170/// hardcoded to `true` for every scan path, so user-installed VSIXes
171/// showed up under Built-in and `@installed` was empty.
172///
173/// The canonical user extension root on macOS/Linux is `~/.fiddee/extensions`
174/// (VS Code's equivalent is `~/.vscode/extensions`). We also honour a
175/// `Lodge` override in case callers remap it.
176///
177/// Everything else - the Mountain build's own `Resources/extensions`,
178/// Sky's `Static/Application/extensions`, the VS Code submodule's
179/// `Dependency/…/extensions` - is treated as built-in.
180fn IsUserExtensionScanPath(DirectoryPath:&std::path::Path) -> bool {
181	let Normalised = match DirectoryPath.canonicalize() {
182		Ok(Canonical) => Canonical,
183
184		Err(_) => DirectoryPath.to_path_buf(),
185	};
186
187	// `${Lodge}` explicit override takes priority.
188	if let Ok(Override) = std::env::var("Lodge") {
189		if !Override.is_empty() && Normalised == std::path::PathBuf::from(&Override) {
190			return true;
191		}
192	}
193
194	// `${HOME}/.fiddee/extensions` is the default user-scope root - used by
195	// `VsixInstaller::InstallVsix` for local VSIX drops and by the scan
196	// path list in `ScanPathConfigure`. Resolved through the
197	// `Utilities::FiddeeRoot` atom so the dotfile name lives in one place.
198	let UserRoot = crate::IPC::WindServiceHandlers::Utilities::FiddeeRoot::Fn().join("extensions");
199
200	if Normalised == UserRoot {
201		return true;
202	}
203
204	false
205}
206
207/// Scans a single directory for valid extensions.
208///
209/// This function iterates through a given directory, looking for subdirectories
210/// that contain a `package.json` file. It then attempts to parse this file
211/// into an `ExtensionDescriptionStateDTO`.
212pub async fn ScanDirectoryForExtensions(
213	ApplicationHandle:tauri::AppHandle,
214
215	DirectoryPath:PathBuf,
216) -> Result<Vec<ExtensionDescriptionStateDTO>, CommonError> {
217	// Decide up-front whether this scan path contributes built-ins or user
218	// extensions. Built-ins are ones shipped inside the Mountain/Sky/VS Code
219	// bundle; the `~/.fiddee/extensions` root is user-space.
220	let IsUserPath = IsUserExtensionScanPath(&DirectoryPath);
221
222	let RunTime = ApplicationHandle.state::<Arc<ApplicationRunTime>>().inner().clone();
223
224	let mut FoundExtensions = Vec::new();
225
226	// Distinguish "directory does not exist" (first-run, no user extensions
227	// installed yet - perfectly normal) from a real I/O failure. Only the
228	// latter deserves a `warn:` prefix; the former is debug-level noise.
229	match DirectoryPath.try_exists() {
230		Ok(false) => {
231			dev_log!(
232				"extensions",
233				"[ExtensionScanner] Extension path '{}' does not exist, skipping (no extensions installed here)",
234				DirectoryPath.display()
235			);
236
237			return Ok(Vec::new());
238		},
239
240		Err(error) => {
241			dev_log!(
242				"extensions",
243				"[ExtensionScanner] Could not stat extension path '{}': {} - skipping",
244				DirectoryPath.display(),
245				error
246			);
247
248			return Ok(Vec::new());
249		},
250
251		Ok(true) => {},
252	}
253
254	let TopLevelEntries = match RunTime.Run(ReadDirectory(DirectoryPath.clone())).await {
255		Ok(entries) => entries,
256
257		Err(error) => {
258			dev_log!(
259				"extensions",
260				"warn: [ExtensionScanner] Could not read extension directory '{}': {}. Skipping.",
261				DirectoryPath.display(),
262				error
263			);
264
265			return Ok(Vec::new());
266		},
267	};
268
269	dev_log!(
270		"extensions",
271		"[ExtensionScanner] Directory '{}' contains {} top-level entries",
272		DirectoryPath.display(),
273		TopLevelEntries.len()
274	);
275
276	let mut parse_failures = 0usize;
277
278	let mut missing_package_json = 0usize;
279
280	let mut denied_directory_count = 0usize;
281
282	let mut test_extension_skips = 0usize;
283
284	let AllowTestExtensions = IncludeTestExtensions();
285
286	for (EntryName, FileType) in TopLevelEntries {
287		if FileType == FileTypeDTO::Directory {
288			// BATCH-18: skip scanner traversal into directories that are
289			// build output / shared deps, not extensions.
290			if IsDeniedDirectory(&EntryName) {
291				denied_directory_count += 1;
292
293				continue;
294			}
295
296			if !AllowTestExtensions && IsTestOnlyExtension(&EntryName) {
297				test_extension_skips += 1;
298
299				continue;
300			}
301
302			let PotentialExtensionPath = DirectoryPath.join(EntryName);
303
304			let PackageJsonPath = PotentialExtensionPath.join("package.json");
305
306			// Per-candidate-directory probe, fires for every top-level
307			// entry the scanner inspects (203 lines per session). The
308			// accepted / rejected disposition is already covered by the
309			// `ext-scan` tag below.
310			dev_log!(
311				"ext-scan-verbose",
312				"[ExtensionScanner] Checking for package.json in: {}",
313				PotentialExtensionPath.display()
314			);
315
316			match RunTime.Run(ReadFile(PackageJsonPath.clone())).await {
317				Ok(PackageJsonContent) => {
318					// Parse to a dynamic JSON value first so we can resolve
319					// VS Code NLS placeholders (`%key%` strings referencing
320					// `package.nls.json` entries) across every typed field.
321					// Without this the UI renders literal `%command.clone%`,
322					// `%displayName%`, etc. in the Command Palette and menus.
323					let mut ManifestValue:Value = match serde_json::from_slice::<Value>(&PackageJsonContent) {
324						Ok(v) => v,
325
326						Err(error) => {
327							parse_failures += 1;
328
329							dev_log!(
330								"extensions",
331								"warn: [ExtensionScanner] Failed to parse package.json at '{}': {}",
332								PotentialExtensionPath.display(),
333								error
334							);
335
336							continue;
337						},
338					};
339
340					// BATCH-18: only report "no bundle" when the manifest
341					// actually contains `%placeholder%` strings that need
342					// substitution. Many shipped extensions (js-debug-companion,
343					// js-profile-table) publish English-only manifests with no
344					// placeholders - surfacing a warning there is misleading
345					// because the UI renders correctly with the raw fields.
346					let ManifestUsesPlaceholders = ManifestContainsNLSPlaceholders(&ManifestValue);
347
348					if let Some(NLSMap) =
349						LoadNLSBundle(&RunTime, &PotentialExtensionPath, ManifestUsesPlaceholders).await
350					{
351						let mut Replaced = 0u32;
352
353						let mut Unresolved = 0u32;
354
355						ResolveNLSPlaceholdersInner(&mut ManifestValue, &NLSMap, &mut Replaced, &mut Unresolved);
356
357						dev_log!(
358							"nls",
359							"[LandFix:NLS] {} → {} replaced, {} unresolved placeholders",
360							PotentialExtensionPath.display(),
361							Replaced,
362							Unresolved
363						);
364					}
365
366					match serde_json::from_value::<ExtensionDescriptionStateDTO>(ManifestValue) {
367						Ok(mut Description) => {
368							// Augment the description with its location on disk.
369							Description.ExtensionLocation =
370								serde_json::to_value(url::Url::from_directory_path(&PotentialExtensionPath).unwrap())
371									.unwrap_or(Value::Null);
372
373							// Construct identifier from publisher.name if not set
374							if Description.Identifier == Value::Null
375								|| Description.Identifier == Value::Object(Default::default())
376							{
377								let Id = if Description.Publisher.is_empty() {
378									Description.Name.clone()
379								} else {
380									format!("{}.{}", Description.Publisher, Description.Name)
381								};
382
383								Description.Identifier = serde_json::json!({ "value": Id });
384							}
385
386							// Classify the extension by the scan path it came from.
387							// Built-in extensions ship in the Mountain/Sky/VS Code
388							// bundle; user extensions live under
389							// `~/.fiddee/extensions` (written by
390							// `VsixInstaller::InstallVsix`). Hardcoding `true`
391							// here (the previous behaviour) made every VSIX
392							// install appear under **Built-in** in the
393							// Extensions sidebar and left `@installed` empty
394							// because the default query filters for User-scope
395							// extensions only.
396							Description.IsBuiltin = !IsUserPath;
397
398							// Boot-time exec-bit heal for user-scope extensions.
399							// Runs only against `~/.fiddee/extensions/<id>/` (built-in
400							// trees ship with correct modes from the bundle). Walks
401							// `bin/`, `server/`, `tools/`, etc., promotes 0o644 →
402							// 0o755 on files matching ELF / Mach-O / shebang magic.
403							// One-shot per boot - cheap (a couple stat + read(4) calls
404							// per file in those directories), and recovers extensions
405							// installed before the in-extractor exec-bit fix landed
406							// without forcing the user to reinstall.
407							#[cfg(unix)]
408							if IsUserPath {
409								crate::ExtensionManagement::VsixInstaller::HealExecutableBits(&PotentialExtensionPath);
410							}
411
412							dev_log!(
413								"ext-scan",
414								"[ExtScan] accept path={} is_user={} is_builtin={} id={}",
415								PotentialExtensionPath.display(),
416								IsUserPath,
417								Description.IsBuiltin,
418								Description
419									.Identifier
420									.get("value")
421									.and_then(|V| V.as_str())
422									.unwrap_or("<unknown>")
423							);
424
425							FoundExtensions.push(Description);
426						},
427
428						Err(error) => {
429							parse_failures += 1;
430
431							dev_log!(
432								"extensions",
433								"warn: [ExtensionScanner] Failed to parse package.json for extension at '{}': {}",
434								PotentialExtensionPath.display(),
435								error
436							);
437
438							dev_log!(
439								"ext-scan",
440								"[ExtScan] skip path={} reason=parse-failure err={}",
441								PotentialExtensionPath.display(),
442								error
443							);
444						},
445					}
446				},
447
448				Err(error) => {
449					missing_package_json += 1;
450
451					dev_log!(
452						"extensions",
453						"warn: [ExtensionScanner] Could not read package.json at '{}': {}",
454						PackageJsonPath.display(),
455						error
456					);
457
458					dev_log!(
459						"ext-scan",
460						"[ExtScan] skip path={} reason=no-package-json err={}",
461						PotentialExtensionPath.display(),
462						error
463					);
464				},
465			}
466		}
467	}
468
469	dev_log!(
470		"extensions",
471		"[ExtensionScanner] Directory '{}' scan done: {} parsed, {} parse-failures, {} missing package.json, {} \
472		 denied-dirs, {} test-extensions-skipped (Test={})",
473		DirectoryPath.display(),
474		FoundExtensions.len(),
475		parse_failures,
476		missing_package_json,
477		denied_directory_count,
478		test_extension_skips,
479		AllowTestExtensions,
480	);
481
482	Ok(FoundExtensions)
483}
484
485/// Walk a manifest value and return true as soon as any `%placeholder%` string
486/// is encountered. Used to decide whether a missing `package.nls.json` bundle
487/// is a real problem or a shipped-as-English extension.
488fn ManifestContainsNLSPlaceholders(Value:&Value) -> bool {
489	match Value {
490		serde_json::Value::String(Text) => {
491			Text.len() >= 2 && Text.starts_with('%') && Text.ends_with('%') && !Text[1..Text.len() - 1].contains('%')
492		},
493
494		serde_json::Value::Array(Items) => Items.iter().any(ManifestContainsNLSPlaceholders),
495
496		serde_json::Value::Object(Object) => Object.values().any(ManifestContainsNLSPlaceholders),
497
498		_ => false,
499	}
500}
501
502/// Load an extension's NLS bundle (`package.nls.json`) into a `{key → string}`
503/// map. Returns `None` if the bundle is absent or unreadable; placeholders stay
504/// as-is in that case. Entries can be bare strings or `{message, comment}`
505/// objects - we only keep `message`.
506///
507/// The `PlaceholdersNeeded` flag downgrades the "no bundle" warning when the
508/// caller already proved the manifest has no `%placeholder%` entries to
509/// resolve - in that case the bundle is optional and its absence is benign
510/// (BATCH-18).
511async fn LoadNLSBundle(
512	RunTime:&Arc<ApplicationRunTime>,
513
514	ExtensionPath:&PathBuf,
515
516	PlaceholdersNeeded:bool,
517) -> Option<Map<String, Value>> {
518	let NLSPath = ExtensionPath.join("package.nls.json");
519
520	let Content = match RunTime.Run(ReadFile(NLSPath.clone())).await {
521		Ok(Bytes) => Bytes,
522
523		Err(Error) => {
524			if PlaceholdersNeeded {
525				dev_log!("nls", "[LandFix:NLS] no bundle for {} ({})", ExtensionPath.display(), Error);
526			} else {
527				dev_log!(
528					"nls",
529					"[LandFix:NLS] {} has no placeholders, no bundle needed",
530					ExtensionPath.display()
531				);
532			}
533
534			return None;
535		},
536	};
537
538	let Parsed:Value = match serde_json::from_slice(&Content) {
539		Ok(V) => V,
540
541		Err(Error) => {
542			dev_log!("nls", "warn: [LandFix:NLS] failed to parse {}: {}", NLSPath.display(), Error);
543
544			return None;
545		},
546	};
547
548	let Object = Parsed.as_object()?;
549
550	let mut Resolved = Map::with_capacity(Object.len());
551
552	for (Key, RawValue) in Object {
553		let Text = if let Some(s) = RawValue.as_str() {
554			Some(s.to_string())
555		} else if let Some(obj) = RawValue.as_object() {
556			obj.get("message").and_then(|m| m.as_str()).map(|s| s.to_string())
557		} else {
558			None
559		};
560
561		if let Some(t) = Text {
562			Resolved.insert(Key.clone(), Value::String(t));
563		}
564	}
565
566	dev_log!(
567		"nls",
568		"[LandFix:NLS] loaded {} keys for {}",
569		Resolved.len(),
570		ExtensionPath.display()
571	);
572
573	Some(Resolved)
574}
575
576/// Internal NLS walker that also counts substitutions made vs. unresolved
577/// placeholders it saw, so the outer scanner can log a one-line summary per
578/// extension.
579fn ResolveNLSPlaceholdersInner(Value:&mut Value, NLS:&Map<String, Value>, Replaced:&mut u32, Unresolved:&mut u32) {
580	match Value {
581		serde_json::Value::String(Text) => {
582			if Text.len() >= 2 && Text.starts_with('%') && Text.ends_with('%') {
583				let Key = &Text[1..Text.len() - 1];
584
585				if !Key.is_empty() && !Key.contains('%') {
586					if let Some(Replacement) = NLS.get(Key).and_then(|v| v.as_str()) {
587						*Text = Replacement.to_string();
588						*Replaced += 1;
589					} else {
590						*Unresolved += 1;
591					}
592				}
593			}
594		},
595
596		serde_json::Value::Array(Items) => {
597			for Item in Items {
598				ResolveNLSPlaceholdersInner(Item, NLS, Replaced, Unresolved);
599			}
600		},
601
602		serde_json::Value::Object(Map) => {
603			for (_, FieldValue) in Map {
604				ResolveNLSPlaceholdersInner(FieldValue, NLS, Replaced, Unresolved);
605			}
606		},
607
608		_ => {},
609	}
610}
611
612/// A helper function to extract default configuration values from all
613/// scanned extensions.
614pub fn CollectDefaultConfigurations(State:&ApplicationState) -> Result<Value, CommonError> {
615	let mut MergedDefaults = Map::new();
616
617	let Extensions = State
618		.Extension
619		.ScannedExtensions
620		.ScannedExtensions
621		.lock()
622		.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
623
624	for Extension in Extensions.values() {
625		if let Some(contributes) = Extension.Contributes.as_ref().and_then(|v| v.as_object()) {
626			if let Some(configuration) = contributes.get("configuration").and_then(|v| v.as_object()) {
627				if let Some(properties) = configuration.get("properties").and_then(|v| v.as_object()) {
628					// NESTED OBJECT HANDLING: Recursively process configuration properties
629					self::process_configuration_properties(&mut MergedDefaults, "", properties, &mut Vec::new())?;
630				}
631			}
632		}
633	}
634
635	Ok(Value::Object(MergedDefaults))
636}
637
638/// RECURSIVE CONFIGURATION PROCESSING: Handle nested object structures
639fn process_configuration_properties(
640	merged_defaults:&mut serde_json::Map<String, Value>,
641
642	current_path:&str,
643
644	properties:&serde_json::Map<String, Value>,
645
646	visited_keys:&mut Vec<String>,
647) -> Result<(), CommonError> {
648	for (key, value) in properties {
649		// Build the full path for this property
650		let full_path = if current_path.is_empty() {
651			key.clone()
652		} else {
653			format!("{}.{}", current_path, key)
654		};
655
656		// Check for circular references
657		if visited_keys.contains(&full_path) {
658			return Err(CommonError::Unknown {
659				Description:format!("Circular reference detected in configuration properties: {}", full_path),
660			});
661		}
662
663		visited_keys.push(full_path.clone());
664
665		if let Some(prop_details) = value.as_object() {
666			// Check if this is a nested object structure
667			if let Some(nested_properties) = prop_details.get("properties").and_then(|v| v.as_object()) {
668				// Recursively process nested properties
669				self::process_configuration_properties(merged_defaults, &full_path, nested_properties, visited_keys)?;
670			} else if let Some(default_value) = prop_details.get("default") {
671				// Handle regular property with default value
672				merged_defaults.insert(full_path.clone(), default_value.clone());
673			}
674		}
675
676		// Remove current key from visited keys
677		visited_keys.retain(|k| k != &full_path);
678	}
679
680	Ok(())
681}