Skip to main content

components_rs/discovery/
package_json.rs

1//! `package.json` parsing and pre-processing for Components.js metadata.
2//!
3//! Reads the `lsd:*` fields that Components.js adds to `package.json`:
4//!
5//! | Field | Meaning |
6//! |-------|---------|
7//! | `lsd:module` | `true` (auto-expand) or an explicit module IRI string |
8//! | `lsd:components` | relative path to `components.jsonld` |
9//! | `lsd:contexts` | map of context IRI → relative path to the context file |
10//! | `lsd:importPaths` | map of IRI prefix → relative directory for import resolution |
11//! | `lsd:basePath` | optional path prefix applied to all relative paths above |
12//!
13//! [`preprocess_package_json`] handles the `lsd:module: true` shorthand: it expands the
14//! module IRI to `https://linkedsoftwaredependencies.org/bundles/npm/<name>` and
15//! auto-detects the standard `components/` and `config/` directories.
16
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20use url::Url;
21
22use crate::error::Result;
23use crate::fs::Fs;
24
25const LSD_BUNDLES_PREFIX: &str = "https://linkedsoftwaredependencies.org/bundles/npm/";
26
27/// Parsed package.json with CJS-specific fields.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PackageJson {
30    #[serde(default)]
31    pub name: String,
32    #[serde(default)]
33    pub version: String,
34
35    /// `lsd:module` — can be `true` (auto-expand) or a string IRI.
36    #[serde(rename = "lsd:module", default)]
37    pub lsd_module: Option<LsdModule>,
38
39    /// `lsd:components` — relative path to components.jsonld
40    #[serde(rename = "lsd:components", default)]
41    pub lsd_components: Option<String>,
42
43    /// `lsd:contexts` — map of context IRI → relative path
44    #[serde(rename = "lsd:contexts", default)]
45    pub lsd_contexts: Option<HashMap<String, String>>,
46
47    /// `lsd:importPaths` — map of IRI prefix → relative path
48    #[serde(rename = "lsd:importPaths", default)]
49    pub lsd_import_paths: Option<HashMap<String, String>>,
50
51    /// `lsd:basePath` — optional base path prefix
52    #[serde(rename = "lsd:basePath", default)]
53    pub lsd_base_path: Option<String>,
54}
55
56/// `lsd:module` can be either `true` or a string IRI.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(untagged)]
59pub enum LsdModule {
60    Bool(bool),
61    Iri(String),
62}
63
64impl LsdModule {
65    pub fn as_iri(&self) -> Option<&str> {
66        match self {
67            LsdModule::Iri(s) => Some(s.as_str()),
68            LsdModule::Bool(_) => None,
69        }
70    }
71
72    pub fn is_enabled(&self) -> bool {
73        match self {
74            LsdModule::Bool(b) => *b,
75            LsdModule::Iri(_) => true,
76        }
77    }
78}
79
80/// Join `segment` onto `base`, ensuring the result is treated as a directory URL.
81/// If `segment` is empty, returns `base` unchanged.
82fn join_dir(base: &Url, segment: &str) -> Option<Url> {
83    if segment.is_empty() {
84        return Some(base.clone());
85    }
86    let seg = if segment.ends_with('/') {
87        segment.to_string()
88    } else {
89        format!("{}/", segment)
90    };
91    base.join(&seg).ok()
92}
93
94/// Read package.json files from all given module directory URLs.
95pub async fn read_package_jsons(
96    fs: &dyn Fs,
97    module_paths: &[Url],
98) -> Result<HashMap<Url, PackageJson>> {
99    tracing::info!("[package_json] read_package_jsons called with {} paths", module_paths.len());
100    let mut result = HashMap::new();
101    for module_path in module_paths {
102        let pkg_url = match module_path.join("package.json") {
103            Ok(u) => u,
104            Err(e) => {
105                tracing::warn!("[package_json] failed to join package.json onto {}: {}", module_path.as_str(), e);
106                continue;
107            }
108        };
109        let contents = match fs.read_to_string(&pkg_url).await {
110            Ok(c) => c,
111            Err(e) => {
112                tracing::debug!("[package_json] read failed for {}: {}", pkg_url.as_str(), e);
113                continue;
114            }
115        };
116        let pkg: PackageJson = match serde_json::from_str(&contents) {
117            Ok(p) => p,
118            Err(e) => {
119                tracing::warn!("[package_json] skipping {}: {}", pkg_url.as_str(), e);
120                continue;
121            }
122        };
123        result.insert(module_path.clone(), pkg);
124    }
125    tracing::info!("[package_json] read_package_jsons parsed {} package.json files", result.len());
126    Ok(result)
127}
128
129/// Preprocess a package.json: expand `lsd:module: true` into full IRI,
130/// auto-detect components and config directories.
131/// Mirrors `ModuleStateBuilder.preprocessPackageJson`.
132pub async fn preprocess_package_json(fs: &dyn Fs, package_path: &Url, pkg: &mut PackageJson) {
133    let needs_expansion = matches!(pkg.lsd_module, Some(LsdModule::Bool(true)));
134    if !needs_expansion {
135        return;
136    }
137
138    // Expand lsd:module to full IRI
139    let module_iri = format!("{LSD_BUNDLES_PREFIX}{}", pkg.name);
140    pkg.lsd_module = Some(LsdModule::Iri(module_iri.clone()));
141
142    let base_path = pkg.lsd_base_path.as_deref().unwrap_or("");
143    let base_dir = join_dir(package_path, base_path).unwrap_or_else(|| package_path.clone());
144
145    // Auto-detect components/components.jsonld
146    if let Ok(components_file) = base_dir.join("components/components.jsonld") {
147        if fs.is_file(&components_file).await {
148            pkg.lsd_components = Some(format!("{base_path}components/components.jsonld"));
149        }
150    }
151
152    // Compute major version for IRI construction
153    let major = parse_major_version(&pkg.version).unwrap_or(0);
154    let base_iri = format!("{module_iri}/^{major}.0.0/");
155
156    // Auto-detect context
157    if let Ok(context_file) = base_dir.join("components/context.jsonld") {
158        if fs.is_file(&context_file).await {
159            let mut contexts = HashMap::new();
160            contexts.insert(
161                format!("{base_iri}components/context.jsonld"),
162                format!("{base_path}components/context.jsonld"),
163            );
164            pkg.lsd_contexts = Some(contexts);
165        }
166    }
167
168    // Auto-detect import paths
169    let mut import_paths = HashMap::new();
170    if let Ok(components_dir) = base_dir.join("components/") {
171        if fs.is_dir(&components_dir).await {
172            import_paths.insert(
173                format!("{base_iri}components/"),
174                format!("{base_path}components/"),
175            );
176        }
177    }
178    if let Ok(config_dir) = base_dir.join("config/") {
179        if fs.is_dir(&config_dir).await {
180            import_paths.insert(format!("{base_iri}config/"), format!("{base_path}config/"));
181        }
182    }
183    pkg.lsd_import_paths = Some(import_paths);
184}
185
186/// Preprocess all package.json files.
187pub async fn preprocess_all(fs: &dyn Fs, package_jsons: &mut HashMap<Url, PackageJson>) {
188    let keys: Vec<Url> = package_jsons.keys().cloned().collect();
189    for path in keys {
190        if let Some(pkg) = package_jsons.get_mut(&path) {
191            preprocess_package_json(fs, &path, pkg).await;
192        }
193    }
194}
195
196fn parse_major_version(version: &str) -> Option<u64> {
197    semver::Version::parse(version).ok().map(|v| v.major)
198}
199
200/// Get the resolved module IRI from a PackageJson (after preprocessing).
201pub fn get_module_iri(pkg: &PackageJson) -> Option<String> {
202    match &pkg.lsd_module {
203        Some(LsdModule::Iri(iri)) => Some(iri.clone()),
204        _ => None,
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_lsd_module_deserialization_bool() {
214        let json = r#"{"name":"test","version":"1.0.0","lsd:module":true}"#;
215        let pkg: PackageJson = serde_json::from_str(json).unwrap();
216        assert!(matches!(pkg.lsd_module, Some(LsdModule::Bool(true))));
217    }
218
219    #[test]
220    fn test_lsd_module_deserialization_string() {
221        let json = r#"{"name":"test","version":"1.0.0","lsd:module":"https://example.org/test"}"#;
222        let pkg: PackageJson = serde_json::from_str(json).unwrap();
223        assert!(matches!(pkg.lsd_module, Some(LsdModule::Iri(_))));
224        assert_eq!(
225            pkg.lsd_module.unwrap().as_iri(),
226            Some("https://example.org/test")
227        );
228    }
229
230    #[test]
231    fn test_no_lsd_module() {
232        let json = r#"{"name":"test","version":"1.0.0"}"#;
233        let pkg: PackageJson = serde_json::from_str(json).unwrap();
234        assert!(pkg.lsd_module.is_none());
235    }
236}