Skip to main content

wowlab_docgen_cli/infra/
metadata.rs

1use std::collections::BTreeMap;
2
3use serde::Deserialize;
4use wowlab_fs::path::{Path, PathBuf};
5
6mod model;
7mod parser;
8
9pub use model::{BinTarget, ExternalDep, Metadata, PackageKind, Script, WorkspaceDep};
10pub(crate) use parser::parse;
11
12const MAX_METADATA_DIAGNOSTICS: usize = 2;
13
14/// # Panics
15///
16/// Panics when neither `dir` nor the workspace root has indexed metadata.
17#[must_use]
18pub fn load<'a>(workspace: &'a crate::WorkspaceIndex, dir: &Path) -> &'a Metadata {
19    workspace.metadata(dir).unwrap_or_else(|| {
20        workspace
21            .metadata(workspace.root())
22            .expect("root metadata indexed")
23    })
24}
25
26/// A diagnostic about missing or incomplete metadata.
27#[derive(Debug)]
28pub struct Diagnostic {
29    message: String,
30}
31
32impl Diagnostic {
33    #[must_use]
34    pub fn message(&self) -> &str {
35        &self.message
36    }
37}
38
39/// Return diagnostics for any missing name or description fields.
40#[must_use]
41pub fn validate(meta: &Metadata, rel_dir: &str) -> Vec<Diagnostic> {
42    let mut diags = Vec::with_capacity(MAX_METADATA_DIAGNOSTICS);
43
44    match meta.kind {
45        PackageKind::Unknown => {
46            diags.push(Diagnostic {
47                message: format!(
48                    "{rel_dir}: no metadata resolver \
49                     (no Cargo.toml, package.json, .toc, or docgen.toml)"
50                ),
51            });
52        }
53        PackageKind::Directory => {
54            if meta.description.is_none() {
55                diags.push(Diagnostic {
56                    message: format!(
57                        "{rel_dir}: no metadata resolver \
58                         (no Cargo.toml, package.json, .toc, or docgen.toml)"
59                    ),
60                });
61            }
62        }
63        kind => {
64            let source = match kind {
65                PackageKind::RustCrate => "Cargo.toml",
66                PackageKind::NodePackage => "package.json",
67                _ => "manifest",
68            };
69
70            if meta.name.is_none() {
71                diags.push(Diagnostic {
72                    message: format!("{rel_dir}: {source} missing `name`"),
73                });
74            }
75
76            if meta.description.is_none() {
77                diags.push(Diagnostic {
78                    message: format!("{rel_dir}: {source} missing `description`"),
79                });
80            }
81        }
82    }
83
84    diags
85}
86
87fn dir_name(dir: &Path) -> Option<String> {
88    dir.file_name().map(|n| n.to_string_lossy().to_string())
89}
90
91#[derive(Deserialize)]
92struct CargoToml {
93    package: Option<CargoPackage>,
94    workspace: Option<CargoWorkspace>,
95    #[serde(default)]
96    bin: Vec<CargoBin>,
97    #[serde(default)]
98    test: Vec<CargoTarget>,
99    #[serde(default)]
100    bench: Vec<CargoTarget>,
101    #[serde(default)]
102    example: Vec<CargoTarget>,
103    #[serde(default)]
104    features: BTreeMap<String, Vec<String>>,
105    #[serde(default)]
106    dependencies: BTreeMap<String, toml::Value>,
107}
108
109#[derive(Deserialize)]
110struct CargoWorkspace {
111    #[serde(default)]
112    dependencies: BTreeMap<String, toml::Value>,
113}
114
115#[derive(Debug, Default)]
116pub(crate) struct WorkspaceCargoDeps {
117    manifest_dir: PathBuf,
118    dependencies: BTreeMap<String, toml::Value>,
119}
120
121#[derive(Debug, Default)]
122struct CargoDependency {
123    path: Option<PathBuf>,
124    version: Option<String>,
125    optional: bool,
126    features: Vec<Box<str>>,
127    default_features: Option<bool>,
128}
129
130fn string_or_table<'de, D>(d: D) -> Result<Option<String>, D::Error>
131where
132    D: serde::Deserializer<'de>,
133{
134    let val = Option::<toml::Value>::deserialize(d)?;
135
136    Ok(val.and_then(|v| v.as_str().map(String::from)))
137}
138
139#[derive(Deserialize)]
140struct CargoPackage {
141    #[serde(default, deserialize_with = "string_or_table")]
142    name: Option<String>,
143    #[serde(default, deserialize_with = "string_or_table")]
144    description: Option<String>,
145    #[serde(default, deserialize_with = "string_or_table")]
146    version: Option<String>,
147    #[serde(default, rename = "rust-version", deserialize_with = "string_or_table")]
148    rust_version: Option<String>,
149}
150
151#[derive(Deserialize)]
152struct CargoBin {
153    name: Option<String>,
154    #[serde(default, rename = "required-features")]
155    required_features: Vec<Box<str>>,
156}
157
158#[derive(Deserialize)]
159struct CargoTarget {
160    name: Option<String>,
161}
162
163fn try_cargo(
164    dir: &Path,
165    files: &BTreeMap<PathBuf, Option<Box<str>>>,
166    workspace_deps: &WorkspaceCargoDeps,
167) -> Option<Metadata> {
168    let content = files.get(&dir.join("Cargo.toml"))?.as_deref()?;
169    let cargo: CargoToml = toml::from_str(content).ok()?;
170    let pkg = cargo.package?;
171
172    let internal_deps = resolve_workspace_deps(&cargo.dependencies, dir, workspace_deps);
173
174    let mut external_deps: Vec<ExternalDep> = cargo
175        .dependencies
176        .iter()
177        .filter_map(|(name, value)| {
178            let dependency = resolve_dependency(name, value, dir, workspace_deps)?;
179
180            if dependency.path.is_some() {
181                return None;
182            }
183
184            let version = dependency.version?;
185
186            Some(ExternalDep {
187                name: name.clone(),
188                version,
189            })
190        })
191        .collect();
192
193    external_deps.sort_by(|a, b| a.name.cmp(&b.name));
194
195    let bins: Vec<BinTarget> = cargo
196        .bin
197        .iter()
198        .filter_map(|b| {
199            Some(BinTarget {
200                name: b.name.clone()?,
201                required_features: b
202                    .required_features
203                    .iter()
204                    .map(ToString::to_string)
205                    .collect(),
206            })
207        })
208        .collect();
209
210    let target_names = |targets: &[CargoTarget]| -> Vec<String> {
211        targets.iter().filter_map(|t| t.name.clone()).collect()
212    };
213
214    Some(Metadata {
215        name: pkg.name,
216        description: pkg.description,
217        workspace_deps: internal_deps,
218        features: cargo.features,
219        has_binary: !bins.is_empty(),
220        binary_name: bins.first().map(|b| b.name.clone()),
221        bins,
222        tests: target_names(&cargo.test),
223        benches: target_names(&cargo.bench),
224        examples: target_names(&cargo.example),
225        external_deps,
226        version: pkg.version.unwrap_or_default(),
227        msrv: pkg.rust_version.unwrap_or_default(),
228        kind: PackageKind::RustCrate,
229        ..Default::default()
230    })
231}
232
233fn resolve_workspace_deps(
234    deps: &BTreeMap<String, toml::Value>,
235    crate_dir: &Path,
236    workspace_deps: &WorkspaceCargoDeps,
237) -> BTreeMap<String, WorkspaceDep> {
238    deps.iter()
239        .filter_map(|(name, value)| {
240            let dependency = resolve_dependency(name, value, crate_dir, workspace_deps)?;
241            let dep_path = dependency.path?;
242            let rel = relative_path(crate_dir, &dep_path)?;
243
244            Some((
245                name.clone(),
246                WorkspaceDep {
247                    path: rel,
248                    optional: dependency.optional,
249                    features: dependency.features.into_iter().map(String::from).collect(),
250                    default_features: dependency.default_features,
251                },
252            ))
253        })
254        .collect()
255}
256
257pub(crate) fn workspace_cargo_deps(
258    workspace_root: &Path,
259    files: &BTreeMap<PathBuf, Option<Box<str>>>,
260) -> WorkspaceCargoDeps {
261    let manifests = files
262        .iter()
263        .filter(|(path, _)| path.file_name().is_some_and(|name| name == "Cargo.toml"))
264        .filter_map(|(path, contents)| {
265            let cargo: CargoToml = toml::from_str(contents.as_deref()?).ok()?;
266            let workspace = cargo.workspace?;
267            let manifest_dir = path.parent()?.to_path_buf();
268
269            Some((manifest_dir, workspace.dependencies))
270        });
271    let workspace_manifest = manifests.min_by_key(|(manifest_dir, _)| {
272        manifest_dir
273            .strip_prefix(workspace_root)
274            .map_or(usize::MAX, |relative| relative.components().count())
275    });
276
277    workspace_manifest.map_or_else(
278        WorkspaceCargoDeps::default,
279        |(manifest_dir, dependencies)| WorkspaceCargoDeps {
280            manifest_dir,
281            dependencies,
282        },
283    )
284}
285
286fn resolve_dependency(
287    name: &str,
288    value: &toml::Value,
289    crate_dir: &Path,
290    workspace_deps: &WorkspaceCargoDeps,
291) -> Option<CargoDependency> {
292    let local = parse_dependency(value)?;
293    let inherits = value
294        .as_table()
295        .and_then(|table| table.get("workspace"))
296        .and_then(toml::Value::as_bool)
297        .unwrap_or(false);
298
299    if !inherits {
300        return Some(CargoDependency {
301            path: local.path.map(|path| normalize_path(&crate_dir.join(path))),
302            ..local
303        });
304    }
305
306    let inherited = parse_dependency(workspace_deps.dependencies.get(name)?)?;
307    let mut features = inherited.features;
308
309    for feature in local.features {
310        if !features.contains(&feature) {
311            features.push(feature);
312        }
313    }
314
315    Some(CargoDependency {
316        path: inherited
317            .path
318            .map(|path| normalize_path(&workspace_deps.manifest_dir.join(path))),
319        version: inherited.version,
320        optional: local.optional,
321        features,
322        default_features: local.default_features.or(inherited.default_features),
323    })
324}
325
326fn parse_dependency(value: &toml::Value) -> Option<CargoDependency> {
327    match value {
328        toml::Value::String(version) => Some(CargoDependency {
329            version: Some(version.clone()),
330            ..Default::default()
331        }),
332        toml::Value::Table(table) => Some(CargoDependency {
333            path: table
334                .get("path")
335                .and_then(toml::Value::as_str)
336                .map(PathBuf::from),
337            version: table
338                .get("version")
339                .and_then(toml::Value::as_str)
340                .map(String::from),
341            optional: table
342                .get("optional")
343                .and_then(toml::Value::as_bool)
344                .unwrap_or(false),
345            features: table
346                .get("features")
347                .and_then(toml::Value::as_array)
348                .into_iter()
349                .flatten()
350                .filter_map(toml::Value::as_str)
351                .map(Box::<str>::from)
352                .collect(),
353            default_features: table.get("default-features").and_then(toml::Value::as_bool),
354        }),
355        _ => None,
356    }
357}
358
359fn relative_path(from: &Path, to: &Path) -> Option<String> {
360    let from = normalize_path(from);
361    let to = normalize_path(to);
362    let common = from
363        .components()
364        .zip(to.components())
365        .take_while(|(left, right)| left == right)
366        .count();
367    let mut relative = PathBuf::new();
368
369    for _ in from.components().skip(common) {
370        relative.push("..");
371    }
372
373    for component in to.components().skip(common) {
374        relative.push(component.as_os_str());
375    }
376
377    relative.to_str().map(String::from)
378}
379
380fn normalize_path(path: &Path) -> PathBuf {
381    crate::workspace::normalize(path)
382}
383
384#[cfg(test)]
385fn extract_path_dep(value: &toml::Value) -> Option<String> {
386    let table = value.as_table()?;
387
388    table.get("path").and_then(|v| v.as_str()).map(String::from)
389}
390
391#[cfg(test)]
392fn extract_external_dep(value: &toml::Value) -> Option<String> {
393    match value {
394        toml::Value::String(v) => Some(v.clone()),
395        toml::Value::Table(t) => {
396            if t.contains_key("path") {
397                return None;
398            }
399
400            t.get("version").and_then(|v| v.as_str()).map(String::from)
401        }
402        _ => None,
403    }
404}
405
406#[derive(Deserialize)]
407struct PackageJson {
408    name: Option<String>,
409    description: Option<String>,
410    version: Option<String>,
411    #[serde(default)]
412    engines: Option<PackageEngines>,
413    #[serde(default)]
414    scripts: BTreeMap<String, String>,
415    #[serde(default)]
416    dependencies: BTreeMap<String, String>,
417}
418
419#[derive(Deserialize)]
420struct PackageEngines {
421    node: Option<String>,
422}
423
424fn try_package_json(dir: &Path, files: &BTreeMap<PathBuf, Option<Box<str>>>) -> Option<Metadata> {
425    let content = files.get(&dir.join("package.json"))?.as_deref()?;
426    let pkg: PackageJson = serde_json::from_str(content).ok()?;
427    let scripts: Vec<Script> = pkg
428        .scripts
429        .into_iter()
430        .map(|(name, command)| Script { name, command })
431        .collect();
432    let mut external_deps: Vec<ExternalDep> = pkg
433        .dependencies
434        .into_iter()
435        .map(|(name, version)| ExternalDep { name, version })
436        .collect();
437
438    external_deps.sort_by(|a, b| a.name.cmp(&b.name));
439
440    Some(Metadata {
441        name: pkg.name,
442        description: pkg.description,
443        version: pkg.version.unwrap_or_default(),
444        msrv: pkg.engines.and_then(|e| e.node).unwrap_or_default(),
445        scripts,
446        external_deps,
447        kind: PackageKind::NodePackage,
448        ..Default::default()
449    })
450}
451
452fn try_toc(dir: &Path, files: &BTreeMap<PathBuf, Option<Box<str>>>) -> Option<Metadata> {
453    let content = files
454        .iter()
455        .find(|(path, _)| {
456            path.parent() == Some(dir)
457                && path.extension().is_some_and(|extension| extension == "toc")
458        })?
459        .1
460        .as_deref()?;
461    let mut title = None;
462    let mut notes = None;
463
464    for line in content.lines() {
465        let line = line.trim();
466
467        if let Some(rest) = line.strip_prefix("## ") {
468            if let Some((key, value)) = rest.split_once(':') {
469                let key = key.trim();
470                let value = value.trim();
471
472                // #t(block: rust_alloc_in_loop) only matches twice, unavoidable
473                match key {
474                    "Title" => title = Some(value.to_string()),
475                    "Notes" => notes = Some(value.to_string()),
476                    _ => {}
477                }
478            }
479        }
480    }
481
482    Some(Metadata {
483        name: title.or_else(|| dir_name(dir)),
484        description: notes,
485        kind: PackageKind::NodePackage,
486        ..Default::default()
487    })
488}
489
490#[derive(Deserialize)]
491struct DocgenToml {
492    name: Option<String>,
493    description: Option<String>,
494}
495
496fn try_docgen_toml(dir: &Path, files: &BTreeMap<PathBuf, Option<Box<str>>>) -> Option<Metadata> {
497    let content = files.get(&dir.join("docgen.toml"))?.as_deref()?;
498    let doc: DocgenToml = toml::from_str(content).ok()?;
499
500    Some(Metadata {
501        name: doc.name.or_else(|| dir_name(dir)),
502        description: doc.description,
503        kind: PackageKind::Directory,
504        ..Default::default()
505    })
506}
507
508#[cfg(test)]
509mod tests;