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#[derive(Debug, Clone, Deserialize)]
11pub struct LightManifest {
12 pub kind: ComponentKind,
14 #[serde(rename = "display-name")]
16 pub display_name: String,
17 pub core: String,
19 #[serde(default)]
21 pub provides: Provides,
22 #[serde(default)]
24 pub requires: Requires,
25 #[serde(default)]
27 pub conflicts: Vec<String>,
28 #[serde(default)]
30 pub registry: RegistryMeta,
31}
32
33#[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#[derive(Debug, Clone, Default, Deserialize)]
47pub struct Provides {
48 #[serde(default)]
50 pub elements: Vec<String>,
51 #[serde(default)]
53 pub attributes: Vec<String>,
54 #[serde(default)]
56 pub directives: Vec<String>,
57}
58
59#[derive(Debug, Clone, Default, Deserialize)]
61pub struct Requires {
62 #[serde(default)]
64 pub components: Vec<String>,
65}
66
67#[derive(Debug, Clone, Default, Deserialize)]
69pub struct RegistryMeta {
70 #[serde(default)]
72 pub official: bool,
73 #[serde(default)]
75 pub tags: Vec<String>,
76}
77
78#[derive(Debug, Clone)]
80pub struct Dependency {
81 pub name: String,
83 pub version: Option<String>,
85}
86
87pub 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
123pub fn read_cargo_toml(path: &Path) -> Result<String> {
125 std::fs::read_to_string(path).map_err(Into::into)
126}
127
128pub 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#[derive(Debug, Clone)]
156pub struct DepDetail {
157 pub name: String,
159 pub version: Option<String>,
161 pub path: Option<PathBuf>,
163 pub workspace: bool,
165 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
212pub 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
224pub 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
243pub 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
256pub 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
264pub 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
279pub 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
304pub 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
350pub 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
360pub 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
395pub 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}