components-rs 0.1.1

Static analysis tooling for Components.js dependency injection projects
Documentation
use std::collections::HashMap;
use std::path::Path;

use crate::components::types::*;
use crate::context::expand::ContextResolver;
use crate::error::{ComponentsJsError, Result};
use crate::fs::{self as cfs, Fs};
use crate::module_state::ModuleState;

/// Registry of discovered configuration instances.
#[derive(Debug, Clone)]
pub struct ConfigRegistry {
    pub configs: Vec<ConfigInstance>,
}

impl ConfigRegistry {
    pub fn new() -> Self {
        Self {
            configs: Vec::new(),
        }
    }

    /// Discover and load all config files from the import paths.
    /// Config files live in directories mapped by lsd:importPaths with "config/" in the key.
    pub async fn discover_configs(
        &mut self,
        fs: &dyn Fs,
        state: &ModuleState,
    ) -> Result<()> {
        for (iri_prefix, local_dir) in &state.import_paths {
            if iri_prefix.contains("/config/") && fs.is_dir(local_dir).await {
                self.load_config_directory(fs, local_dir, state).await?;
            }
        }
        Ok(())
    }

    /// Load a single config file.
    pub fn load_config_file<'a>(
        &'a mut self,
        fs: &'a dyn Fs,
        path: &'a Path,
        state: &'a ModuleState,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + 'a>> {
        Box::pin(async move {
            tracing::debug!("Loading config file: {}", path.display());

            let contents = fs.read_to_string(path).await?;
            let doc: serde_json::Value =
                serde_json::from_str(&contents).map_err(|e| ComponentsJsError::JsonParse {
                    path: path.display().to_string(),
                    source: e,
                })?;

            let resolver = if let Some(ctx) = doc.get("@context") {
                ContextResolver::from_context_value(ctx, &state.contexts)?
            } else {
                ContextResolver::new()
            };

            // Process imports
            self.process_imports(fs, &doc, &resolver, state, path).await?;

            // Get graph entries
            let entries: Vec<&serde_json::Value> = if let Some(graph) = doc.get("@graph") {
                if let Some(arr) = graph.as_array() {
                    arr.iter().collect()
                } else {
                    vec![graph]
                }
            } else if doc.get("@id").is_some() {
                vec![&doc]
            } else {
                vec![]
            };

            for entry in entries {
                if let Some(config) = self.parse_config_entry(entry, &resolver, path) {
                    self.configs.push(config);
                }
            }

            Ok(())
        })
    }

    async fn process_imports(
        &mut self,
        fs: &dyn Fs,
        doc: &serde_json::Value,
        resolver: &ContextResolver,
        state: &ModuleState,
        source_path: &Path,
    ) -> Result<()> {
        if let Some(import_val) = doc.get("import") {
            let iris = extract_import_iris(import_val, resolver);
            for iri in iris {
                if let Some(local_path) =
                    crate::components::registry::resolve_iri_to_path(&iri, &state.import_paths)
                {
                    if cfs::exists(fs, &local_path).await && local_path != source_path {
                        self.load_config_file(fs, &local_path, state).await?;
                    }
                }
            }
        }
        Ok(())
    }

    fn parse_config_entry(
        &self,
        value: &serde_json::Value,
        resolver: &ContextResolver,
        source_path: &Path,
    ) -> Option<ConfigInstance> {
        let obj = value.as_object()?;

        let iri = obj
            .get("@id")
            .and_then(|v| v.as_str())
            .map(|s| resolver.expand_term(s))?;

        let type_iri = match obj.get("@type") {
            Some(serde_json::Value::String(t)) => resolver.expand_term(t),
            Some(serde_json::Value::Array(arr)) => {
                arr.iter()
                    .filter_map(|v| v.as_str())
                    .map(|s| resolver.expand_term(s))
                    .next()?
            }
            _ => return None,
        };

        if type_iri.contains("Override") {
            return None;
        }

        let mut parameters = HashMap::new();
        for (key, val) in obj {
            if key.starts_with('@') {
                continue;
            }
            let expanded_key = resolver.expand_term(key);
            parameters.insert(expanded_key, val.clone());
        }

        Some(ConfigInstance {
            iri,
            component_type_iri: type_iri,
            parameters,
            source_file: source_path.display().to_string(),
        })
    }

    /// Recursively load all config files (.jsonld and .json) from a directory.
    async fn load_config_directory(
        &mut self,
        fs: &dyn Fs,
        dir: &Path,
        state: &ModuleState,
    ) -> Result<()> {
        if !fs.is_dir(dir).await {
            return Ok(());
        }

        let files = cfs::walk_dir(fs, dir).await?;
        for path in files {
            let is_config = path
                .extension()
                .is_some_and(|ext| ext == "jsonld" || ext == "json");
            if is_config {
                self.load_config_file(fs, &path, state).await?;
            }
        }

        Ok(())
    }
}

fn extract_import_iris(value: &serde_json::Value, resolver: &ContextResolver) -> Vec<String> {
    match value {
        serde_json::Value::String(s) => vec![resolver.expand_term(s)],
        serde_json::Value::Array(arr) => arr
            .iter()
            .filter_map(|v| v.as_str())
            .map(|s| resolver.expand_term(s))
            .collect(),
        _ => vec![],
    }
}