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,
39 Behavior,
41 Widget,
43 Integration,
45 Subsystem,
47 Dev,
49}
50
51#[derive(Debug, Clone, Default, Deserialize)]
53pub struct Provides {
54 #[serde(default)]
56 pub elements: Vec<String>,
57 #[serde(default)]
59 pub attributes: Vec<String>,
60 #[serde(default)]
62 pub directives: Vec<String>,
63}
64
65#[derive(Debug, Clone, Default, Deserialize)]
67pub struct Requires {
68 #[serde(default)]
70 pub components: Vec<String>,
71}
72
73#[derive(Debug, Clone, Default, Deserialize)]
75pub struct RegistryMeta {
76 #[serde(default)]
78 pub official: bool,
79 #[serde(default)]
81 pub tags: Vec<String>,
82}
83
84#[derive(Debug, Clone)]
86pub struct Dependency {
87 pub name: String,
89 pub version: Option<String>,
91}
92
93pub 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
129pub fn read_cargo_toml(path: &Path) -> Result<String> {
131 std::fs::read_to_string(path).map_err(Into::into)
132}
133
134pub 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#[derive(Debug, Clone)]
162pub struct DepDetail {
163 pub name: String,
165 pub version: Option<String>,
167 pub path: Option<PathBuf>,
169 pub workspace: bool,
171 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
218pub 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
230pub 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
249pub 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
262pub 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
270pub 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
285pub 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
310pub 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
356pub 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
366pub 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
401pub 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}