Skip to main content

light_fw_cli_core/
manifest.rs

1use std::path::{Path, PathBuf};
2use std::str::FromStr;
3
4use serde::Deserialize;
5use toml_edit::DocumentMut;
6
7use crate::error::{Error, Result};
8
9/// Манифест компонента Light (`[package.metadata.light]`).
10#[derive(Debug, Clone, Deserialize)]
11pub struct LightManifest {
12    /// Тип компонента.
13    pub kind: ComponentKind,
14    /// Человекочитаемое имя.
15    #[serde(rename = "display-name")]
16    pub display_name: String,
17    /// Требование к версии ядра (semver-range).
18    pub core: String,
19    /// Что компонент предоставляет.
20    #[serde(default)]
21    pub provides: Provides,
22    /// Что компонент требует.
23    #[serde(default)]
24    pub requires: Requires,
25    /// Компоненты, несовместимые с этим.
26    #[serde(default)]
27    pub conflicts: Vec<String>,
28    /// Метаданные реестра.
29    #[serde(default)]
30    pub registry: RegistryMeta,
31}
32
33/// Тип компонента (см. §3 модели компонентов).
34#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "kebab-case")]
36pub enum ComponentKind {
37    Element,
38    Behavior,
39    Widget,
40    Integration,
41    Subsystem,
42    Dev,
43}
44
45/// Что компонент предоставляет.
46#[derive(Debug, Clone, Default, Deserialize)]
47pub struct Provides {
48    /// Новые теги.
49    #[serde(default)]
50    pub elements: Vec<String>,
51    /// Новые атрибуты.
52    #[serde(default)]
53    pub attributes: Vec<String>,
54    /// Новые директивы.
55    #[serde(default)]
56    pub directives: Vec<String>,
57}
58
59/// Что компонент требует.
60#[derive(Debug, Clone, Default, Deserialize)]
61pub struct Requires {
62    /// Обязательные компоненты (имя + semver-range).
63    #[serde(default)]
64    pub components: Vec<String>,
65}
66
67/// Метаданные компонента для реестра.
68#[derive(Debug, Clone, Default, Deserialize)]
69pub struct RegistryMeta {
70    /// Официальный ли компонент.
71    #[serde(default)]
72    pub official: bool,
73    /// Теги для поиска.
74    #[serde(default)]
75    pub tags: Vec<String>,
76}
77
78/// Зависимость из `[dependencies]` потребительского проекта.
79#[derive(Debug, Clone)]
80pub struct Dependency {
81    /// Имя крейта.
82    pub name: String,
83    /// Версия, если указана строкой или как `version = "..."`.
84    pub version: Option<String>,
85}
86
87/// Разбирает секцию `[package.metadata.light]` из текста `Cargo.toml`.
88///
89/// Возвращает `Ok(None)`, если секции нет (обычный крейт без компонента).
90pub fn parse_manifest_from_text(toml_text: &str) -> Result<Option<LightManifest>> {
91    let manifest: CargoManifest =
92        toml_edit::de::from_str(toml_text).map_err(|e| Error::Manifest(e.to_string()))?;
93    let Some(light) = manifest
94        .package
95        .and_then(|pkg| pkg.metadata)
96        .and_then(|meta| meta.light)
97    else {
98        return Ok(None);
99    };
100    semver::VersionReq::from_str(&light.core)
101        .map_err(|e| Error::Manifest(format!("invalid core range: {e}")))?;
102    Ok(Some(light))
103}
104
105#[derive(Debug, Deserialize)]
106struct CargoManifest {
107    #[serde(default)]
108    package: Option<PackageSection>,
109}
110
111#[derive(Debug, Deserialize)]
112struct PackageSection {
113    #[serde(default)]
114    metadata: Option<MetadataSection>,
115}
116
117#[derive(Debug, Deserialize)]
118struct MetadataSection {
119    #[serde(default)]
120    light: Option<LightManifest>,
121}
122
123/// Читает `Cargo.toml` с диска.
124pub fn read_cargo_toml(path: &Path) -> Result<String> {
125    std::fs::read_to_string(path).map_err(Into::into)
126}
127
128/// Собирает зависимости из секции `[dependencies]`.
129pub fn collect_dependencies(cargo_toml: &str) -> Result<Vec<Dependency>> {
130    let doc: DocumentMut = cargo_toml
131        .parse::<DocumentMut>()
132        .map_err(|e| Error::Manifest(e.to_string()))?;
133    let mut dependencies = Vec::new();
134    if let Some(deps) = doc.get("dependencies").and_then(|d| d.as_table_like()) {
135        for (name, item) in deps.iter() {
136            let version = if let Some(s) = item.as_str() {
137                Some(s.to_string())
138            } else {
139                item.as_table_like()
140                    .and_then(|t| t.get("version"))
141                    .and_then(|v| v.as_str())
142                    .map(|s| s.to_string())
143            };
144            dependencies.push(Dependency {
145                name: name.to_string(),
146                version,
147            });
148        }
149    }
150    dependencies.sort_by(|a, b| a.name.cmp(&b.name));
151    Ok(dependencies)
152}
153
154/// Подробности одной зависимости (`[dependencies]` или `[workspace.dependencies]`).
155#[derive(Debug, Clone)]
156pub struct DepDetail {
157    /// Имя крейта.
158    pub name: String,
159    /// Строковое требование версии (`"0.1"`, `"^0.1"`, `">=0.1,<0.2"`).
160    pub version: Option<String>,
161    /// Путь (`path = "..."`), относительный к каталогу манифеста.
162    pub path: Option<PathBuf>,
163    /// Ссылка на workspace-зависимость (`workspace = true`).
164    pub workspace: bool,
165    /// Необязательная зависимость (`optional = true`).
166    pub optional: bool,
167}
168
169fn details_from_table(table: &dyn toml_edit::TableLike, base_dir: &Path) -> Vec<DepDetail> {
170    let mut out = Vec::new();
171    for (name, item) in table.iter() {
172        if let Some(version) = item.as_str() {
173            out.push(DepDetail {
174                name: name.to_string(),
175                version: Some(version.to_string()),
176                path: None,
177                workspace: false,
178                optional: false,
179            });
180            continue;
181        }
182        let Some(tbl) = item.as_table_like() else {
183            continue;
184        };
185        let version = tbl
186            .get("version")
187            .and_then(|v| v.as_str())
188            .map(str::to_string);
189        let path = tbl
190            .get("path")
191            .and_then(|v| v.as_str())
192            .map(|p| base_dir.join(p));
193        let workspace = tbl
194            .get("workspace")
195            .and_then(|v| v.as_bool())
196            .unwrap_or(false);
197        let optional = tbl
198            .get("optional")
199            .and_then(|v| v.as_bool())
200            .unwrap_or(false);
201        out.push(DepDetail {
202            name: name.to_string(),
203            version,
204            path,
205            workspace,
206            optional,
207        });
208    }
209    out
210}
211
212/// Собирает подробности из секции `[dependencies]` манифеста.
213pub fn collect_dependency_details(cargo_toml: &str, base_dir: &Path) -> Result<Vec<DepDetail>> {
214    let doc: DocumentMut = cargo_toml
215        .parse::<DocumentMut>()
216        .map_err(|e| Error::Manifest(e.to_string()))?;
217    let mut out = Vec::new();
218    if let Some(deps) = doc.get("dependencies").and_then(|d| d.as_table_like()) {
219        out.extend(details_from_table(deps, base_dir));
220    }
221    Ok(out)
222}
223
224/// Читает `[workspace.dependencies]` корневого манифеста.
225pub fn read_workspace_dependencies(workspace_root: &Path) -> Result<Vec<DepDetail>> {
226    let cargo = workspace_root.join("Cargo.toml");
227    let text = read_cargo_toml(&cargo)?;
228    let doc: DocumentMut = text
229        .parse::<DocumentMut>()
230        .map_err(|e| Error::Manifest(e.to_string()))?;
231    let mut out = Vec::new();
232    if let Some(deps) = doc
233        .get("workspace")
234        .and_then(|w| w.as_table_like())
235        .and_then(|w| w.get("dependencies"))
236        .and_then(|d| d.as_table_like())
237    {
238        out.extend(details_from_table(deps, workspace_root));
239    }
240    Ok(out)
241}
242
243/// Читает имя пакета из текста `Cargo.toml`.
244pub fn read_package_name_from_text(cargo_toml: &str) -> Result<Option<String>> {
245    let doc: DocumentMut = cargo_toml
246        .parse::<DocumentMut>()
247        .map_err(|e| Error::Manifest(e.to_string()))?;
248    Ok(doc
249        .get("package")
250        .and_then(|p| p.as_table_like())
251        .and_then(|p| p.get("name"))
252        .and_then(|n| n.as_str())
253        .map(str::to_string))
254}
255
256/// Проверяет наличие секции `[package]` в манифесте.
257pub fn has_package_section(cargo_toml: &str) -> Result<bool> {
258    let doc: DocumentMut = cargo_toml
259        .parse::<DocumentMut>()
260        .map_err(|e| Error::Manifest(e.to_string()))?;
261    Ok(doc.get("package").is_some())
262}
263
264/// Делает путь абсолютным относительно текущего каталога, не обращаясь к ФС.
265///
266/// Нужно перед подъёмом вверх по дереву каталогов: у относительного пути вида
267/// `.` нет родительского компонента, поэтому [`Path::pop`] не может подняться
268/// выше (`crates/light-demo` → `crates` → корень теряется).
269pub fn absolute(path: &Path) -> PathBuf {
270    if path.is_absolute() {
271        return path.to_path_buf();
272    }
273    match std::env::current_dir() {
274        Ok(cwd) => cwd.join(path),
275        Err(_) => path.to_path_buf(),
276    }
277}
278
279/// Находит корень workspace, поднимаясь от `start` вверх по каталогам.
280///
281/// Возвращает первый каталог с `Cargo.toml`, содержащим секцию `[workspace]`.
282pub fn find_workspace_root(start: &Path) -> Option<PathBuf> {
283    let start = absolute(start);
284    let mut dir = if start.is_file() {
285        start.parent().map(Path::to_path_buf)?
286    } else {
287        start
288    };
289    loop {
290        let cargo = dir.join("Cargo.toml");
291        if cargo.is_file()
292            && let Ok(text) = std::fs::read_to_string(&cargo)
293            && let Ok(doc) = text.parse::<DocumentMut>()
294            && doc.get("workspace").is_some()
295        {
296            return Some(dir);
297        }
298        if !dir.pop() {
299            return None;
300        }
301    }
302}
303
304/// Читает версию пакета из манифеста, разрешая `version.workspace = true`.
305pub fn read_package_version(crate_dir: &Path) -> Result<Option<semver::Version>> {
306    let cargo = crate_dir.join("Cargo.toml");
307    let text = read_cargo_toml(&cargo)?;
308    let doc: DocumentMut = text
309        .parse::<DocumentMut>()
310        .map_err(|e| Error::Manifest(e.to_string()))?;
311    let Some(pkg) = doc.get("package").and_then(|p| p.as_table_like()) else {
312        return Ok(None);
313    };
314    match pkg.get("version") {
315        Some(v) if v.as_str().is_some() => {
316            Ok(semver::Version::parse(v.as_str().expect("checked")).ok())
317        }
318        Some(v) if v.as_table_like().is_some() => {
319            let is_workspace = v
320                .as_table_like()
321                .and_then(|t| t.get("workspace"))
322                .and_then(|w| w.as_bool())
323                .unwrap_or(false);
324            if is_workspace && let Some(root) = find_workspace_root(crate_dir) {
325                return read_workspace_package_version(&root);
326            }
327            Ok(None)
328        }
329        _ => Ok(None),
330    }
331}
332
333fn read_workspace_package_version(workspace_root: &Path) -> Result<Option<semver::Version>> {
334    let cargo = workspace_root.join("Cargo.toml");
335    let text = read_cargo_toml(&cargo)?;
336    let doc: DocumentMut = text
337        .parse::<DocumentMut>()
338        .map_err(|e| Error::Manifest(e.to_string()))?;
339    let version = doc
340        .get("workspace")
341        .and_then(|w| w.as_table_like())
342        .and_then(|w| w.get("package"))
343        .and_then(|p| p.as_table_like())
344        .and_then(|p| p.get("version"))
345        .and_then(|v| v.as_str())
346        .and_then(|s| semver::Version::parse(s).ok());
347    Ok(version)
348}
349
350/// Минимальная версия, удовлетворяющая требованию (нижняя граница первого компаратора).
351pub fn min_version(req: &semver::VersionReq) -> Option<semver::Version> {
352    let c = req.comparators.first()?;
353    Some(semver::Version::new(
354        c.major,
355        c.minor.unwrap_or(0),
356        c.patch.unwrap_or(0),
357    ))
358}
359
360/// Разрешает версию зависимости `name` в проекте `project_dir`.
361///
362/// Учитывает `version = "..."`, `path = "..."` и `workspace = true`.
363pub fn resolve_dependency_version(
364    project_dir: &Path,
365    name: &str,
366) -> Result<Option<semver::Version>> {
367    let cargo = project_dir.join("Cargo.toml");
368    let text = read_cargo_toml(&cargo)?;
369    let details = collect_dependency_details(&text, project_dir)?;
370    let Some(dep) = details.into_iter().find(|d| d.name == name) else {
371        return Ok(None);
372    };
373    if dep.workspace
374        && let Some(root) = find_workspace_root(project_dir)
375    {
376        let ws = read_workspace_dependencies(&root)?;
377        if let Some(ws_dep) = ws.into_iter().find(|d| d.name == name) {
378            return resolve_detail_version(&ws_dep);
379        }
380    }
381    resolve_detail_version(&dep)
382}
383
384fn resolve_detail_version(dep: &DepDetail) -> Result<Option<semver::Version>> {
385    if let Some(path) = &dep.path {
386        return read_package_version(path);
387    }
388    if let Some(req) = &dep.version {
389        let req = semver::VersionReq::parse(req)?;
390        return Ok(min_version(&req));
391    }
392    Ok(None)
393}
394
395/// Версия ядра Light в проекте: явный `light-fw-core` или мета-крейт `light`.
396///
397/// Разрешает `path`-/`workspace`-зависимости, поэтому корректно работает и в
398/// кладках-workspace, где `light` подключается как `workspace = true`.
399pub fn resolve_core_version(project_dir: &Path) -> Result<Option<semver::Version>> {
400    Ok(resolve_dependency_version(project_dir, "light-fw-core")?
401        .or(resolve_dependency_version(project_dir, "light")?))
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn parses_component_manifest() {
410        let toml = r#"
411            [package]
412            name = "light-widget-datepicker"
413            version = "0.3.1"
414
415            [package.metadata.light]
416            kind = "widget"
417            display-name = "Date Picker"
418            core = "^0.3"
419            conflicts = ["light-widget-old-datepicker"]
420
421            [package.metadata.light.provides]
422            elements = ["datepicker"]
423
424            [package.metadata.light.requires]
425            components = ["light-fw-element-form ^0.3"]
426
427            [package.metadata.light.registry]
428            official = false
429            tags = ["form", "date"]
430        "#;
431        let manifest = parse_manifest_from_text(toml)
432            .expect("parse")
433            .expect("manifest present");
434        assert_eq!(manifest.kind, ComponentKind::Widget);
435        assert_eq!(manifest.display_name, "Date Picker");
436        assert_eq!(manifest.provides.elements, vec!["datepicker"]);
437        assert_eq!(
438            manifest.requires.components,
439            vec!["light-fw-element-form ^0.3"]
440        );
441        assert_eq!(manifest.conflicts, vec!["light-widget-old-datepicker"]);
442        assert_eq!(manifest.registry.tags, vec!["form", "date"]);
443    }
444
445    #[test]
446    fn returns_none_without_metadata() {
447        let toml = "[package]\nname = \"plain\"\nversion = \"0.1.0\"\n";
448        assert!(parse_manifest_from_text(toml).expect("parse").is_none());
449    }
450
451    #[test]
452    fn rejects_bad_core_range() {
453        let toml = r#"
454            [package.metadata.light]
455            kind = "element"
456            display-name = "Broken"
457            core = "not-a-range"
458        "#;
459        assert!(parse_manifest_from_text(toml).is_err());
460    }
461
462    #[test]
463    fn finds_workspace_root_from_relative_path() {
464        let root = find_workspace_root(Path::new(".")).expect("workspace root from '.'");
465        assert!(root.is_absolute(), "корень должен быть абсолютным");
466        assert!(root.join("Cargo.toml").is_file());
467        assert_eq!(
468            find_workspace_root(Path::new("src")).expect("workspace root from 'src'"),
469            root
470        );
471    }
472
473    #[test]
474    fn collects_dependencies_variants() {
475        let toml = r#"
476            [dependencies]
477            light = { version = "0.3", features = ["elements-basic"] }
478            light-fw-element-form = "0.3"
479            serde = { version = "1", features = ["derive"] }
480            local = { path = "../local" }
481        "#;
482        let deps = collect_dependencies(toml).expect("collect");
483        assert_eq!(deps.len(), 4);
484        let by_name = |n: &str| deps.iter().find(|d| d.name == n).expect("dep");
485        assert_eq!(by_name("light").version.as_deref(), Some("0.3"));
486        assert_eq!(
487            by_name("light-fw-element-form").version.as_deref(),
488            Some("0.3")
489        );
490        assert_eq!(by_name("serde").version.as_deref(), Some("1"));
491        assert!(by_name("local").version.is_none());
492    }
493}