DevelopmentNodeEnvironment_MicrosoftVSCodeDependency_22NodeVersion_Bundle_Clean_Debug_ElectronProfile_EsbuildCompiler_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.
180pub(crate) fn 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 // Legacy `${HOME}/.land/extensions`. Pre-FIDDEE installs landed here
205 // (Roo Code, GitLens, rust-analyzer, etc.). ScanPathConfigure.rs T3
206 // added this directory to the scan-path registry, but without the
207 // matching classifier entry every extension found there got tagged
208 // `IsBuiltin=true` and hid under "Built-in" in the sidebar - and the
209 // `extensions:scanUserExtensions` IPC returned 0 entries. Treat it as
210 // a user-scope root so the classifier matches the scan-path config.
211 if let Some(Home) = dirs::home_dir() {
212 let LandLegacy = Home.join(".land").join("extensions");
213
214 if Normalised == LandLegacy {
215 return true;
216 }
217 }
218
219 false
220}
221
222/// Scans a single directory for valid extensions.
223///
224/// This function iterates through a given directory, looking for subdirectories
225/// that contain a `package.json` file. It then attempts to parse this file
226/// into an `ExtensionDescriptionStateDTO`.
227pub async fn ScanDirectoryForExtensions(
228 ApplicationHandle:tauri::AppHandle,
229
230 DirectoryPath:PathBuf,
231) -> Result<Vec<ExtensionDescriptionStateDTO>, CommonError> {
232 // Decide up-front whether this scan path contributes built-ins or user
233 // extensions. Built-ins are ones shipped inside the Mountain/Sky/VS Code
234 // bundle; the `~/.fiddee/extensions` root is user-space.
235 let IsUserPath = IsUserExtensionScanPath(&DirectoryPath);
236
237 let RunTime = ApplicationHandle.state::<Arc<ApplicationRunTime>>().inner().clone();
238
239 let mut FoundExtensions = Vec::new();
240
241 // Distinguish "directory does not exist" (first-run, no user extensions
242 // installed yet - perfectly normal) from a real I/O failure. Only the
243 // latter deserves a `warn:` prefix; the former is debug-level noise.
244 match DirectoryPath.try_exists() {
245 Ok(false) => {
246 dev_log!(
247 "extensions",
248 "[ExtensionScanner] Extension path '{}' does not exist, skipping (no extensions installed here)",
249 DirectoryPath.display()
250 );
251
252 return Ok(Vec::new());
253 },
254
255 Err(error) => {
256 dev_log!(
257 "extensions",
258 "[ExtensionScanner] Could not stat extension path '{}': {} - skipping",
259 DirectoryPath.display(),
260 error
261 );
262
263 return Ok(Vec::new());
264 },
265
266 Ok(true) => {},
267 }
268
269 let TopLevelEntries = match RunTime.Run(ReadDirectory(DirectoryPath.clone())).await {
270 Ok(entries) => entries,
271
272 Err(error) => {
273 dev_log!(
274 "extensions",
275 "warn: [ExtensionScanner] Could not read extension directory '{}': {}. Skipping.",
276 DirectoryPath.display(),
277 error
278 );
279
280 return Ok(Vec::new());
281 },
282 };
283
284 dev_log!(
285 "extensions",
286 "[ExtensionScanner] Directory '{}' contains {} top-level entries",
287 DirectoryPath.display(),
288 TopLevelEntries.len()
289 );
290
291 let mut parse_failures = 0usize;
292
293 let mut missing_package_json = 0usize;
294
295 let mut denied_directory_count = 0usize;
296
297 let mut test_extension_skips = 0usize;
298
299 let AllowTestExtensions = IncludeTestExtensions();
300
301 for (EntryName, FileType) in TopLevelEntries {
302 if FileType == FileTypeDTO::Directory {
303 // BATCH-18: skip scanner traversal into directories that are
304 // build output / shared deps, not extensions.
305 if IsDeniedDirectory(&EntryName) {
306 denied_directory_count += 1;
307
308 continue;
309 }
310
311 if !AllowTestExtensions && IsTestOnlyExtension(&EntryName) {
312 test_extension_skips += 1;
313
314 continue;
315 }
316
317 let PotentialExtensionPath = DirectoryPath.join(EntryName);
318
319 let PackageJsonPath = PotentialExtensionPath.join("package.json");
320
321 // Per-candidate-directory probe, fires for every top-level
322 // entry the scanner inspects (203 lines per session). The
323 // accepted / rejected disposition is already covered by the
324 // `ext-scan` tag below.
325 dev_log!(
326 "ext-scan-verbose",
327 "[ExtensionScanner] Checking for package.json in: {}",
328 PotentialExtensionPath.display()
329 );
330
331 match RunTime.Run(ReadFile(PackageJsonPath.clone())).await {
332 Ok(PackageJsonContent) => {
333 // Parse to a dynamic JSON value first so we can resolve
334 // VS Code NLS placeholders (`%key%` strings referencing
335 // `package.nls.json` entries) across every typed field.
336 // Without this the UI renders literal `%command.clone%`,
337 // `%displayName%`, etc. in the Command Palette and menus.
338 let mut ManifestValue:Value = match serde_json::from_slice::<Value>(&PackageJsonContent) {
339 Ok(v) => v,
340
341 Err(error) => {
342 parse_failures += 1;
343
344 dev_log!(
345 "extensions",
346 "warn: [ExtensionScanner] Failed to parse package.json at '{}': {}",
347 PotentialExtensionPath.display(),
348 error
349 );
350
351 continue;
352 },
353 };
354
355 // BATCH-18: only report "no bundle" when the manifest
356 // actually contains `%placeholder%` strings that need
357 // substitution. Many shipped extensions (js-debug-companion,
358 // js-profile-table) publish English-only manifests with no
359 // placeholders - surfacing a warning there is misleading
360 // because the UI renders correctly with the raw fields.
361 let ManifestUsesPlaceholders = ManifestContainsNLSPlaceholders(&ManifestValue);
362
363 if let Some(NLSMap) =
364 LoadNLSBundle(&RunTime, &PotentialExtensionPath, ManifestUsesPlaceholders).await
365 {
366 let mut Replaced = 0u32;
367
368 let mut Unresolved = 0u32;
369
370 ResolveNLSPlaceholdersInner(&mut ManifestValue, &NLSMap, &mut Replaced, &mut Unresolved);
371
372 dev_log!(
373 "nls",
374 "[LandFix:NLS] {} → {} replaced, {} unresolved placeholders",
375 PotentialExtensionPath.display(),
376 Replaced,
377 Unresolved
378 );
379 }
380
381 match serde_json::from_value::<ExtensionDescriptionStateDTO>(ManifestValue) {
382 Ok(mut Description) => {
383 // Augment the description with its location on disk.
384 Description.ExtensionLocation =
385 serde_json::to_value(url::Url::from_directory_path(&PotentialExtensionPath).unwrap())
386 .unwrap_or(Value::Null);
387
388 // Construct identifier from publisher.name if not set
389 if Description.Identifier == Value::Null
390 || Description.Identifier == Value::Object(Default::default())
391 {
392 let Id = if Description.Publisher.is_empty() {
393 Description.Name.clone()
394 } else {
395 format!("{}.{}", Description.Publisher, Description.Name)
396 };
397
398 Description.Identifier = serde_json::json!({ "value": Id });
399 }
400
401 // Classify the extension by the scan path it came from.
402 // Built-in extensions ship in the Mountain/Sky/VS Code
403 // bundle; user extensions live under
404 // `~/.fiddee/extensions` (written by
405 // `VsixInstaller::InstallVsix`). Hardcoding `true`
406 // here (the previous behaviour) made every VSIX
407 // install appear under **Built-in** in the
408 // Extensions sidebar and left `@installed` empty
409 // because the default query filters for User-scope
410 // extensions only.
411 Description.IsBuiltin = !IsUserPath;
412
413 // Boot-time exec-bit heal for user-scope extensions.
414 // Runs only against `~/.fiddee/extensions/<id>/` (built-in
415 // trees ship with correct modes from the bundle). Walks
416 // `bin/`, `server/`, `tools/`, etc., promotes 0o644 →
417 // 0o755 on files matching ELF / Mach-O / shebang magic.
418 // One-shot per boot - cheap (a couple stat + read(4) calls
419 // per file in those directories), and recovers extensions
420 // installed before the in-extractor exec-bit fix landed
421 // without forcing the user to reinstall.
422 #[cfg(unix)]
423 if IsUserPath {
424 crate::ExtensionManagement::VsixInstaller::HealExecutableBits(&PotentialExtensionPath);
425 }
426
427 dev_log!(
428 "ext-scan",
429 "[ExtScan] accept path={} is_user={} is_builtin={} id={}",
430 PotentialExtensionPath.display(),
431 IsUserPath,
432 Description.IsBuiltin,
433 Description
434 .Identifier
435 .get("value")
436 .and_then(|V| V.as_str())
437 .unwrap_or("<unknown>")
438 );
439
440 FoundExtensions.push(Description);
441 },
442
443 Err(error) => {
444 parse_failures += 1;
445
446 dev_log!(
447 "extensions",
448 "warn: [ExtensionScanner] Failed to parse package.json for extension at '{}': {}",
449 PotentialExtensionPath.display(),
450 error
451 );
452
453 dev_log!(
454 "ext-scan",
455 "[ExtScan] skip path={} reason=parse-failure err={}",
456 PotentialExtensionPath.display(),
457 error
458 );
459 },
460 }
461 },
462
463 Err(error) => {
464 missing_package_json += 1;
465
466 dev_log!(
467 "extensions",
468 "warn: [ExtensionScanner] Could not read package.json at '{}': {}",
469 PackageJsonPath.display(),
470 error
471 );
472
473 dev_log!(
474 "ext-scan",
475 "[ExtScan] skip path={} reason=no-package-json err={}",
476 PotentialExtensionPath.display(),
477 error
478 );
479 },
480 }
481 }
482 }
483
484 dev_log!(
485 "extensions",
486 "[ExtensionScanner] Directory '{}' scan done: {} parsed, {} parse-failures, {} missing package.json, {} \
487 denied-dirs, {} test-extensions-skipped (Test={})",
488 DirectoryPath.display(),
489 FoundExtensions.len(),
490 parse_failures,
491 missing_package_json,
492 denied_directory_count,
493 test_extension_skips,
494 AllowTestExtensions,
495 );
496
497 Ok(FoundExtensions)
498}
499
500/// Walk a manifest value and return true as soon as any `%placeholder%` string
501/// is encountered. Used to decide whether a missing `package.nls.json` bundle
502/// is a real problem or a shipped-as-English extension.
503fn ManifestContainsNLSPlaceholders(Value:&Value) -> bool {
504 match Value {
505 serde_json::Value::String(Text) => {
506 Text.len() >= 2 && Text.starts_with('%') && Text.ends_with('%') && !Text[1..Text.len() - 1].contains('%')
507 },
508
509 serde_json::Value::Array(Items) => Items.iter().any(ManifestContainsNLSPlaceholders),
510
511 serde_json::Value::Object(Object) => Object.values().any(ManifestContainsNLSPlaceholders),
512
513 _ => false,
514 }
515}
516
517/// Load an extension's NLS bundle (`package.nls.json`) into a `{key → string}`
518/// map. Returns `None` if the bundle is absent or unreadable; placeholders stay
519/// as-is in that case. Entries can be bare strings or `{message, comment}`
520/// objects - we only keep `message`.
521///
522/// The `PlaceholdersNeeded` flag downgrades the "no bundle" warning when the
523/// caller already proved the manifest has no `%placeholder%` entries to
524/// resolve - in that case the bundle is optional and its absence is benign
525/// (BATCH-18).
526async fn LoadNLSBundle(
527 RunTime:&Arc<ApplicationRunTime>,
528
529 ExtensionPath:&PathBuf,
530
531 PlaceholdersNeeded:bool,
532) -> Option<Map<String, Value>> {
533 let NLSPath = ExtensionPath.join("package.nls.json");
534
535 let Content = match RunTime.Run(ReadFile(NLSPath.clone())).await {
536 Ok(Bytes) => Bytes,
537
538 Err(Error) => {
539 if PlaceholdersNeeded {
540 dev_log!("nls", "[LandFix:NLS] no bundle for {} ({})", ExtensionPath.display(), Error);
541 } else {
542 dev_log!(
543 "nls",
544 "[LandFix:NLS] {} has no placeholders, no bundle needed",
545 ExtensionPath.display()
546 );
547 }
548
549 return None;
550 },
551 };
552
553 let Parsed:Value = match serde_json::from_slice(&Content) {
554 Ok(V) => V,
555
556 Err(Error) => {
557 dev_log!("nls", "warn: [LandFix:NLS] failed to parse {}: {}", NLSPath.display(), Error);
558
559 return None;
560 },
561 };
562
563 let Object = Parsed.as_object()?;
564
565 let mut Resolved = Map::with_capacity(Object.len());
566
567 for (Key, RawValue) in Object {
568 let Text = if let Some(s) = RawValue.as_str() {
569 Some(s.to_string())
570 } else if let Some(obj) = RawValue.as_object() {
571 obj.get("message").and_then(|m| m.as_str()).map(|s| s.to_string())
572 } else {
573 None
574 };
575
576 if let Some(t) = Text {
577 Resolved.insert(Key.clone(), Value::String(t));
578 }
579 }
580
581 dev_log!(
582 "nls",
583 "[LandFix:NLS] loaded {} keys for {}",
584 Resolved.len(),
585 ExtensionPath.display()
586 );
587
588 Some(Resolved)
589}
590
591/// Internal NLS walker that also counts substitutions made vs. unresolved
592/// placeholders it saw, so the outer scanner can log a one-line summary per
593/// extension.
594fn ResolveNLSPlaceholdersInner(Value:&mut Value, NLS:&Map<String, Value>, Replaced:&mut u32, Unresolved:&mut u32) {
595 match Value {
596 serde_json::Value::String(Text) => {
597 if Text.len() >= 2 && Text.starts_with('%') && Text.ends_with('%') {
598 let Key = &Text[1..Text.len() - 1];
599
600 if !Key.is_empty() && !Key.contains('%') {
601 if let Some(Replacement) = NLS.get(Key).and_then(|v| v.as_str()) {
602 *Text = Replacement.to_string();
603 *Replaced += 1;
604 } else {
605 *Unresolved += 1;
606 }
607 }
608 }
609 },
610
611 serde_json::Value::Array(Items) => {
612 for Item in Items {
613 ResolveNLSPlaceholdersInner(Item, NLS, Replaced, Unresolved);
614 }
615 },
616
617 serde_json::Value::Object(Map) => {
618 for (_, FieldValue) in Map {
619 ResolveNLSPlaceholdersInner(FieldValue, NLS, Replaced, Unresolved);
620 }
621 },
622
623 _ => {},
624 }
625}
626
627/// A helper function to extract default configuration values from all
628/// scanned extensions.
629pub fn CollectDefaultConfigurations(State:&ApplicationState) -> Result<Value, CommonError> {
630 let mut MergedDefaults = Map::new();
631
632 let Extensions = State
633 .Extension
634 .ScannedExtensions
635 .ScannedExtensions
636 .lock()
637 .map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
638
639 for Extension in Extensions.values() {
640 if let Some(contributes) = Extension.Contributes.as_ref().and_then(|v| v.as_object()) {
641 if let Some(configuration) = contributes.get("configuration").and_then(|v| v.as_object()) {
642 if let Some(properties) = configuration.get("properties").and_then(|v| v.as_object()) {
643 // NESTED OBJECT HANDLING: Recursively process configuration properties
644 self::process_configuration_properties(&mut MergedDefaults, "", properties, &mut Vec::new())?;
645 }
646 }
647 }
648 }
649
650 Ok(Value::Object(MergedDefaults))
651}
652
653/// RECURSIVE CONFIGURATION PROCESSING: Handle nested object structures
654fn process_configuration_properties(
655 merged_defaults:&mut serde_json::Map<String, Value>,
656
657 current_path:&str,
658
659 properties:&serde_json::Map<String, Value>,
660
661 visited_keys:&mut Vec<String>,
662) -> Result<(), CommonError> {
663 for (key, value) in properties {
664 // Build the full path for this property
665 let full_path = if current_path.is_empty() {
666 key.clone()
667 } else {
668 format!("{}.{}", current_path, key)
669 };
670
671 // Check for circular references
672 if visited_keys.contains(&full_path) {
673 return Err(CommonError::Unknown {
674 Description:format!("Circular reference detected in configuration properties: {}", full_path),
675 });
676 }
677
678 visited_keys.push(full_path.clone());
679
680 if let Some(prop_details) = value.as_object() {
681 // Check if this is a nested object structure
682 if let Some(nested_properties) = prop_details.get("properties").and_then(|v| v.as_object()) {
683 // Recursively process nested properties
684 self::process_configuration_properties(merged_defaults, &full_path, nested_properties, visited_keys)?;
685 } else if let Some(default_value) = prop_details.get("default") {
686 // Handle regular property with default value
687 merged_defaults.insert(full_path.clone(), default_value.clone());
688 }
689 }
690
691 // Remove current key from visited keys
692 visited_keys.retain(|k| k != &full_path);
693 }
694
695 Ok(())
696}