Skip to main content

light_fw_cli_core/
fetch.rs

1//! Каталог компонентов: где искать метаданные крейта-компонента.
2//!
3//! Разрешение идёт в порядке: **workspace** → **vendor** → **crates.io**.
4//! Реальные сетевые запросы спрятаны за фичей `network`; без неё crates.io
5//! заменяется встроенным реестром (`registry.toml`), поэтому ядро CLI
6//! собирается и тестируется без сети.
7
8use 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/// Источник, из которого разрешён компонент.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SourceKind {
20    /// Локальный член workspace.
21    Workspace,
22    /// Локальная копия из `vendor/`.
23    Vendor,
24    /// Опубликованный крейт на crates.io.
25    CratesIo,
26}
27
28impl SourceKind {
29    /// Строковое имя для `light.lock`.
30    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/// Разрешённый компонент с метаданными.
40#[derive(Debug, Clone)]
41pub struct ResolvedComponent {
42    /// Имя крейта.
43    pub name: String,
44    /// Версия компонента.
45    pub version: semver::Version,
46    /// Требование к версии ядра.
47    pub core: semver::VersionReq,
48    /// Фичи мета-крейта `light-fw`, которые включает компонент.
49    pub light_features: Vec<String>,
50    /// Что предоставляет компонент.
51    pub provides: Provides,
52    /// Обязательные компоненты (`"name ^ver"`).
53    pub requires: Vec<String>,
54    /// Несовместимые компоненты.
55    pub conflicts: Vec<String>,
56    /// Источник.
57    pub source: SourceKind,
58    /// Путь к локальному компоненту (для workspace/vendor).
59    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/// Результат поиска крейта на crates.io.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct CrateSearchResult {
81    /// Имя крейта.
82    pub name: String,
83    /// Последняя стабильная версия.
84    pub version: String,
85    /// Краткое описание.
86    pub description: Option<String>,
87    /// Ссылка на страницу крейта.
88    pub url: String,
89}
90
91/// Источник данных crates.io: версии и поиск.
92///
93/// Трейт позволяет подменять сетевые запросы в тестах.
94pub trait RemoteRegistry: Send + Sync {
95    /// Возвращает последнюю стабильную версию или `None`, если крейт не найден.
96    fn latest_version(&self, name: &str) -> Result<Option<semver::Version>> {
97        let _ = name;
98        Ok(None)
99    }
100
101    /// Ищет крейты по запросу.
102    fn search(&self, query: &str, per_page: usize) -> Result<Vec<CrateSearchResult>> {
103        let _ = (query, per_page);
104        Ok(Vec::new())
105    }
106}
107
108/// Реальный клиент crates.io API (работает только с фичей `network`).
109#[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
138/// Каталог разрешения компонентов для проекта.
139pub 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    /// Создаёт каталог для проекта, автоматически находя workspace и `vendor/`.
150    pub fn discover(project_root: &Path, offline: bool) -> Self {
151        Self::with_remote(project_root, offline, Box::new(CratesIoRegistry))
152    }
153
154    /// Создаёт каталог с подменяемым источником crates.io (для тестов и
155    /// встраивания).
156    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    /// Встроенный реестр.
174    pub fn registry(&self) -> &ComponentRegistry {
175        &self.registry
176    }
177
178    /// Разрешает компонент по имени.
179    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    /// Разрешает компонент только среди локальных (workspace/vendor), без сети.
187    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            // Даже если зависимости нет в `[workspace.dependencies]`, компонент
205            // может быть членом workspace по соглашению `crates/<name>`.
206            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        // Убеждаемся, что это тот самый компонент (а не одноимённый каталог).
220        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    /// Последняя версия компонента на crates.io (через `remote`), без учёта
320    /// локальных workspace/vendor. Используется при несовместимости реестра с
321    /// ядром проекта: берём свежую версию, которая может уже поддерживать это
322    /// ядро. Без фичи `network` возвращает `None` (сеть недоступна).
323    pub fn resolve_remote_latest(&self, name: &str) -> Result<Option<ResolvedComponent>> {
324        if self.offline {
325            return Ok(None);
326        }
327        let info = self.registry.by_name(name);
328        match self.remote.latest_version(name)? {
329            Some(version) => {
330                let (core, light_features, provides) = match info {
331                    Some(i) => (
332                        i.core_requirement.clone(),
333                        i.light_features.clone(),
334                        i.provides.clone(),
335                    ),
336                    None => (semver::VersionReq::STAR, Vec::new(), Provides::default()),
337                };
338                Ok(Some(ResolvedComponent {
339                    name: name.to_string(),
340                    version,
341                    core,
342                    light_features,
343                    provides,
344                    requires: Vec::new(),
345                    conflicts: Vec::new(),
346                    source: SourceKind::CratesIo,
347                    path: None,
348                }))
349            }
350            None => Ok(None),
351        }
352    }
353
354    /// Описание зависимости, как её следует записать в `Cargo.toml` проекта.
355    pub fn dependency_entry(&self, component: &ResolvedComponent) -> Result<DependencyEntry> {
356        // Если workspace определяет зависимость — используем `workspace = true`.
357        if matches!(component.source, SourceKind::Workspace)
358            && let Some(root) = &self.workspace_root
359        {
360            let deps = read_workspace_dependencies(root)?;
361            if deps.iter().any(|d| d.name == component.name) {
362                return Ok(DependencyEntry::Workspace);
363            }
364        }
365        if let Some(path) = &component.path {
366            return Ok(DependencyEntry::Path(relative_to(&self.project_root, path)));
367        }
368        Ok(DependencyEntry::Version(component.version.to_string()))
369    }
370
371    /// Возвращает `DepDetail`-совместимое описание (для проверок), если задано в workspace.
372    pub fn workspace_detail(&self, name: &str) -> Result<Option<DepDetail>> {
373        let Some(root) = &self.workspace_root else {
374            return Ok(None);
375        };
376        Ok(read_workspace_dependencies(root)?
377            .into_iter()
378            .find(|d| d.name == name))
379    }
380
381    /// Корень проекта.
382    pub fn project_root(&self) -> &Path {
383        &self.project_root
384    }
385
386    /// Корень workspace (если найден).
387    pub fn workspace_root(&self) -> Option<&Path> {
388        self.workspace_root.as_deref()
389    }
390
391    /// Режим `--offline`.
392    pub fn is_offline(&self) -> bool {
393        self.offline
394    }
395}
396
397/// Имя внешнего компонента Light: `light-fw-*`, кроме ядра.
398pub fn is_component_crate_name(name: &str) -> bool {
399    name.starts_with("light-fw-") && name != "light-fw-core"
400}
401
402/// Вычисляет путь `target` относительно каталога `base`.
403///
404/// Оба пути должны быть абсолютными. Результат — относительный путь с
405/// ведущими `..` при необходимости (например, `../comp`).
406fn relative_to(base: &Path, target: &Path) -> PathBuf {
407    let base: Vec<_> = base.components().collect();
408    let target: Vec<_> = target.components().collect();
409    let mut common = 0;
410    while common < base.len() && common < target.len() && base[common] == target[common] {
411        common += 1;
412    }
413    let mut result = PathBuf::new();
414    for _ in common..base.len() {
415        result.push("..");
416    }
417    for component in &target[common..] {
418        result.push(component.as_os_str());
419    }
420    result
421}
422
423/// Как записать зависимость компонента в `Cargo.toml`.
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub enum DependencyEntry {
426    /// `name = { workspace = true, optional = true }`
427    Workspace,
428    /// `name = { path = "...", optional = true }`
429    Path(PathBuf),
430    /// `name = { version = "...", optional = true }`
431    Version(String),
432}
433
434/// Проверяет доступность crates.io (используется в `doctor`).
435#[cfg(feature = "network")]
436pub fn ping_crates_io() -> bool {
437    use std::time::Duration;
438    let client = match reqwest::blocking::Client::builder()
439        .timeout(Duration::from_secs(5))
440        .build()
441    {
442        Ok(c) => c,
443        Err(_) => return false,
444    };
445    client
446        .get("https://crates.io/api/v1/crates/light")
447        .header("User-Agent", "light-fw-cli (diagnostics)")
448        .send()
449        .is_ok()
450}
451
452/// Разбирает ответ `GET /api/v1/crates?q=...`.
453#[cfg(feature = "network")]
454fn parse_search_response(body: &str) -> Result<Vec<CrateSearchResult>> {
455    #[derive(serde::Deserialize)]
456    struct Response {
457        #[serde(default)]
458        crates: Vec<Entry>,
459    }
460    #[derive(serde::Deserialize)]
461    struct Entry {
462        name: String,
463        #[serde(default)]
464        max_stable_version: Option<String>,
465        #[serde(default)]
466        max_version: Option<String>,
467        #[serde(default)]
468        description: Option<String>,
469    }
470
471    let response: Response = serde_json::from_str(body)?;
472    Ok(response
473        .crates
474        .into_iter()
475        .map(|entry| CrateSearchResult {
476            version: entry
477                .max_stable_version
478                .or(entry.max_version)
479                .unwrap_or_default(),
480            url: format!("https://crates.io/crates/{}", entry.name),
481            name: entry.name,
482            description: entry.description,
483        })
484        .collect())
485}
486
487/// Ищет крейты на crates.io через API.
488#[cfg(feature = "network")]
489pub fn fetch_crates_io_search(query: &str, per_page: usize) -> Result<Vec<CrateSearchResult>> {
490    use std::time::Duration;
491
492    let per_page = per_page.to_string();
493    let client = reqwest::blocking::Client::builder()
494        .timeout(Duration::from_secs(15))
495        .build()?;
496    let response = client
497        .get("https://crates.io/api/v1/crates")
498        .query(&[("q", query), ("per_page", per_page.as_str())])
499        .header("User-Agent", "light-fw-cli (component search)")
500        .send()?
501        .error_for_status()?;
502    let body = response.text()?;
503    parse_search_response(&body)
504}
505
506/// Запрашивает последнюю версию крейта на crates.io.
507///
508/// Возвращает `Ok(None)`, если крейт не найден (HTTP 404).
509#[cfg(feature = "network")]
510pub fn fetch_crates_io(name: &str) -> Result<Option<semver::Version>> {
511    use std::time::Duration;
512
513    #[derive(serde::Deserialize)]
514    struct CrateResponse {
515        #[serde(rename = "crate")]
516        krate: CrateInfo,
517    }
518    #[derive(serde::Deserialize)]
519    struct CrateInfo {
520        max_stable_version: Option<String>,
521        max_version: String,
522    }
523
524    let url = format!("https://crates.io/api/v1/crates/{name}");
525    let client = reqwest::blocking::Client::builder()
526        .timeout(Duration::from_secs(15))
527        .build()?;
528    let response = client
529        .get(&url)
530        .header("User-Agent", "light-fw-cli (component manager)")
531        .send()?;
532    if response.status() == reqwest::StatusCode::NOT_FOUND {
533        return Ok(None);
534    }
535    let response = response.error_for_status()?;
536    let parsed: CrateResponse = response.json()?;
537    let raw = parsed
538        .krate
539        .max_stable_version
540        .unwrap_or(parsed.krate.max_version);
541    let version = semver::Version::parse(&raw)?;
542    Ok(Some(version))
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use std::fs;
549
550    fn temp_dir(name: &str) -> PathBuf {
551        let dir = std::env::temp_dir().join(format!("light-fw-cli-fetch-{name}"));
552        let _ = fs::remove_dir_all(&dir);
553        fs::create_dir_all(&dir).expect("create temp dir");
554        dir
555    }
556
557    fn write_component(dir: &Path, name: &str) {
558        fs::create_dir_all(dir.join("src")).expect("src");
559        fs::write(dir.join("src/lib.rs"), "").expect("lib");
560        fs::write(
561            dir.join("Cargo.toml"),
562            format!(
563                "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
564                 [package.metadata.light]\nkind = \"element\"\ndisplay-name = \"Test\"\ncore = \"^0.1\"\n\n\
565                 [package.metadata.light.provides]\nelements = [\"checkbox\"]\n"
566            ),
567        )
568        .expect("write component");
569    }
570
571    #[test]
572    fn resolves_workspace_component() {
573        let root = temp_dir("workspace");
574        fs::write(
575            root.join("Cargo.toml"),
576            "[workspace]\nmembers = [\"app\", \"comp\"]\n\n\
577             [workspace.dependencies]\nlight-fw-element-form = { path = \"comp\", version = \"0.1.0\" }\n",
578        )
579        .expect("root manifest");
580        let app = root.join("app");
581        fs::create_dir_all(&app).expect("app");
582        fs::write(
583            app.join("Cargo.toml"),
584            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
585        )
586        .expect("app manifest");
587        write_component(&root.join("comp"), "light-fw-element-form");
588
589        let catalog = Catalog::discover(&app, false);
590        let resolved = catalog.resolve("light-fw-element-form").expect("resolve");
591        assert_eq!(resolved.source, SourceKind::Workspace);
592        assert_eq!(resolved.version, semver::Version::new(0, 1, 0));
593        assert_eq!(resolved.provides.elements, vec!["checkbox"]);
594        assert_eq!(
595            catalog.dependency_entry(&resolved).expect("entry"),
596            DependencyEntry::Workspace
597        );
598    }
599
600    struct NoRemote;
601
602    impl RemoteRegistry for NoRemote {}
603
604    #[test]
605    fn resolves_registry_fallback_without_network() {
606        let dir = temp_dir("registry-fallback");
607        fs::write(
608            dir.join("Cargo.toml"),
609            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
610        )
611        .expect("manifest");
612        let catalog = Catalog::with_remote(&dir, false, Box::new(NoRemote));
613        let resolved = catalog
614            .resolve("light-fw-element-form")
615            .expect("registry fallback");
616        assert_eq!(resolved.source, SourceKind::CratesIo);
617        assert_eq!(resolved.version, semver::Version::new(0, 7, 0));
618    }
619
620    #[test]
621    fn offline_without_local_is_error() {
622        let dir = temp_dir("offline");
623        fs::write(
624            dir.join("Cargo.toml"),
625            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
626        )
627        .expect("manifest");
628        let catalog = Catalog::discover(&dir, true);
629        let err = catalog
630            .resolve("light-fw-element-form")
631            .expect_err("offline error");
632        assert!(matches!(err, Error::Offline(_)));
633    }
634
635    #[test]
636    fn unknown_component_is_not_found() {
637        let dir = temp_dir("unknown");
638        fs::write(
639            dir.join("Cargo.toml"),
640            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
641        )
642        .expect("manifest");
643        let catalog = Catalog::discover(&dir, false);
644        let err = catalog
645            .resolve("light-fw-datepicker")
646            .expect_err("not found");
647        assert!(matches!(err, Error::ComponentNotFound(_)));
648    }
649
650    #[test]
651    fn non_component_crate_name_is_rejected() {
652        let dir = temp_dir("not-component");
653        fs::write(
654            dir.join("Cargo.toml"),
655            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
656        )
657        .expect("manifest");
658        let catalog = Catalog::discover(&dir, false);
659        let err = catalog.resolve("serde").expect_err("not a component");
660        assert!(matches!(err, Error::NotComponent(_)));
661    }
662
663    struct MockRemote {
664        name: &'static str,
665        version: semver::Version,
666    }
667
668    impl RemoteRegistry for MockRemote {
669        fn latest_version(&self, name: &str) -> Result<Option<semver::Version>> {
670            if name == self.name {
671                Ok(Some(self.version.clone()))
672            } else {
673                Ok(None)
674            }
675        }
676    }
677
678    #[cfg(feature = "network")]
679    #[test]
680    fn parses_crates_io_search_response() {
681        let json = r#"{
682            "crates": [
683                {
684                    "name": "light-fw-datepicker",
685                    "max_stable_version": "0.3.0",
686                    "max_version": "0.4.0-beta.1",
687                    "description": "Date picker for Light"
688                },
689                {
690                    "name": "light-fw-no-version",
691                    "max_version": "1.0.0",
692                    "description": null
693                }
694            ]
695        }"#;
696        let results = parse_search_response(json).expect("parse");
697        assert_eq!(results.len(), 2);
698        assert_eq!(results[0].name, "light-fw-datepicker");
699        assert_eq!(results[0].version, "0.3.0");
700        assert_eq!(
701            results[0].url,
702            "https://crates.io/crates/light-fw-datepicker"
703        );
704        assert_eq!(
705            results[0].description.as_deref(),
706            Some("Date picker for Light")
707        );
708        assert_eq!(results[1].version, "1.0.0");
709        assert!(results[1].description.is_none());
710    }
711
712    #[test]
713    fn resolves_external_component_from_remote() {
714        let dir = temp_dir("external");
715        fs::write(
716            dir.join("Cargo.toml"),
717            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
718        )
719        .expect("manifest");
720        let catalog = Catalog::with_remote(
721            &dir,
722            false,
723            Box::new(MockRemote {
724                name: "light-fw-datepicker",
725                version: semver::Version::new(1, 2, 3),
726            }),
727        );
728        let resolved = catalog
729            .resolve("light-fw-datepicker")
730            .expect("external component");
731        assert_eq!(resolved.source, SourceKind::CratesIo);
732        assert_eq!(resolved.version, semver::Version::new(1, 2, 3));
733        assert_eq!(
734            catalog.dependency_entry(&resolved).expect("entry"),
735            DependencyEntry::Version("1.2.3".to_string())
736        );
737    }
738}