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    /// Что предоставляет компонент.
49    pub provides: Provides,
50    /// Обязательные компоненты (`"name ^ver"`).
51    pub requires: Vec<String>,
52    /// Несовместимые компоненты.
53    pub conflicts: Vec<String>,
54    /// Источник.
55    pub source: SourceKind,
56    /// Путь к локальному компоненту (для workspace/vendor).
57    pub path: Option<PathBuf>,
58}
59
60impl ResolvedComponent {
61    fn from_registry(info: &crate::registry::ComponentInfo) -> Self {
62        Self {
63            name: info.name.clone(),
64            version: info.latest_version.clone(),
65            core: info.core_requirement.clone(),
66            provides: info.provides.clone(),
67            requires: Vec::new(),
68            conflicts: Vec::new(),
69            source: SourceKind::CratesIo,
70            path: None,
71        }
72    }
73}
74
75/// Каталог разрешения компонентов для проекта.
76pub struct Catalog {
77    project_root: PathBuf,
78    workspace_root: Option<PathBuf>,
79    vendor_dir: Option<PathBuf>,
80    offline: bool,
81    registry: ComponentRegistry,
82}
83
84impl Catalog {
85    /// Создаёт каталог для проекта, автоматически находя workspace и `vendor/`.
86    pub fn discover(project_root: &Path, offline: bool) -> Self {
87        let project_root = absolute(project_root);
88        let vendor = project_root.join("vendor");
89        Self {
90            workspace_root: find_workspace_root(&project_root),
91            vendor_dir: vendor.is_dir().then_some(vendor),
92            project_root,
93            offline,
94            registry: ComponentRegistry::builtin(),
95        }
96    }
97
98    /// Встроенный реестр.
99    pub fn registry(&self) -> &ComponentRegistry {
100        &self.registry
101    }
102
103    /// Разрешает компонент по имени.
104    pub fn resolve(&self, name: &str) -> Result<ResolvedComponent> {
105        if let Some(component) = self.resolve_local(name)? {
106            return Ok(component);
107        }
108        self.resolve_remote(name)
109    }
110
111    /// Разрешает компонент только среди локальных (workspace/vendor), без сети.
112    pub fn resolve_local(&self, name: &str) -> Result<Option<ResolvedComponent>> {
113        if let Some(component) = self.resolve_workspace(name)? {
114            return Ok(Some(component));
115        }
116        self.resolve_vendor(name)
117    }
118
119    fn resolve_workspace(&self, name: &str) -> Result<Option<ResolvedComponent>> {
120        let Some(root) = &self.workspace_root else {
121            return Ok(None);
122        };
123        let deps = read_workspace_dependencies(root)?;
124        let dir = match deps.into_iter().find(|d| d.name == name) {
125            Some(dep) => match dep.path {
126                Some(p) => p,
127                None => root.join("crates").join(name),
128            },
129            // Даже если зависимости нет в `[workspace.dependencies]`, компонент
130            // может быть членом workspace по соглашению `crates/<name>`.
131            None => {
132                let candidate = root.join("crates").join(name);
133                if candidate.join("Cargo.toml").is_file() {
134                    candidate
135                } else {
136                    return Ok(None);
137                }
138            }
139        };
140        let cargo = dir.join("Cargo.toml");
141        if !cargo.is_file() {
142            return Ok(None);
143        }
144        // Убеждаемся, что это тот самый компонент (а не одноимённый каталог).
145        let text = read_cargo_toml(&cargo)?;
146        if read_package_name_from_text(&text)?.as_deref() != Some(name) {
147            return Ok(None);
148        }
149        if parse_manifest_from_text(&text)?.is_none() {
150            return Ok(None);
151        }
152        Ok(Some(self.component_from_dir(
153            name,
154            &dir,
155            SourceKind::Workspace,
156        )?))
157    }
158
159    fn resolve_vendor(&self, name: &str) -> Result<Option<ResolvedComponent>> {
160        let Some(vendor) = &self.vendor_dir else {
161            return Ok(None);
162        };
163        let dir = vendor.join(name);
164        if !dir.join("Cargo.toml").is_file() {
165            return Ok(None);
166        }
167        Ok(Some(self.component_from_dir(
168            name,
169            &dir,
170            SourceKind::Vendor,
171        )?))
172    }
173
174    fn component_from_dir(
175        &self,
176        name: &str,
177        dir: &Path,
178        source: SourceKind,
179    ) -> Result<ResolvedComponent> {
180        let text = read_cargo_toml(&dir.join("Cargo.toml"))?;
181        let manifest = parse_manifest_from_text(&text)?
182            .ok_or_else(|| Error::Manifest(format!("в {name} нет [package.metadata.light]")))?;
183        let version = read_package_version(dir)?
184            .or_else(|| {
185                self.registry
186                    .by_name(name)
187                    .map(|i| i.latest_version.clone())
188            })
189            .ok_or_else(|| Error::Manifest(format!("не удалось определить версию {name}")))?;
190        Ok(ResolvedComponent {
191            name: name.to_string(),
192            version,
193            core: manifest.core.parse()?,
194            provides: manifest.provides,
195            requires: manifest.requires.components,
196            conflicts: manifest.conflicts,
197            source,
198            path: Some(dir.to_path_buf()),
199        })
200    }
201
202    fn resolve_remote(&self, name: &str) -> Result<ResolvedComponent> {
203        let info = self.registry.by_name(name);
204        if self.offline {
205            return Err(Error::Offline(name.to_string()));
206        }
207        #[cfg(feature = "network")]
208        {
209            self.resolve_remote_network(name, info)
210        }
211        #[cfg(not(feature = "network"))]
212        {
213            match info {
214                Some(i) => Ok(ResolvedComponent::from_registry(i)),
215                None => Err(Error::ComponentNotFound(name.to_string())),
216            }
217        }
218    }
219
220    #[cfg(feature = "network")]
221    fn resolve_remote_network(
222        &self,
223        name: &str,
224        info: Option<&crate::registry::ComponentInfo>,
225    ) -> Result<ResolvedComponent> {
226        match fetch_crates_io(name)? {
227            Some(version) => {
228                let (core, provides) = match info {
229                    Some(i) => (i.core_requirement.clone(), i.provides.clone()),
230                    None => (semver::VersionReq::STAR, Provides::default()),
231                };
232                Ok(ResolvedComponent {
233                    name: name.to_string(),
234                    version,
235                    core,
236                    provides,
237                    requires: Vec::new(),
238                    conflicts: Vec::new(),
239                    source: SourceKind::CratesIo,
240                    path: None,
241                })
242            }
243            None => match info {
244                Some(i) => Ok(ResolvedComponent::from_registry(i)),
245                None => Err(Error::ComponentNotFound(name.to_string())),
246            },
247        }
248    }
249
250    /// Описание зависимости, как её следует записать в `Cargo.toml` проекта.
251    pub fn dependency_entry(&self, component: &ResolvedComponent) -> Result<DependencyEntry> {
252        // Если workspace определяет зависимость — используем `workspace = true`.
253        if matches!(component.source, SourceKind::Workspace)
254            && let Some(root) = &self.workspace_root
255        {
256            let deps = read_workspace_dependencies(root)?;
257            if deps.iter().any(|d| d.name == component.name) {
258                return Ok(DependencyEntry::Workspace);
259            }
260        }
261        if let Some(path) = &component.path {
262            return Ok(DependencyEntry::Path(relative_to(&self.project_root, path)));
263        }
264        Ok(DependencyEntry::Version(component.version.to_string()))
265    }
266
267    /// Возвращает `DepDetail`-совместимое описание (для проверок), если задано в workspace.
268    pub fn workspace_detail(&self, name: &str) -> Result<Option<DepDetail>> {
269        let Some(root) = &self.workspace_root else {
270            return Ok(None);
271        };
272        Ok(read_workspace_dependencies(root)?
273            .into_iter()
274            .find(|d| d.name == name))
275    }
276
277    /// Корень проекта.
278    pub fn project_root(&self) -> &Path {
279        &self.project_root
280    }
281
282    /// Корень workspace (если найден).
283    pub fn workspace_root(&self) -> Option<&Path> {
284        self.workspace_root.as_deref()
285    }
286
287    /// Режим `--offline`.
288    pub fn is_offline(&self) -> bool {
289        self.offline
290    }
291}
292
293/// Вычисляет путь `target` относительно каталога `base`.
294///
295/// Оба пути должны быть абсолютными. Результат — относительный путь с
296/// ведущими `..` при необходимости (например, `../comp`).
297fn relative_to(base: &Path, target: &Path) -> PathBuf {
298    let base: Vec<_> = base.components().collect();
299    let target: Vec<_> = target.components().collect();
300    let mut common = 0;
301    while common < base.len() && common < target.len() && base[common] == target[common] {
302        common += 1;
303    }
304    let mut result = PathBuf::new();
305    for _ in common..base.len() {
306        result.push("..");
307    }
308    for component in &target[common..] {
309        result.push(component.as_os_str());
310    }
311    result
312}
313
314/// Как записать зависимость компонента в `Cargo.toml`.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub enum DependencyEntry {
317    /// `name = { workspace = true, optional = true }`
318    Workspace,
319    /// `name = { path = "...", optional = true }`
320    Path(PathBuf),
321    /// `name = { version = "...", optional = true }`
322    Version(String),
323}
324
325/// Проверяет доступность crates.io (используется в `doctor`).
326#[cfg(feature = "network")]
327pub fn ping_crates_io() -> bool {
328    use std::time::Duration;
329    let client = match reqwest::blocking::Client::builder()
330        .timeout(Duration::from_secs(5))
331        .build()
332    {
333        Ok(c) => c,
334        Err(_) => return false,
335    };
336    client
337        .get("https://crates.io/api/v1/crates/light")
338        .header("User-Agent", "light-fw-cli (diagnostics)")
339        .send()
340        .is_ok()
341}
342
343/// Запрашивает последнюю версию крейта на crates.io.
344///
345/// Возвращает `Ok(None)`, если крейт не найден (HTTP 404).
346#[cfg(feature = "network")]
347pub fn fetch_crates_io(name: &str) -> Result<Option<semver::Version>> {
348    use std::time::Duration;
349
350    #[derive(serde::Deserialize)]
351    struct CrateResponse {
352        #[serde(rename = "crate")]
353        krate: CrateInfo,
354    }
355    #[derive(serde::Deserialize)]
356    struct CrateInfo {
357        max_stable_version: Option<String>,
358        max_version: String,
359    }
360
361    let url = format!("https://crates.io/api/v1/crates/{name}");
362    let client = reqwest::blocking::Client::builder()
363        .timeout(Duration::from_secs(15))
364        .build()?;
365    let response = client
366        .get(&url)
367        .header("User-Agent", "light-fw-cli (component manager)")
368        .send()?;
369    if response.status() == reqwest::StatusCode::NOT_FOUND {
370        return Ok(None);
371    }
372    let response = response.error_for_status()?;
373    let parsed: CrateResponse = response.json()?;
374    let raw = parsed
375        .krate
376        .max_stable_version
377        .unwrap_or(parsed.krate.max_version);
378    let version = semver::Version::parse(&raw)?;
379    Ok(Some(version))
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use std::fs;
386
387    fn temp_dir(name: &str) -> PathBuf {
388        let dir = std::env::temp_dir().join(format!("light-fw-cli-fetch-{name}"));
389        let _ = fs::remove_dir_all(&dir);
390        fs::create_dir_all(&dir).expect("create temp dir");
391        dir
392    }
393
394    fn write_component(dir: &Path, name: &str) {
395        fs::create_dir_all(dir.join("src")).expect("src");
396        fs::write(dir.join("src/lib.rs"), "").expect("lib");
397        fs::write(
398            dir.join("Cargo.toml"),
399            format!(
400                "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
401                 [package.metadata.light]\nkind = \"element\"\ndisplay-name = \"Test\"\ncore = \"^0.1\"\n\n\
402                 [package.metadata.light.provides]\nelements = [\"checkbox\"]\n"
403            ),
404        )
405        .expect("write component");
406    }
407
408    #[test]
409    fn resolves_workspace_component() {
410        let root = temp_dir("workspace");
411        fs::write(
412            root.join("Cargo.toml"),
413            "[workspace]\nmembers = [\"app\", \"comp\"]\n\n\
414             [workspace.dependencies]\nlight-fw-element-form = { path = \"comp\", version = \"0.1.0\" }\n",
415        )
416        .expect("root manifest");
417        let app = root.join("app");
418        fs::create_dir_all(&app).expect("app");
419        fs::write(
420            app.join("Cargo.toml"),
421            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
422        )
423        .expect("app manifest");
424        write_component(&root.join("comp"), "light-fw-element-form");
425
426        let catalog = Catalog::discover(&app, false);
427        let resolved = catalog.resolve("light-fw-element-form").expect("resolve");
428        assert_eq!(resolved.source, SourceKind::Workspace);
429        assert_eq!(resolved.version, semver::Version::new(0, 1, 0));
430        assert_eq!(resolved.provides.elements, vec!["checkbox"]);
431        assert_eq!(
432            catalog.dependency_entry(&resolved).expect("entry"),
433            DependencyEntry::Workspace
434        );
435    }
436
437    #[test]
438    fn resolves_registry_fallback_without_network() {
439        let dir = temp_dir("registry-fallback");
440        fs::write(
441            dir.join("Cargo.toml"),
442            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
443        )
444        .expect("manifest");
445        let catalog = Catalog::discover(&dir, false);
446        let resolved = catalog
447            .resolve("light-fw-element-form")
448            .expect("registry fallback");
449        assert_eq!(resolved.source, SourceKind::CratesIo);
450        assert_eq!(resolved.version, semver::Version::new(0, 4, 0));
451    }
452
453    #[test]
454    fn offline_without_local_is_error() {
455        let dir = temp_dir("offline");
456        fs::write(
457            dir.join("Cargo.toml"),
458            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
459        )
460        .expect("manifest");
461        let catalog = Catalog::discover(&dir, true);
462        let err = catalog
463            .resolve("light-fw-element-form")
464            .expect_err("offline error");
465        assert!(matches!(err, Error::Offline(_)));
466    }
467
468    #[test]
469    fn unknown_component_is_not_found() {
470        let dir = temp_dir("unknown");
471        fs::write(
472            dir.join("Cargo.toml"),
473            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
474        )
475        .expect("manifest");
476        let catalog = Catalog::discover(&dir, false);
477        let err = catalog
478            .resolve("light-widget-datepicker")
479            .expect_err("not found");
480        assert!(matches!(err, Error::ComponentNotFound(_)));
481    }
482}