Skip to main content

light_fw_cli_core/
lockfile.rs

1//! Формат и управление `light.lock`.
2//!
3//! `light.lock` фиксирует установленные компоненты и их версии (по аналогии с
4//! `Cargo.lock`), обеспечивая воспроизводимость сборки. Файл генерируется
5//! автоматически командами `light add`/`remove`/`update`/`lock` — вручную
6//! редактировать его не нужно.
7
8use 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
21/// Имя lock-файла в корне проекта.
22pub const LOCKFILE_NAME: &str = "light.lock";
23
24/// Заголовок, записываемый в начало файла.
25const HEADER: &str = "# light.lock — генерируется автоматически, не редактировать вручную.\n";
26
27/// Содержимое `light.lock`.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Lockfile {
30    /// Версия формата.
31    pub version: u32,
32    /// Версия ядра, под которую зафиксированы компоненты.
33    pub core: String,
34    /// Зафиксированные компоненты.
35    #[serde(rename = "component", default)]
36    pub components: Vec<LockComponent>,
37}
38
39/// Один зафиксированный компонент.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct LockComponent {
42    /// Имя крейта.
43    pub name: String,
44    /// Версия.
45    pub version: String,
46    /// Источник: `workspace` | `crates.io` | `vendor`.
47    pub source: String,
48    /// Путь к локальному компоненту (для `workspace`).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub path: Option<String>,
51    /// Зависимости компонента.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub dependencies: Vec<LockDependency>,
54}
55
56/// Зависимость компонента.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct LockDependency {
59    /// Имя крейта.
60    pub name: String,
61    /// Требование/версия.
62    pub version: String,
63}
64
65impl Lockfile {
66    /// Путь к lock-файлу в проекте.
67    pub fn path_in(project_root: &Path) -> PathBuf {
68        project_root.join(LOCKFILE_NAME)
69    }
70
71    /// Читает lock-файл с диска.
72    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    /// Разбирает lock-файл из TOML-текста.
79    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    /// Сериализует lock-файл в TOML-текст (с заголовком-комментарием).
84    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    /// Записывает lock-файл на диск.
90    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    /// Строит lock-файл из текущего `Cargo.toml` проекта.
97    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    /// Сравнивает set компонентов (имя+версия) с другим lock-файлом.
134    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/// Разница между двумя `light.lock`.
149#[derive(Debug, Clone, PartialEq, Eq, Default)]
150pub struct LockDiff {
151    /// Компоненты, появившиеся в новом lock.
152    pub added: Vec<String>,
153    /// Компоненты, исчезнувшие из нового lock.
154    pub removed: Vec<String>,
155    /// Компоненты с изменившейся версией.
156    pub changed: Vec<String>,
157}
158
159impl LockDiff {
160    /// Нет ли изменений.
161    pub fn is_empty(&self) -> bool {
162        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
163    }
164
165    /// Человекочитаемое описание.
166    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
181/// Сравнивает два lock-файла по составу компонентов и их версиям.
182pub 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
214/// Актуален ли существующий `light.lock` относительно `Cargo.toml`.
215pub 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}
224/// Читает установленные компоненты проекта: известные реестру и внешние
225/// `light-fw-*` крейты.
226fn 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
247/// Определяет источник компонента (workspace / crates.io), не читая сеть.
248pub 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
258/// Полный анализ проекта (переиспользуется в `lock`), возвращает число шаблонов.
259pub 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        // Нет файла → не актуален.
369        assert!(!is_actual(&dir).expect("is_actual"));
370        // Записываем корректный lock → актуален.
371        Lockfile::from_project(&dir)
372            .expect("build")
373            .write(&dir)
374            .expect("write lock");
375        assert!(is_actual(&dir).expect("is_actual"));
376        // Меняем Cargo.toml → снова не актуален.
377        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}