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