use super::types::SourceScope;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockEntry {
pub source: String,
pub name: String,
pub version: String,
pub integrity: Option<String>,
pub scope: SourceScope,
pub source_type: String,
#[serde(default)]
pub dependencies: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lockfile {
pub version: u32,
pub packages: BTreeMap<String, LockEntry>,
}
impl Lockfile {
pub fn new() -> Self {
Self {
version: 1,
packages: BTreeMap::new(),
}
}
pub fn read(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read lockfile {}", path.display()))?;
let lock: Lockfile = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse lockfile {}", path.display()))?;
Ok(Some(lock))
}
pub fn write(&self, path: &Path) -> Result<()> {
let content = serde_json::to_string_pretty(self).context("Failed to serialize lockfile")?;
fs::write(path, content)
.with_context(|| format!("Failed to write lockfile {}", path.display()))?;
Ok(())
}
pub fn insert(&mut self, entry: LockEntry) {
self.packages.insert(entry.name.clone(), entry);
}
pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
self.packages.remove(name)
}
pub fn contains(&self, name: &str) -> bool {
self.packages.contains_key(name)
}
pub fn get(&self, name: &str) -> Option<&LockEntry> {
self.packages.get(name)
}
}
impl Default for Lockfile {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResourceCounts {
pub extensions: usize,
pub skills: usize,
pub prompts: usize,
pub themes: usize,
}
impl std::fmt::Display for ResourceCounts {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut parts = Vec::new();
if self.extensions > 0 {
parts.push(format!("{} ext", self.extensions));
}
if self.skills > 0 {
parts.push(format!("{} skill", self.skills));
}
if self.prompts > 0 {
parts.push(format!("{} prompt", self.prompts));
}
if self.themes > 0 {
parts.push(format!("{} theme", self.themes));
}
if parts.is_empty() {
write!(f, "-")?;
} else {
write!(f, "{}", parts.join(", "))?;
}
Ok(())
}
}
pub(crate) fn compute_dir_hash(dir: &Path) -> Option<String> {
let mut hasher = Sha256::new();
let mut files = collect_file_paths(dir);
files.sort();
for file_path in &files {
if let Ok(content) = fs::read(file_path) {
hasher.update(&content);
}
}
let result = hasher.finalize();
Some(format!("sha256-{:x}", result))
}
pub(crate) fn verify_lockfile_integrity(install_dir: &Path, expected: &str) -> Result<(), String> {
let expected_hex = expected.strip_prefix("sha256-").ok_or_else(|| {
format!("lockfile integrity value not in `sha256-<hex>` form: {expected}")
})?;
let actual = compute_dir_hash(install_dir)
.ok_or_else(|| format!("could not hash install dir {}", install_dir.display()))?;
let actual_hex = actual
.strip_prefix("sha256-")
.ok_or_else(|| format!("recomputed hash not in expected form: {actual}"))?;
if actual_hex.eq_ignore_ascii_case(expected_hex) {
Ok(())
} else {
Err(format!(
"sha256 mismatch: expected sha256-{expected_hex}, got {actual_hex}"
))
}
}
pub(crate) fn collect_file_paths(dir: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
if !dir.exists() {
return paths;
}
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return paths,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
paths.extend(collect_file_paths(&path));
} else {
paths.push(path);
}
}
paths
}