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`, которые включает компонент.
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    /// Описание зависимости, как её следует записать в `Cargo.toml` проекта.
320    pub fn dependency_entry(&self, component: &ResolvedComponent) -> Result<DependencyEntry> {
321        // Если workspace определяет зависимость — используем `workspace = true`.
322        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    /// Возвращает `DepDetail`-совместимое описание (для проверок), если задано в workspace.
337    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    /// Корень проекта.
347    pub fn project_root(&self) -> &Path {
348        &self.project_root
349    }
350
351    /// Корень workspace (если найден).
352    pub fn workspace_root(&self) -> Option<&Path> {
353        self.workspace_root.as_deref()
354    }
355
356    /// Режим `--offline`.
357    pub fn is_offline(&self) -> bool {
358        self.offline
359    }
360}
361
362/// Имя внешнего компонента Light: `light-fw-*`, кроме ядра.
363pub fn is_component_crate_name(name: &str) -> bool {
364    name.starts_with("light-fw-") && name != "light-fw-core"
365}
366
367/// Вычисляет путь `target` относительно каталога `base`.
368///
369/// Оба пути должны быть абсолютными. Результат — относительный путь с
370/// ведущими `..` при необходимости (например, `../comp`).
371fn 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/// Как записать зависимость компонента в `Cargo.toml`.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub enum DependencyEntry {
391    /// `name = { workspace = true, optional = true }`
392    Workspace,
393    /// `name = { path = "...", optional = true }`
394    Path(PathBuf),
395    /// `name = { version = "...", optional = true }`
396    Version(String),
397}
398
399/// Проверяет доступность crates.io (используется в `doctor`).
400#[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/// Разбирает ответ `GET /api/v1/crates?q=...`.
418#[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/// Ищет крейты на crates.io через API.
453#[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/// Запрашивает последнюю версию крейта на crates.io.
472///
473/// Возвращает `Ok(None)`, если крейт не найден (HTTP 404).
474#[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}