1use std::path::{Path, PathBuf};
9
10use crate::error::{Error, Result};
11use crate::manifest::{
12 DepDetail, Provides, absolute, find_workspace_root, parse_manifest_from_text, read_cargo_toml,
13 read_package_name_from_text, read_package_version, read_workspace_dependencies,
14};
15use crate::registry::ComponentRegistry;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SourceKind {
20 Workspace,
22 Vendor,
24 CratesIo,
26}
27
28impl SourceKind {
29 pub fn as_str(self) -> &'static str {
31 match self {
32 SourceKind::Workspace => "workspace",
33 SourceKind::Vendor => "vendor",
34 SourceKind::CratesIo => "crates.io",
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct ResolvedComponent {
42 pub name: String,
44 pub version: semver::Version,
46 pub core: semver::VersionReq,
48 pub light_features: Vec<String>,
50 pub provides: Provides,
52 pub requires: Vec<String>,
54 pub conflicts: Vec<String>,
56 pub source: SourceKind,
58 pub path: Option<PathBuf>,
60}
61
62impl ResolvedComponent {
63 fn from_registry(info: &crate::registry::ComponentInfo) -> Self {
64 Self {
65 name: info.name.clone(),
66 version: info.latest_version.clone(),
67 core: info.core_requirement.clone(),
68 light_features: info.light_features.clone(),
69 provides: info.provides.clone(),
70 requires: Vec::new(),
71 conflicts: Vec::new(),
72 source: SourceKind::CratesIo,
73 path: None,
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct CrateSearchResult {
81 pub name: String,
83 pub version: String,
85 pub description: Option<String>,
87 pub url: String,
89}
90
91pub trait RemoteRegistry: Send + Sync {
95 fn latest_version(&self, name: &str) -> Result<Option<semver::Version>> {
97 let _ = name;
98 Ok(None)
99 }
100
101 fn search(&self, query: &str, per_page: usize) -> Result<Vec<CrateSearchResult>> {
103 let _ = (query, per_page);
104 Ok(Vec::new())
105 }
106}
107
108#[derive(Debug, Default, Clone, Copy)]
110pub struct CratesIoRegistry;
111
112impl RemoteRegistry for CratesIoRegistry {
113 fn latest_version(&self, name: &str) -> Result<Option<semver::Version>> {
114 #[cfg(feature = "network")]
115 {
116 fetch_crates_io(name)
117 }
118 #[cfg(not(feature = "network"))]
119 {
120 let _ = name;
121 Ok(None)
122 }
123 }
124
125 fn search(&self, query: &str, per_page: usize) -> Result<Vec<CrateSearchResult>> {
126 #[cfg(feature = "network")]
127 {
128 fetch_crates_io_search(query, per_page)
129 }
130 #[cfg(not(feature = "network"))]
131 {
132 let _ = (query, per_page);
133 Ok(Vec::new())
134 }
135 }
136}
137
138pub struct Catalog {
140 project_root: PathBuf,
141 workspace_root: Option<PathBuf>,
142 vendor_dir: Option<PathBuf>,
143 offline: bool,
144 registry: ComponentRegistry,
145 remote: Box<dyn RemoteRegistry>,
146}
147
148impl Catalog {
149 pub fn discover(project_root: &Path, offline: bool) -> Self {
151 Self::with_remote(project_root, offline, Box::new(CratesIoRegistry))
152 }
153
154 pub fn with_remote(
157 project_root: &Path,
158 offline: bool,
159 remote: Box<dyn RemoteRegistry>,
160 ) -> Self {
161 let project_root = absolute(project_root);
162 let vendor = project_root.join("vendor");
163 Self {
164 workspace_root: find_workspace_root(&project_root),
165 vendor_dir: vendor.is_dir().then_some(vendor),
166 project_root,
167 offline,
168 registry: ComponentRegistry::builtin(),
169 remote,
170 }
171 }
172
173 pub fn registry(&self) -> &ComponentRegistry {
175 &self.registry
176 }
177
178 pub fn resolve(&self, name: &str) -> Result<ResolvedComponent> {
180 if let Some(component) = self.resolve_local(name)? {
181 return Ok(component);
182 }
183 self.resolve_remote(name)
184 }
185
186 pub fn resolve_local(&self, name: &str) -> Result<Option<ResolvedComponent>> {
188 if let Some(component) = self.resolve_workspace(name)? {
189 return Ok(Some(component));
190 }
191 self.resolve_vendor(name)
192 }
193
194 fn resolve_workspace(&self, name: &str) -> Result<Option<ResolvedComponent>> {
195 let Some(root) = &self.workspace_root else {
196 return Ok(None);
197 };
198 let deps = read_workspace_dependencies(root)?;
199 let dir = match deps.into_iter().find(|d| d.name == name) {
200 Some(dep) => match dep.path {
201 Some(p) => p,
202 None => root.join("crates").join(name),
203 },
204 None => {
207 let candidate = root.join("crates").join(name);
208 if candidate.join("Cargo.toml").is_file() {
209 candidate
210 } else {
211 return Ok(None);
212 }
213 }
214 };
215 let cargo = dir.join("Cargo.toml");
216 if !cargo.is_file() {
217 return Ok(None);
218 }
219 let text = read_cargo_toml(&cargo)?;
221 if read_package_name_from_text(&text)?.as_deref() != Some(name) {
222 return Ok(None);
223 }
224 if parse_manifest_from_text(&text)?.is_none() {
225 return Ok(None);
226 }
227 Ok(Some(self.component_from_dir(
228 name,
229 &dir,
230 SourceKind::Workspace,
231 )?))
232 }
233
234 fn resolve_vendor(&self, name: &str) -> Result<Option<ResolvedComponent>> {
235 let Some(vendor) = &self.vendor_dir else {
236 return Ok(None);
237 };
238 let dir = vendor.join(name);
239 if !dir.join("Cargo.toml").is_file() {
240 return Ok(None);
241 }
242 Ok(Some(self.component_from_dir(
243 name,
244 &dir,
245 SourceKind::Vendor,
246 )?))
247 }
248
249 fn component_from_dir(
250 &self,
251 name: &str,
252 dir: &Path,
253 source: SourceKind,
254 ) -> Result<ResolvedComponent> {
255 let text = read_cargo_toml(&dir.join("Cargo.toml"))?;
256 let manifest = parse_manifest_from_text(&text)?
257 .ok_or_else(|| Error::Manifest(format!("в {name} нет [package.metadata.light]")))?;
258 let version = read_package_version(dir)?
259 .or_else(|| {
260 self.registry
261 .by_name(name)
262 .map(|i| i.latest_version.clone())
263 })
264 .ok_or_else(|| Error::Manifest(format!("не удалось определить версию {name}")))?;
265 Ok(ResolvedComponent {
266 name: name.to_string(),
267 version,
268 core: manifest.core.parse()?,
269 light_features: self
270 .registry
271 .by_name(name)
272 .map(|info| info.light_features.clone())
273 .unwrap_or_default(),
274 provides: manifest.provides,
275 requires: manifest.requires.components,
276 conflicts: manifest.conflicts,
277 source,
278 path: Some(dir.to_path_buf()),
279 })
280 }
281
282 fn resolve_remote(&self, name: &str) -> Result<ResolvedComponent> {
283 let info = self.registry.by_name(name);
284 if info.is_none() && !is_component_crate_name(name) {
285 return Err(Error::NotComponent(name.to_string()));
286 }
287 if self.offline {
288 return Err(Error::Offline(name.to_string()));
289 }
290 match self.remote.latest_version(name)? {
291 Some(version) => {
292 let (core, light_features, provides) = match info {
293 Some(i) => (
294 i.core_requirement.clone(),
295 i.light_features.clone(),
296 i.provides.clone(),
297 ),
298 None => (semver::VersionReq::STAR, Vec::new(), Provides::default()),
299 };
300 Ok(ResolvedComponent {
301 name: name.to_string(),
302 version,
303 core,
304 light_features,
305 provides,
306 requires: Vec::new(),
307 conflicts: Vec::new(),
308 source: SourceKind::CratesIo,
309 path: None,
310 })
311 }
312 None => match info {
313 Some(i) => Ok(ResolvedComponent::from_registry(i)),
314 None => Err(Error::ComponentNotFound(name.to_string())),
315 },
316 }
317 }
318
319 pub fn dependency_entry(&self, component: &ResolvedComponent) -> Result<DependencyEntry> {
321 if matches!(component.source, SourceKind::Workspace)
323 && let Some(root) = &self.workspace_root
324 {
325 let deps = read_workspace_dependencies(root)?;
326 if deps.iter().any(|d| d.name == component.name) {
327 return Ok(DependencyEntry::Workspace);
328 }
329 }
330 if let Some(path) = &component.path {
331 return Ok(DependencyEntry::Path(relative_to(&self.project_root, path)));
332 }
333 Ok(DependencyEntry::Version(component.version.to_string()))
334 }
335
336 pub fn workspace_detail(&self, name: &str) -> Result<Option<DepDetail>> {
338 let Some(root) = &self.workspace_root else {
339 return Ok(None);
340 };
341 Ok(read_workspace_dependencies(root)?
342 .into_iter()
343 .find(|d| d.name == name))
344 }
345
346 pub fn project_root(&self) -> &Path {
348 &self.project_root
349 }
350
351 pub fn workspace_root(&self) -> Option<&Path> {
353 self.workspace_root.as_deref()
354 }
355
356 pub fn is_offline(&self) -> bool {
358 self.offline
359 }
360}
361
362pub fn is_component_crate_name(name: &str) -> bool {
364 name.starts_with("light-fw-") && name != "light-fw-core"
365}
366
367fn relative_to(base: &Path, target: &Path) -> PathBuf {
372 let base: Vec<_> = base.components().collect();
373 let target: Vec<_> = target.components().collect();
374 let mut common = 0;
375 while common < base.len() && common < target.len() && base[common] == target[common] {
376 common += 1;
377 }
378 let mut result = PathBuf::new();
379 for _ in common..base.len() {
380 result.push("..");
381 }
382 for component in &target[common..] {
383 result.push(component.as_os_str());
384 }
385 result
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
390pub enum DependencyEntry {
391 Workspace,
393 Path(PathBuf),
395 Version(String),
397}
398
399#[cfg(feature = "network")]
401pub fn ping_crates_io() -> bool {
402 use std::time::Duration;
403 let client = match reqwest::blocking::Client::builder()
404 .timeout(Duration::from_secs(5))
405 .build()
406 {
407 Ok(c) => c,
408 Err(_) => return false,
409 };
410 client
411 .get("https://crates.io/api/v1/crates/light")
412 .header("User-Agent", "light-fw-cli (diagnostics)")
413 .send()
414 .is_ok()
415}
416
417#[cfg(feature = "network")]
419fn parse_search_response(body: &str) -> Result<Vec<CrateSearchResult>> {
420 #[derive(serde::Deserialize)]
421 struct Response {
422 #[serde(default)]
423 crates: Vec<Entry>,
424 }
425 #[derive(serde::Deserialize)]
426 struct Entry {
427 name: String,
428 #[serde(default)]
429 max_stable_version: Option<String>,
430 #[serde(default)]
431 max_version: Option<String>,
432 #[serde(default)]
433 description: Option<String>,
434 }
435
436 let response: Response = serde_json::from_str(body)?;
437 Ok(response
438 .crates
439 .into_iter()
440 .map(|entry| CrateSearchResult {
441 version: entry
442 .max_stable_version
443 .or(entry.max_version)
444 .unwrap_or_default(),
445 url: format!("https://crates.io/crates/{}", entry.name),
446 name: entry.name,
447 description: entry.description,
448 })
449 .collect())
450}
451
452#[cfg(feature = "network")]
454pub fn fetch_crates_io_search(query: &str, per_page: usize) -> Result<Vec<CrateSearchResult>> {
455 use std::time::Duration;
456
457 let per_page = per_page.to_string();
458 let client = reqwest::blocking::Client::builder()
459 .timeout(Duration::from_secs(15))
460 .build()?;
461 let response = client
462 .get("https://crates.io/api/v1/crates")
463 .query(&[("q", query), ("per_page", per_page.as_str())])
464 .header("User-Agent", "light-fw-cli (component search)")
465 .send()?
466 .error_for_status()?;
467 let body = response.text()?;
468 parse_search_response(&body)
469}
470
471#[cfg(feature = "network")]
475pub fn fetch_crates_io(name: &str) -> Result<Option<semver::Version>> {
476 use std::time::Duration;
477
478 #[derive(serde::Deserialize)]
479 struct CrateResponse {
480 #[serde(rename = "crate")]
481 krate: CrateInfo,
482 }
483 #[derive(serde::Deserialize)]
484 struct CrateInfo {
485 max_stable_version: Option<String>,
486 max_version: String,
487 }
488
489 let url = format!("https://crates.io/api/v1/crates/{name}");
490 let client = reqwest::blocking::Client::builder()
491 .timeout(Duration::from_secs(15))
492 .build()?;
493 let response = client
494 .get(&url)
495 .header("User-Agent", "light-fw-cli (component manager)")
496 .send()?;
497 if response.status() == reqwest::StatusCode::NOT_FOUND {
498 return Ok(None);
499 }
500 let response = response.error_for_status()?;
501 let parsed: CrateResponse = response.json()?;
502 let raw = parsed
503 .krate
504 .max_stable_version
505 .unwrap_or(parsed.krate.max_version);
506 let version = semver::Version::parse(&raw)?;
507 Ok(Some(version))
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use std::fs;
514
515 fn temp_dir(name: &str) -> PathBuf {
516 let dir = std::env::temp_dir().join(format!("light-fw-cli-fetch-{name}"));
517 let _ = fs::remove_dir_all(&dir);
518 fs::create_dir_all(&dir).expect("create temp dir");
519 dir
520 }
521
522 fn write_component(dir: &Path, name: &str) {
523 fs::create_dir_all(dir.join("src")).expect("src");
524 fs::write(dir.join("src/lib.rs"), "").expect("lib");
525 fs::write(
526 dir.join("Cargo.toml"),
527 format!(
528 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
529 [package.metadata.light]\nkind = \"element\"\ndisplay-name = \"Test\"\ncore = \"^0.1\"\n\n\
530 [package.metadata.light.provides]\nelements = [\"checkbox\"]\n"
531 ),
532 )
533 .expect("write component");
534 }
535
536 #[test]
537 fn resolves_workspace_component() {
538 let root = temp_dir("workspace");
539 fs::write(
540 root.join("Cargo.toml"),
541 "[workspace]\nmembers = [\"app\", \"comp\"]\n\n\
542 [workspace.dependencies]\nlight-fw-element-form = { path = \"comp\", version = \"0.1.0\" }\n",
543 )
544 .expect("root manifest");
545 let app = root.join("app");
546 fs::create_dir_all(&app).expect("app");
547 fs::write(
548 app.join("Cargo.toml"),
549 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
550 )
551 .expect("app manifest");
552 write_component(&root.join("comp"), "light-fw-element-form");
553
554 let catalog = Catalog::discover(&app, false);
555 let resolved = catalog.resolve("light-fw-element-form").expect("resolve");
556 assert_eq!(resolved.source, SourceKind::Workspace);
557 assert_eq!(resolved.version, semver::Version::new(0, 1, 0));
558 assert_eq!(resolved.provides.elements, vec!["checkbox"]);
559 assert_eq!(
560 catalog.dependency_entry(&resolved).expect("entry"),
561 DependencyEntry::Workspace
562 );
563 }
564
565 struct NoRemote;
566
567 impl RemoteRegistry for NoRemote {}
568
569 #[test]
570 fn resolves_registry_fallback_without_network() {
571 let dir = temp_dir("registry-fallback");
572 fs::write(
573 dir.join("Cargo.toml"),
574 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
575 )
576 .expect("manifest");
577 let catalog = Catalog::with_remote(&dir, false, Box::new(NoRemote));
578 let resolved = catalog
579 .resolve("light-fw-element-form")
580 .expect("registry fallback");
581 assert_eq!(resolved.source, SourceKind::CratesIo);
582 assert_eq!(resolved.version, semver::Version::new(0, 5, 0));
583 }
584
585 #[test]
586 fn offline_without_local_is_error() {
587 let dir = temp_dir("offline");
588 fs::write(
589 dir.join("Cargo.toml"),
590 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
591 )
592 .expect("manifest");
593 let catalog = Catalog::discover(&dir, true);
594 let err = catalog
595 .resolve("light-fw-element-form")
596 .expect_err("offline error");
597 assert!(matches!(err, Error::Offline(_)));
598 }
599
600 #[test]
601 fn unknown_component_is_not_found() {
602 let dir = temp_dir("unknown");
603 fs::write(
604 dir.join("Cargo.toml"),
605 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
606 )
607 .expect("manifest");
608 let catalog = Catalog::discover(&dir, false);
609 let err = catalog
610 .resolve("light-fw-datepicker")
611 .expect_err("not found");
612 assert!(matches!(err, Error::ComponentNotFound(_)));
613 }
614
615 #[test]
616 fn non_component_crate_name_is_rejected() {
617 let dir = temp_dir("not-component");
618 fs::write(
619 dir.join("Cargo.toml"),
620 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
621 )
622 .expect("manifest");
623 let catalog = Catalog::discover(&dir, false);
624 let err = catalog.resolve("serde").expect_err("not a component");
625 assert!(matches!(err, Error::NotComponent(_)));
626 }
627
628 struct MockRemote {
629 name: &'static str,
630 version: semver::Version,
631 }
632
633 impl RemoteRegistry for MockRemote {
634 fn latest_version(&self, name: &str) -> Result<Option<semver::Version>> {
635 if name == self.name {
636 Ok(Some(self.version.clone()))
637 } else {
638 Ok(None)
639 }
640 }
641 }
642
643 #[cfg(feature = "network")]
644 #[test]
645 fn parses_crates_io_search_response() {
646 let json = r#"{
647 "crates": [
648 {
649 "name": "light-fw-datepicker",
650 "max_stable_version": "0.3.0",
651 "max_version": "0.4.0-beta.1",
652 "description": "Date picker for Light"
653 },
654 {
655 "name": "light-fw-no-version",
656 "max_version": "1.0.0",
657 "description": null
658 }
659 ]
660 }"#;
661 let results = parse_search_response(json).expect("parse");
662 assert_eq!(results.len(), 2);
663 assert_eq!(results[0].name, "light-fw-datepicker");
664 assert_eq!(results[0].version, "0.3.0");
665 assert_eq!(
666 results[0].url,
667 "https://crates.io/crates/light-fw-datepicker"
668 );
669 assert_eq!(
670 results[0].description.as_deref(),
671 Some("Date picker for Light")
672 );
673 assert_eq!(results[1].version, "1.0.0");
674 assert!(results[1].description.is_none());
675 }
676
677 #[test]
678 fn resolves_external_component_from_remote() {
679 let dir = temp_dir("external");
680 fs::write(
681 dir.join("Cargo.toml"),
682 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
683 )
684 .expect("manifest");
685 let catalog = Catalog::with_remote(
686 &dir,
687 false,
688 Box::new(MockRemote {
689 name: "light-fw-datepicker",
690 version: semver::Version::new(1, 2, 3),
691 }),
692 );
693 let resolved = catalog
694 .resolve("light-fw-datepicker")
695 .expect("external component");
696 assert_eq!(resolved.source, SourceKind::CratesIo);
697 assert_eq!(resolved.version, semver::Version::new(1, 2, 3));
698 assert_eq!(
699 catalog.dependency_entry(&resolved).expect("entry"),
700 DependencyEntry::Version("1.2.3".to_string())
701 );
702 }
703}