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#[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#[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#[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 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;