1use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::Result;
13use crate::fetch::is_component_crate_name;
14use crate::manifest::{
15 collect_dependency_details, find_workspace_root, read_workspace_dependencies,
16 resolve_core_version as detect_core_version, resolve_dependency_version,
17};
18use crate::registry::ComponentRegistry;
19use crate::scan::scan_templates;
20
21pub const LOCKFILE_NAME: &str = "light.lock";
23
24const HEADER: &str = "# light.lock — генерируется автоматически, не редактировать вручную.\n";
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Lockfile {
30 pub version: u32,
32 pub core: String,
34 #[serde(rename = "component", default)]
36 pub components: Vec<LockComponent>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct LockComponent {
42 pub name: String,
44 pub version: String,
46 pub source: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub path: Option<String>,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub dependencies: Vec<LockDependency>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct LockDependency {
59 pub name: String,
61 pub version: String,
63}
64
65impl Lockfile {
66 pub fn path_in(project_root: &Path) -> PathBuf {
68 project_root.join(LOCKFILE_NAME)
69 }
70
71 pub fn read(project_root: &Path) -> Result<Lockfile> {
73 let path = Self::path_in(project_root);
74 let text = std::fs::read_to_string(&path)?;
75 Self::from_toml(&text)
76 }
77
78 pub fn from_toml(text: &str) -> Result<Lockfile> {
80 toml_edit::de::from_str(text).map_err(|e| crate::Error::Lockfile(e.to_string()))
81 }
82
83 pub fn to_toml(&self) -> Result<String> {
85 let body = toml_edit::ser::to_string_pretty(self)?;
86 Ok(format!("{HEADER}{body}"))
87 }
88
89 pub fn write(&self, project_root: &Path) -> Result<()> {
91 let path = Self::path_in(project_root);
92 std::fs::write(path, self.to_toml()?)?;
93 Ok(())
94 }
95
96 pub fn from_project(project_root: &Path) -> Result<Lockfile> {
98 let registry = ComponentRegistry::builtin();
99 let core = resolve_core_version(project_root)?;
100 let details = collect_project_components(project_root)?;
101
102 let mut components = Vec::new();
103 for dep in details {
104 let info = registry.by_name(&dep.name);
105 let version = resolve_dependency_version(project_root, &dep.name)?
106 .or_else(|| info.map(|i| i.latest_version.clone()))
107 .map(|v| v.to_string())
108 .unwrap_or_else(|| "0.0.0".to_string());
109 let source = if dep.workspace || dep.path.is_some() {
110 "workspace"
111 } else {
112 "crates.io"
113 };
114 components.push(LockComponent {
115 name: dep.name.clone(),
116 version,
117 source: source.to_string(),
118 path: dep.path.as_ref().map(|p| relative_path(project_root, p)),
119 dependencies: vec![LockDependency {
120 name: "light-fw-core".to_string(),
121 version: core.clone(),
122 }],
123 });
124 }
125 components.sort_by(|a, b| a.name.cmp(&b.name));
126 Ok(Lockfile {
127 version: 1,
128 core,
129 components,
130 })
131 }
132
133 pub fn same_components(&self, other: &Lockfile) -> bool {
135 let key = |l: &Lockfile| {
136 let mut v: Vec<(String, String)> = l
137 .components
138 .iter()
139 .map(|c| (c.name.clone(), c.version.clone()))
140 .collect();
141 v.sort();
142 v
143 };
144 key(self) == key(other)
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Default)]
150pub struct LockDiff {
151 pub added: Vec<String>,
153 pub removed: Vec<String>,
155 pub changed: Vec<String>,
157}
158
159impl LockDiff {
160 pub fn is_empty(&self) -> bool {
162 self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
163 }
164
165 pub fn to_text(&self) -> String {
167 let mut out = String::new();
168 for name in &self.added {
169 out.push_str(&format!(" + {name}\n"));
170 }
171 for name in &self.removed {
172 out.push_str(&format!(" - {name}\n"));
173 }
174 for name in &self.changed {
175 out.push_str(&format!(" ~ {name}\n"));
176 }
177 out
178 }
179}
180
181pub fn diff(old: &Lockfile, new: &Lockfile) -> LockDiff {
183 use std::collections::BTreeMap;
184
185 let old_map: BTreeMap<&str, &str> = old
186 .components
187 .iter()
188 .map(|c| (c.name.as_str(), c.version.as_str()))
189 .collect();
190 let new_map: BTreeMap<&str, &str> = new
191 .components
192 .iter()
193 .map(|c| (c.name.as_str(), c.version.as_str()))
194 .collect();
195
196 let mut diff = LockDiff::default();
197 for (name, version) in &new_map {
198 match old_map.get(name) {
199 None => diff.added.push((*name).to_string()),
200 Some(old_version) if old_version != version => {
201 diff.changed.push((*name).to_string());
202 }
203 Some(_) => {}
204 }
205 }
206 for name in old_map.keys() {
207 if !new_map.contains_key(name) {
208 diff.removed.push((*name).to_string());
209 }
210 }
211 diff
212}
213
214pub fn is_actual(project_root: &Path) -> Result<bool> {
216 let path = Lockfile::path_in(project_root);
217 if !path.is_file() {
218 return Ok(false);
219 }
220 let current = Lockfile::read(project_root)?;
221 let expected = Lockfile::from_project(project_root)?;
222 Ok(current.same_components(&expected) && current.core == expected.core)
223}
224fn collect_project_components(project_root: &Path) -> Result<Vec<crate::manifest::DepDetail>> {
227 let cargo = project_root.join("Cargo.toml");
228 let text = std::fs::read_to_string(&cargo)?;
229 let details = collect_dependency_details(&text, project_root)?;
230 let registry = ComponentRegistry::builtin();
231 Ok(details
232 .into_iter()
233 .filter(|d| registry.by_name(&d.name).is_some() || is_component_crate_name(&d.name))
234 .collect())
235}
236
237fn resolve_core_version(project_root: &Path) -> Result<String> {
238 Ok(detect_core_version(project_root)?
239 .map(|v| v.to_string())
240 .unwrap_or_else(|| "0.0.0".to_string()))
241}
242
243fn relative_path(_base: &Path, path: &Path) -> String {
244 path.to_string_lossy().replace('\\', "/")
245}
246
247pub fn detect_source(project_root: &Path, name: &str) -> Result<Option<String>> {
249 if let Some(root) = find_workspace_root(project_root) {
250 let ws = read_workspace_dependencies(&root)?;
251 if ws.iter().any(|d| d.name == name) {
252 return Ok(Some("workspace".to_string()));
253 }
254 }
255 Ok(None)
256}
257
258pub fn template_count(project_root: &Path) -> Result<usize> {
260 Ok(scan_templates(project_root)?.files.len())
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use std::fs;
267
268 fn temp_dir(name: &str) -> PathBuf {
269 let dir = std::env::temp_dir().join(format!("light-fw-cli-lock-{name}"));
270 let _ = fs::remove_dir_all(&dir);
271 fs::create_dir_all(&dir).expect("create temp dir");
272 dir
273 }
274
275 #[test]
276 fn toml_round_trip() {
277 let lock = Lockfile {
278 version: 1,
279 core: "0.1.0".to_string(),
280 components: vec![LockComponent {
281 name: "light-fw-element-form".to_string(),
282 version: "0.1.0".to_string(),
283 source: "workspace".to_string(),
284 path: Some("crates/light-element-form".to_string()),
285 dependencies: vec![LockDependency {
286 name: "light-fw-core".to_string(),
287 version: "0.1.0".to_string(),
288 }],
289 }],
290 };
291 let text = lock.to_toml().expect("to_toml");
292 assert!(text.contains("not edit") || text.contains("не редактировать"));
293 let parsed = Lockfile::from_toml(&text).expect("from_toml");
294 assert_eq!(parsed, lock);
295 }
296
297 #[test]
298 fn resolves_core_from_workspace_meta_crate() {
299 let root = temp_dir("core-workspace");
300 fs::write(
301 root.join("Cargo.toml"),
302 "[workspace]\nmembers = [\"app\", \"light\"]\nresolver = \"2\"\n\n\
303 [workspace.package]\nversion = \"0.2.3\"\n\n\
304 [workspace.dependencies]\nlight = { path = \"light\" }\n",
305 )
306 .expect("root manifest");
307 let app = root.join("app");
308 fs::create_dir_all(&app).expect("app");
309 fs::write(
310 app.join("Cargo.toml"),
311 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
312 [dependencies]\nlight = { workspace = true }\n",
313 )
314 .expect("app manifest");
315 let light = root.join("light");
316 fs::create_dir_all(light.join("src")).expect("light src");
317 fs::write(light.join("src/lib.rs"), "").expect("light lib");
318 fs::write(
319 light.join("Cargo.toml"),
320 "[package]\nname = \"light\"\nversion.workspace = true\nedition = \"2021\"\n",
321 )
322 .expect("light manifest");
323
324 let lock = Lockfile::from_project(&app).expect("from_project");
325 assert_eq!(lock.core, "0.2.3", "версия ядра из workspace");
326 assert!(lock.components.is_empty());
327 }
328
329 #[test]
330 fn from_project_reads_components() {
331 let dir = temp_dir("from-project");
332 fs::write(
333 dir.join("Cargo.toml"),
334 "[package]\nname=\"p\"\nversion=\"0.1.0\"\n\n[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = \"0.1\"\n",
335 )
336 .expect("write");
337 let lock = Lockfile::from_project(&dir).expect("from_project");
338 assert_eq!(lock.version, 1);
339 assert_eq!(lock.components.len(), 1);
340 assert_eq!(lock.components[0].name, "light-fw-element-form");
341 assert_eq!(lock.core, "0.1.0");
342 }
343
344 #[test]
345 fn from_project_includes_external_component() {
346 let dir = temp_dir("external");
347 fs::write(
348 dir.join("Cargo.toml"),
349 "[package]\nname=\"p\"\nversion=\"0.1.0\"\n\n[dependencies]\nlight = \"0.1\"\n\
350 light-fw-datepicker = { version = \"0.3.0\", optional = true }\n",
351 )
352 .expect("write");
353 let lock = Lockfile::from_project(&dir).expect("from_project");
354 assert_eq!(lock.components.len(), 1);
355 assert_eq!(lock.components[0].name, "light-fw-datepicker");
356 assert_eq!(lock.components[0].version, "0.3.0");
357 assert_eq!(lock.components[0].source, "crates.io");
358 }
359
360 #[test]
361 fn is_actual_detects_drift() {
362 let dir = temp_dir("actual");
363 fs::write(
364 dir.join("Cargo.toml"),
365 "[package]\nname=\"p\"\nversion=\"0.1.0\"\n\n[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = \"0.1\"\n",
366 )
367 .expect("write");
368 assert!(!is_actual(&dir).expect("is_actual"));
370 Lockfile::from_project(&dir)
372 .expect("build")
373 .write(&dir)
374 .expect("write lock");
375 assert!(is_actual(&dir).expect("is_actual"));
376 fs::write(
378 dir.join("Cargo.toml"),
379 "[package]\nname=\"p\"\nversion=\"0.1.0\"\n\n[dependencies]\nlight = \"0.1\"\n",
380 )
381 .expect("write");
382 assert!(!is_actual(&dir).expect("is_actual"));
383 }
384
385 #[test]
386 fn diff_reports_added_removed_changed() {
387 let base = Lockfile {
388 version: 1,
389 core: "0.1.0".to_string(),
390 components: vec![
391 component("light-fw-element-form", "0.1.0"),
392 component("light-fw-behavior-validation", "0.1.0"),
393 ],
394 };
395 let updated = Lockfile {
396 version: 1,
397 core: "0.1.0".to_string(),
398 components: vec![
399 component("light-fw-element-form", "0.2.0"),
400 component("light-widget-datepicker", "0.3.0"),
401 ],
402 };
403 let changes = diff(&base, &updated);
404 assert_eq!(changes.changed, vec!["light-fw-element-form"]);
405 assert_eq!(changes.added, vec!["light-widget-datepicker"]);
406 assert_eq!(changes.removed, vec!["light-fw-behavior-validation"]);
407 assert!(!changes.is_empty());
408 assert!(changes.to_text().contains("~ light-fw-element-form"));
409 assert!(diff(&base, &base).is_empty());
410 }
411
412 fn component(name: &str, version: &str) -> LockComponent {
413 LockComponent {
414 name: name.to_string(),
415 version: version.to_string(),
416 source: "workspace".to_string(),
417 path: None,
418 dependencies: Vec::new(),
419 }
420 }
421
422 #[test]
423 fn template_count_works() {
424 let dir = temp_dir("count");
425 fs::write(
426 dir.join("Cargo.toml"),
427 "[package]\nname=\"p\"\nversion=\"0.1.0\"\n",
428 )
429 .expect("write");
430 assert_eq!(template_count(&dir).expect("count"), 0);
431 }
432}