Skip to main content

light_fw_cli_core/
vendor.rs

1//! Логика команды `light vendor`: копирование исходников компонентов в `vendor/`.
2//!
3//! В `vendor/` складываются локальные (workspace/vendor) компоненты, чтобы
4//! проект можно было собрать без доступа к сети и без соседних репозиториев.
5//! Компоненты, доступные только на crates.io, пропускаются с предупреждением.
6
7use std::path::{Path, PathBuf};
8
9use crate::error::{Error, Result};
10use crate::fetch::Catalog;
11use crate::manifest::{collect_dependency_details, read_cargo_toml};
12use crate::registry::ComponentRegistry;
13
14/// Каталог по умолчанию.
15pub const DEFAULT_VENDOR_DIR: &str = "vendor";
16
17/// Каталоги, которые не копируются.
18const EXCLUDED_DIRS: [&str; 2] = ["target", ".git"];
19
20/// Опции команды `light vendor`.
21#[derive(Debug, Clone)]
22pub struct VendorOptions {
23    /// Куда складывать исходники.
24    pub output: PathBuf,
25    /// Только показать план.
26    pub dry_run: bool,
27    /// Режим без сети.
28    pub offline: bool,
29}
30
31impl Default for VendorOptions {
32    fn default() -> Self {
33        Self {
34            output: PathBuf::from(DEFAULT_VENDOR_DIR),
35            dry_run: false,
36            offline: false,
37        }
38    }
39}
40
41/// Один копируемый компонент.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct VendorEntry {
44    /// Имя крейта.
45    pub name: String,
46    /// Откуда копируем.
47    pub from: PathBuf,
48    /// Куда копируем.
49    pub to: PathBuf,
50}
51
52/// План вендоринга.
53#[derive(Debug, Clone)]
54pub struct VendorPlan {
55    /// Копируемые компоненты.
56    pub entries: Vec<VendorEntry>,
57    /// Компоненты, которые не удалось вендорить (только crates.io).
58    pub skipped: Vec<String>,
59}
60
61impl VendorPlan {
62    /// Человекочитаемое описание.
63    pub fn to_text(&self) -> String {
64        let mut out = String::new();
65        if self.entries.is_empty() {
66            out.push_str("Нечего вендорить (нет локальных компонентов).\n");
67        } else {
68            out.push_str("Вендоринг компонентов:\n");
69            for entry in &self.entries {
70                out.push_str(&format!("  {} → {}\n", entry.name, entry.to.display()));
71            }
72        }
73        for name in &self.skipped {
74            out.push_str(&format!(
75                "  ⚠ {name}: нет локальных исходников (будет взят с crates.io)\n"
76            ));
77        }
78        out
79    }
80}
81
82/// Строит план вендоринга.
83pub fn plan_vendor(project_root: &Path, opts: &VendorOptions) -> Result<VendorPlan> {
84    let cargo_path = project_root.join("Cargo.toml");
85    if !cargo_path.is_file() {
86        return Err(Error::Manifest(format!(
87            "Cargo.toml не найден в {}",
88            project_root.display()
89        )));
90    }
91    let text = read_cargo_toml(&cargo_path)?;
92    let installed = collect_dependency_details(&text, project_root)?;
93    let registry = ComponentRegistry::builtin();
94    let catalog = Catalog::discover(project_root, opts.offline);
95    let output = resolve_output(project_root, &opts.output);
96
97    let mut entries = Vec::new();
98    let mut skipped = Vec::new();
99    for dep in &installed {
100        if registry.by_name(&dep.name).is_none() {
101            continue;
102        }
103        match catalog.resolve(&dep.name) {
104            Ok(component) => match component.path {
105                Some(from) => entries.push(VendorEntry {
106                    name: dep.name.clone(),
107                    to: output.join(&dep.name),
108                    from,
109                }),
110                None => skipped.push(dep.name.clone()),
111            },
112            Err(_) => skipped.push(dep.name.clone()),
113        }
114    }
115
116    entries.sort_by(|a, b| a.name.cmp(&b.name));
117    skipped.sort();
118    Ok(VendorPlan { entries, skipped })
119}
120
121/// Применяет план вендоринга.
122pub fn apply_vendor(plan: &VendorPlan, dry_run: bool) -> Result<Vec<String>> {
123    if dry_run {
124        return Ok(Vec::new());
125    }
126    let mut vendored = Vec::new();
127    for entry in &plan.entries {
128        if entry.to == entry.from || entry.to.starts_with(&entry.from) {
129            continue;
130        }
131        copy_dir_filtered(&entry.from, &entry.to)?;
132        vendored.push(entry.name.clone());
133    }
134    Ok(vendored)
135}
136
137fn resolve_output(project_root: &Path, output: &Path) -> PathBuf {
138    if output.is_absolute() {
139        output.to_path_buf()
140    } else {
141        project_root.join(output)
142    }
143}
144
145fn copy_dir_filtered(src: &Path, dst: &Path) -> Result<()> {
146    std::fs::create_dir_all(dst)?;
147    for entry in std::fs::read_dir(src)? {
148        let entry = entry?;
149        let name = entry.file_name();
150        let name_str = name.to_string_lossy();
151        if entry.file_type()?.is_dir() {
152            if EXCLUDED_DIRS.contains(&name_str.as_ref()) {
153                continue;
154            }
155            copy_dir_filtered(&entry.path(), &dst.join(&name))?;
156        } else {
157            std::fs::copy(entry.path(), dst.join(&name))?;
158        }
159    }
160    Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn temp_dir(name: &str) -> PathBuf {
168        let dir = std::env::temp_dir().join(format!("light-fw-cli-vendor-{name}"));
169        let _ = std::fs::remove_dir_all(&dir);
170        std::fs::create_dir_all(&dir).expect("create temp dir");
171        dir
172    }
173
174    fn installed_workspace(name: &str) -> PathBuf {
175        let root = temp_dir(name);
176        std::fs::write(
177            root.join("Cargo.toml"),
178            "[workspace]\nmembers = [\"app\", \"comp\"]\nresolver = \"2\"\n\n\
179             [workspace.dependencies]\nlight-fw-element-form = { path = \"comp\", version = \"0.1.0\" }\n",
180        )
181        .expect("root manifest");
182        let app = root.join("app");
183        std::fs::create_dir_all(app.join("src")).expect("app src");
184        std::fs::write(app.join("src/lib.rs"), "").expect("app lib");
185        std::fs::write(
186            app.join("Cargo.toml"),
187            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
188             [dependencies]\nlight-fw-element-form = { workspace = true, optional = true }\n",
189        )
190        .expect("app manifest");
191        let comp = root.join("comp");
192        std::fs::create_dir_all(comp.join("src")).expect("comp src");
193        std::fs::write(comp.join("src/lib.rs"), "// form").expect("comp lib");
194        std::fs::write(
195            comp.join("Cargo.toml"),
196            "[package]\nname = \"light-fw-element-form\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
197             [package.metadata.light]\nkind = \"element\"\ndisplay-name = \"Form\"\ncore = \"^0.1\"\n\n\
198             [package.metadata.light.provides]\nelements = [\"checkbox\"]\n",
199        )
200        .expect("comp manifest");
201        // Мусор, который не должен копироваться.
202        std::fs::create_dir_all(comp.join("target/debug")).expect("target");
203        std::fs::write(comp.join("target/debug/junk"), "junk").expect("junk");
204        app
205    }
206
207    #[test]
208    fn plans_workspace_components() {
209        let app = installed_workspace("plan");
210        let plan = plan_vendor(&app, &VendorOptions::default()).expect("plan");
211        assert_eq!(plan.entries.len(), 1);
212        assert_eq!(plan.entries[0].name, "light-fw-element-form");
213        assert!(plan.to_text().contains("light-fw-element-form"));
214    }
215
216    #[test]
217    fn copies_sources_excluding_target() {
218        let app = installed_workspace("apply");
219        let plan = plan_vendor(&app, &VendorOptions::default()).expect("plan");
220        let vendored = apply_vendor(&plan, false).expect("apply");
221        assert_eq!(vendored, vec!["light-fw-element-form"]);
222
223        let dst = app.join("vendor/light-fw-element-form");
224        assert!(dst.join("Cargo.toml").is_file());
225        assert!(dst.join("src/lib.rs").is_file());
226        assert!(!dst.join("target").exists(), "target не копируется");
227    }
228
229    #[test]
230    fn dry_run_does_not_copy() {
231        let app = installed_workspace("dry-run");
232        let plan = plan_vendor(&app, &VendorOptions::default()).expect("plan");
233        apply_vendor(&plan, true).expect("dry-run");
234        assert!(!app.join("vendor").exists());
235    }
236}