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