use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::fetch::is_component_crate_name;
use crate::manifest::{
collect_dependency_details, find_workspace_root, read_workspace_dependencies,
resolve_core_version as detect_core_version, resolve_dependency_version,
};
use crate::registry::ComponentRegistry;
use crate::scan::scan_templates;
pub const LOCKFILE_NAME: &str = "light.lock";
const HEADER: &str = "# light.lock — генерируется автоматически, не редактировать вручную.\n";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lockfile {
pub version: u32,
pub core: String,
#[serde(rename = "component", default)]
pub components: Vec<LockComponent>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockComponent {
pub name: String,
pub version: String,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<LockDependency>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockDependency {
pub name: String,
pub version: String,
}
impl Lockfile {
pub fn path_in(project_root: &Path) -> PathBuf {
project_root.join(LOCKFILE_NAME)
}
pub fn read(project_root: &Path) -> Result<Lockfile> {
let path = Self::path_in(project_root);
let text = std::fs::read_to_string(&path)?;
Self::from_toml(&text)
}
pub fn from_toml(text: &str) -> Result<Lockfile> {
toml_edit::de::from_str(text).map_err(|e| crate::Error::Lockfile(e.to_string()))
}
pub fn to_toml(&self) -> Result<String> {
let body = toml_edit::ser::to_string_pretty(self)?;
Ok(format!("{HEADER}{body}"))
}
pub fn write(&self, project_root: &Path) -> Result<()> {
let path = Self::path_in(project_root);
std::fs::write(path, self.to_toml()?)?;
Ok(())
}
pub fn from_project(project_root: &Path) -> Result<Lockfile> {
let registry = ComponentRegistry::builtin();
let core = resolve_core_version(project_root)?;
let details = collect_project_components(project_root)?;
let mut components = Vec::new();
for dep in details {
let info = registry.by_name(&dep.name);
let version = resolve_dependency_version(project_root, &dep.name)?
.or_else(|| info.map(|i| i.latest_version.clone()))
.map(|v| v.to_string())
.unwrap_or_else(|| "0.0.0".to_string());
let source = if dep.workspace || dep.path.is_some() {
"workspace"
} else {
"crates.io"
};
components.push(LockComponent {
name: dep.name.clone(),
version,
source: source.to_string(),
path: dep.path.as_ref().map(|p| relative_path(project_root, p)),
dependencies: vec![LockDependency {
name: "light-fw-core".to_string(),
version: core.clone(),
}],
});
}
components.sort_by(|a, b| a.name.cmp(&b.name));
Ok(Lockfile {
version: 1,
core,
components,
})
}
pub fn same_components(&self, other: &Lockfile) -> bool {
let key = |l: &Lockfile| {
let mut v: Vec<(String, String)> = l
.components
.iter()
.map(|c| (c.name.clone(), c.version.clone()))
.collect();
v.sort();
v
};
key(self) == key(other)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct LockDiff {
pub added: Vec<String>,
pub removed: Vec<String>,
pub changed: Vec<String>,
}
impl LockDiff {
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
}
pub fn to_text(&self) -> String {
let mut out = String::new();
for name in &self.added {
out.push_str(&format!(" + {name}\n"));
}
for name in &self.removed {
out.push_str(&format!(" - {name}\n"));
}
for name in &self.changed {
out.push_str(&format!(" ~ {name}\n"));
}
out
}
}
pub fn diff(old: &Lockfile, new: &Lockfile) -> LockDiff {
use std::collections::BTreeMap;
let old_map: BTreeMap<&str, &str> = old
.components
.iter()
.map(|c| (c.name.as_str(), c.version.as_str()))
.collect();
let new_map: BTreeMap<&str, &str> = new
.components
.iter()
.map(|c| (c.name.as_str(), c.version.as_str()))
.collect();
let mut diff = LockDiff::default();
for (name, version) in &new_map {
match old_map.get(name) {
None => diff.added.push((*name).to_string()),
Some(old_version) if old_version != version => {
diff.changed.push((*name).to_string());
}
Some(_) => {}
}
}
for name in old_map.keys() {
if !new_map.contains_key(name) {
diff.removed.push((*name).to_string());
}
}
diff
}
pub fn is_actual(project_root: &Path) -> Result<bool> {
let path = Lockfile::path_in(project_root);
if !path.is_file() {
return Ok(false);
}
let current = Lockfile::read(project_root)?;
let expected = Lockfile::from_project(project_root)?;
Ok(current.same_components(&expected) && current.core == expected.core)
}
fn collect_project_components(project_root: &Path) -> Result<Vec<crate::manifest::DepDetail>> {
let cargo = project_root.join("Cargo.toml");
let text = std::fs::read_to_string(&cargo)?;
let details = collect_dependency_details(&text, project_root)?;
let registry = ComponentRegistry::builtin();
Ok(details
.into_iter()
.filter(|d| registry.by_name(&d.name).is_some() || is_component_crate_name(&d.name))
.collect())
}
fn resolve_core_version(project_root: &Path) -> Result<String> {
Ok(detect_core_version(project_root)?
.map(|v| v.to_string())
.unwrap_or_else(|| "0.0.0".to_string()))
}
fn relative_path(_base: &Path, path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
pub fn detect_source(project_root: &Path, name: &str) -> Result<Option<String>> {
if let Some(root) = find_workspace_root(project_root) {
let ws = read_workspace_dependencies(&root)?;
if ws.iter().any(|d| d.name == name) {
return Ok(Some("workspace".to_string()));
}
}
Ok(None)
}
pub fn template_count(project_root: &Path) -> Result<usize> {
Ok(scan_templates(project_root)?.files.len())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("light-fw-cli-lock-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn toml_round_trip() {
let lock = Lockfile {
version: 1,
core: "0.1.0".to_string(),
components: vec![LockComponent {
name: "light-fw-element-form".to_string(),
version: "0.1.0".to_string(),
source: "workspace".to_string(),
path: Some("crates/light-element-form".to_string()),
dependencies: vec![LockDependency {
name: "light-fw-core".to_string(),
version: "0.1.0".to_string(),
}],
}],
};
let text = lock.to_toml().expect("to_toml");
assert!(text.contains("not edit") || text.contains("не редактировать"));
let parsed = Lockfile::from_toml(&text).expect("from_toml");
assert_eq!(parsed, lock);
}
#[test]
fn resolves_core_from_workspace_meta_crate() {
let root = temp_dir("core-workspace");
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"app\", \"light\"]\nresolver = \"2\"\n\n\
[workspace.package]\nversion = \"0.2.3\"\n\n\
[workspace.dependencies]\nlight = { path = \"light\" }\n",
)
.expect("root manifest");
let app = root.join("app");
fs::create_dir_all(&app).expect("app");
fs::write(
app.join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[dependencies]\nlight = { workspace = true }\n",
)
.expect("app manifest");
let light = root.join("light");
fs::create_dir_all(light.join("src")).expect("light src");
fs::write(light.join("src/lib.rs"), "").expect("light lib");
fs::write(
light.join("Cargo.toml"),
"[package]\nname = \"light\"\nversion.workspace = true\nedition = \"2021\"\n",
)
.expect("light manifest");
let lock = Lockfile::from_project(&app).expect("from_project");
assert_eq!(lock.core, "0.2.3", "версия ядра из workspace");
assert!(lock.components.is_empty());
}
#[test]
fn from_project_reads_components() {
let dir = temp_dir("from-project");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname=\"p\"\nversion=\"0.1.0\"\n\n[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = \"0.1\"\n",
)
.expect("write");
let lock = Lockfile::from_project(&dir).expect("from_project");
assert_eq!(lock.version, 1);
assert_eq!(lock.components.len(), 1);
assert_eq!(lock.components[0].name, "light-fw-element-form");
assert_eq!(lock.core, "0.1.0");
}
#[test]
fn from_project_includes_external_component() {
let dir = temp_dir("external");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname=\"p\"\nversion=\"0.1.0\"\n\n[dependencies]\nlight = \"0.1\"\n\
light-fw-datepicker = { version = \"0.3.0\", optional = true }\n",
)
.expect("write");
let lock = Lockfile::from_project(&dir).expect("from_project");
assert_eq!(lock.components.len(), 1);
assert_eq!(lock.components[0].name, "light-fw-datepicker");
assert_eq!(lock.components[0].version, "0.3.0");
assert_eq!(lock.components[0].source, "crates.io");
}
#[test]
fn is_actual_detects_drift() {
let dir = temp_dir("actual");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname=\"p\"\nversion=\"0.1.0\"\n\n[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = \"0.1\"\n",
)
.expect("write");
assert!(!is_actual(&dir).expect("is_actual"));
Lockfile::from_project(&dir)
.expect("build")
.write(&dir)
.expect("write lock");
assert!(is_actual(&dir).expect("is_actual"));
fs::write(
dir.join("Cargo.toml"),
"[package]\nname=\"p\"\nversion=\"0.1.0\"\n\n[dependencies]\nlight = \"0.1\"\n",
)
.expect("write");
assert!(!is_actual(&dir).expect("is_actual"));
}
#[test]
fn diff_reports_added_removed_changed() {
let base = Lockfile {
version: 1,
core: "0.1.0".to_string(),
components: vec![
component("light-fw-element-form", "0.1.0"),
component("light-fw-behavior-validation", "0.1.0"),
],
};
let updated = Lockfile {
version: 1,
core: "0.1.0".to_string(),
components: vec![
component("light-fw-element-form", "0.2.0"),
component("light-widget-datepicker", "0.3.0"),
],
};
let changes = diff(&base, &updated);
assert_eq!(changes.changed, vec!["light-fw-element-form"]);
assert_eq!(changes.added, vec!["light-widget-datepicker"]);
assert_eq!(changes.removed, vec!["light-fw-behavior-validation"]);
assert!(!changes.is_empty());
assert!(changes.to_text().contains("~ light-fw-element-form"));
assert!(diff(&base, &base).is_empty());
}
fn component(name: &str, version: &str) -> LockComponent {
LockComponent {
name: name.to_string(),
version: version.to_string(),
source: "workspace".to_string(),
path: None,
dependencies: Vec::new(),
}
}
#[test]
fn template_count_works() {
let dir = temp_dir("count");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname=\"p\"\nversion=\"0.1.0\"\n",
)
.expect("write");
assert_eq!(template_count(&dir).expect("count"), 0);
}
}