use std::path::{Path, PathBuf};
use std::str::FromStr;
use serde::Deserialize;
use toml_edit::DocumentMut;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Deserialize)]
pub struct LightManifest {
pub kind: ComponentKind,
#[serde(rename = "display-name")]
pub display_name: String,
pub core: String,
#[serde(default)]
pub provides: Provides,
#[serde(default)]
pub requires: Requires,
#[serde(default)]
pub conflicts: Vec<String>,
#[serde(default)]
pub registry: RegistryMeta,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ComponentKind {
Element,
Behavior,
Widget,
Integration,
Subsystem,
Dev,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Provides {
#[serde(default)]
pub elements: Vec<String>,
#[serde(default)]
pub attributes: Vec<String>,
#[serde(default)]
pub directives: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Requires {
#[serde(default)]
pub components: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RegistryMeta {
#[serde(default)]
pub official: bool,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Dependency {
pub name: String,
pub version: Option<String>,
}
pub fn parse_manifest_from_text(toml_text: &str) -> Result<Option<LightManifest>> {
let manifest: CargoManifest =
toml_edit::de::from_str(toml_text).map_err(|e| Error::Manifest(e.to_string()))?;
let Some(light) = manifest
.package
.and_then(|pkg| pkg.metadata)
.and_then(|meta| meta.light)
else {
return Ok(None);
};
semver::VersionReq::from_str(&light.core)
.map_err(|e| Error::Manifest(format!("invalid core range: {e}")))?;
Ok(Some(light))
}
#[derive(Debug, Deserialize)]
struct CargoManifest {
#[serde(default)]
package: Option<PackageSection>,
}
#[derive(Debug, Deserialize)]
struct PackageSection {
#[serde(default)]
metadata: Option<MetadataSection>,
}
#[derive(Debug, Deserialize)]
struct MetadataSection {
#[serde(default)]
light: Option<LightManifest>,
}
pub fn read_cargo_toml(path: &Path) -> Result<String> {
std::fs::read_to_string(path).map_err(Into::into)
}
pub fn collect_dependencies(cargo_toml: &str) -> Result<Vec<Dependency>> {
let doc: DocumentMut = cargo_toml
.parse::<DocumentMut>()
.map_err(|e| Error::Manifest(e.to_string()))?;
let mut dependencies = Vec::new();
if let Some(deps) = doc.get("dependencies").and_then(|d| d.as_table_like()) {
for (name, item) in deps.iter() {
let version = if let Some(s) = item.as_str() {
Some(s.to_string())
} else {
item.as_table_like()
.and_then(|t| t.get("version"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
};
dependencies.push(Dependency {
name: name.to_string(),
version,
});
}
}
dependencies.sort_by(|a, b| a.name.cmp(&b.name));
Ok(dependencies)
}
#[derive(Debug, Clone)]
pub struct DepDetail {
pub name: String,
pub version: Option<String>,
pub path: Option<PathBuf>,
pub workspace: bool,
pub optional: bool,
}
fn details_from_table(table: &dyn toml_edit::TableLike, base_dir: &Path) -> Vec<DepDetail> {
let mut out = Vec::new();
for (name, item) in table.iter() {
if let Some(version) = item.as_str() {
out.push(DepDetail {
name: name.to_string(),
version: Some(version.to_string()),
path: None,
workspace: false,
optional: false,
});
continue;
}
let Some(tbl) = item.as_table_like() else {
continue;
};
let version = tbl
.get("version")
.and_then(|v| v.as_str())
.map(str::to_string);
let path = tbl
.get("path")
.and_then(|v| v.as_str())
.map(|p| base_dir.join(p));
let workspace = tbl
.get("workspace")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let optional = tbl
.get("optional")
.and_then(|v| v.as_bool())
.unwrap_or(false);
out.push(DepDetail {
name: name.to_string(),
version,
path,
workspace,
optional,
});
}
out
}
pub fn collect_dependency_details(cargo_toml: &str, base_dir: &Path) -> Result<Vec<DepDetail>> {
let doc: DocumentMut = cargo_toml
.parse::<DocumentMut>()
.map_err(|e| Error::Manifest(e.to_string()))?;
let mut out = Vec::new();
if let Some(deps) = doc.get("dependencies").and_then(|d| d.as_table_like()) {
out.extend(details_from_table(deps, base_dir));
}
Ok(out)
}
pub fn read_workspace_dependencies(workspace_root: &Path) -> Result<Vec<DepDetail>> {
let cargo = workspace_root.join("Cargo.toml");
let text = read_cargo_toml(&cargo)?;
let doc: DocumentMut = text
.parse::<DocumentMut>()
.map_err(|e| Error::Manifest(e.to_string()))?;
let mut out = Vec::new();
if let Some(deps) = doc
.get("workspace")
.and_then(|w| w.as_table_like())
.and_then(|w| w.get("dependencies"))
.and_then(|d| d.as_table_like())
{
out.extend(details_from_table(deps, workspace_root));
}
Ok(out)
}
pub fn read_package_name_from_text(cargo_toml: &str) -> Result<Option<String>> {
let doc: DocumentMut = cargo_toml
.parse::<DocumentMut>()
.map_err(|e| Error::Manifest(e.to_string()))?;
Ok(doc
.get("package")
.and_then(|p| p.as_table_like())
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(str::to_string))
}
pub fn has_package_section(cargo_toml: &str) -> Result<bool> {
let doc: DocumentMut = cargo_toml
.parse::<DocumentMut>()
.map_err(|e| Error::Manifest(e.to_string()))?;
Ok(doc.get("package").is_some())
}
pub fn absolute(path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(_) => path.to_path_buf(),
}
}
pub fn find_workspace_root(start: &Path) -> Option<PathBuf> {
let start = absolute(start);
let mut dir = if start.is_file() {
start.parent().map(Path::to_path_buf)?
} else {
start
};
loop {
let cargo = dir.join("Cargo.toml");
if cargo.is_file()
&& let Ok(text) = std::fs::read_to_string(&cargo)
&& let Ok(doc) = text.parse::<DocumentMut>()
&& doc.get("workspace").is_some()
{
return Some(dir);
}
if !dir.pop() {
return None;
}
}
}
pub fn read_package_version(crate_dir: &Path) -> Result<Option<semver::Version>> {
let cargo = crate_dir.join("Cargo.toml");
let text = read_cargo_toml(&cargo)?;
let doc: DocumentMut = text
.parse::<DocumentMut>()
.map_err(|e| Error::Manifest(e.to_string()))?;
let Some(pkg) = doc.get("package").and_then(|p| p.as_table_like()) else {
return Ok(None);
};
match pkg.get("version") {
Some(v) if v.as_str().is_some() => {
Ok(semver::Version::parse(v.as_str().expect("checked")).ok())
}
Some(v) if v.as_table_like().is_some() => {
let is_workspace = v
.as_table_like()
.and_then(|t| t.get("workspace"))
.and_then(|w| w.as_bool())
.unwrap_or(false);
if is_workspace && let Some(root) = find_workspace_root(crate_dir) {
return read_workspace_package_version(&root);
}
Ok(None)
}
_ => Ok(None),
}
}
fn read_workspace_package_version(workspace_root: &Path) -> Result<Option<semver::Version>> {
let cargo = workspace_root.join("Cargo.toml");
let text = read_cargo_toml(&cargo)?;
let doc: DocumentMut = text
.parse::<DocumentMut>()
.map_err(|e| Error::Manifest(e.to_string()))?;
let version = doc
.get("workspace")
.and_then(|w| w.as_table_like())
.and_then(|w| w.get("package"))
.and_then(|p| p.as_table_like())
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.and_then(|s| semver::Version::parse(s).ok());
Ok(version)
}
pub fn min_version(req: &semver::VersionReq) -> Option<semver::Version> {
let c = req.comparators.first()?;
Some(semver::Version::new(
c.major,
c.minor.unwrap_or(0),
c.patch.unwrap_or(0),
))
}
pub fn resolve_dependency_version(
project_dir: &Path,
name: &str,
) -> Result<Option<semver::Version>> {
let cargo = project_dir.join("Cargo.toml");
let text = read_cargo_toml(&cargo)?;
let details = collect_dependency_details(&text, project_dir)?;
let Some(dep) = details.into_iter().find(|d| d.name == name) else {
return Ok(None);
};
if dep.workspace
&& let Some(root) = find_workspace_root(project_dir)
{
let ws = read_workspace_dependencies(&root)?;
if let Some(ws_dep) = ws.into_iter().find(|d| d.name == name) {
return resolve_detail_version(&ws_dep);
}
}
resolve_detail_version(&dep)
}
fn resolve_detail_version(dep: &DepDetail) -> Result<Option<semver::Version>> {
if let Some(path) = &dep.path {
return read_package_version(path);
}
if let Some(req) = &dep.version {
let req = semver::VersionReq::parse(req)?;
return Ok(min_version(&req));
}
Ok(None)
}
pub fn resolve_core_version(project_dir: &Path) -> Result<Option<semver::Version>> {
Ok(resolve_dependency_version(project_dir, "light-fw-core")?
.or(resolve_dependency_version(project_dir, "light")?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_component_manifest() {
let toml = r#"
[package]
name = "light-widget-datepicker"
version = "0.3.1"
[package.metadata.light]
kind = "widget"
display-name = "Date Picker"
core = "^0.3"
conflicts = ["light-widget-old-datepicker"]
[package.metadata.light.provides]
elements = ["datepicker"]
[package.metadata.light.requires]
components = ["light-fw-element-form ^0.3"]
[package.metadata.light.registry]
official = false
tags = ["form", "date"]
"#;
let manifest = parse_manifest_from_text(toml)
.expect("parse")
.expect("manifest present");
assert_eq!(manifest.kind, ComponentKind::Widget);
assert_eq!(manifest.display_name, "Date Picker");
assert_eq!(manifest.provides.elements, vec!["datepicker"]);
assert_eq!(
manifest.requires.components,
vec!["light-fw-element-form ^0.3"]
);
assert_eq!(manifest.conflicts, vec!["light-widget-old-datepicker"]);
assert_eq!(manifest.registry.tags, vec!["form", "date"]);
}
#[test]
fn returns_none_without_metadata() {
let toml = "[package]\nname = \"plain\"\nversion = \"0.1.0\"\n";
assert!(parse_manifest_from_text(toml).expect("parse").is_none());
}
#[test]
fn rejects_bad_core_range() {
let toml = r#"
[package.metadata.light]
kind = "element"
display-name = "Broken"
core = "not-a-range"
"#;
assert!(parse_manifest_from_text(toml).is_err());
}
#[test]
fn finds_workspace_root_from_relative_path() {
let root = find_workspace_root(Path::new(".")).expect("workspace root from '.'");
assert!(root.is_absolute(), "корень должен быть абсолютным");
assert!(root.join("Cargo.toml").is_file());
assert_eq!(
find_workspace_root(Path::new("src")).expect("workspace root from 'src'"),
root
);
}
#[test]
fn collects_dependencies_variants() {
let toml = r#"
[dependencies]
light = { version = "0.3", features = ["elements-basic"] }
light-fw-element-form = "0.3"
serde = { version = "1", features = ["derive"] }
local = { path = "../local" }
"#;
let deps = collect_dependencies(toml).expect("collect");
assert_eq!(deps.len(), 4);
let by_name = |n: &str| deps.iter().find(|d| d.name == n).expect("dep");
assert_eq!(by_name("light").version.as_deref(), Some("0.3"));
assert_eq!(
by_name("light-fw-element-form").version.as_deref(),
Some("0.3")
);
assert_eq!(by_name("serde").version.as_deref(), Some("1"));
assert!(by_name("local").version.is_none());
}
}