light_fw_cli_core/
vendor.rs1use 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
14pub const DEFAULT_VENDOR_DIR: &str = "vendor";
16
17const EXCLUDED_DIRS: [&str; 2] = ["target", ".git"];
19
20#[derive(Debug, Clone)]
22pub struct VendorOptions {
23 pub output: PathBuf,
25 pub dry_run: bool,
27 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#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct VendorEntry {
44 pub name: String,
46 pub from: PathBuf,
48 pub to: PathBuf,
50}
51
52#[derive(Debug, Clone)]
54pub struct VendorPlan {
55 pub entries: Vec<VendorEntry>,
57 pub skipped: Vec<String>,
59}
60
61impl VendorPlan {
62 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
82pub 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
121pub 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 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}