oxi/storage/packages/
lockfile.rs1use super::types::SourceScope;
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::collections::BTreeMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct LockEntry {
21 pub source: String,
23 pub name: String,
25 pub version: String,
27 pub integrity: Option<String>,
29 pub scope: SourceScope,
31 pub source_type: String,
33 #[serde(default)]
35 pub dependencies: BTreeMap<String, String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Lockfile {
41 pub version: u32,
43 pub packages: BTreeMap<String, LockEntry>,
45}
46
47impl Lockfile {
48 pub fn new() -> Self {
50 Self {
51 version: 1,
52 packages: BTreeMap::new(),
53 }
54 }
55
56 pub fn read(path: &Path) -> Result<Option<Self>> {
58 if !path.exists() {
59 return Ok(None);
60 }
61 let content = fs::read_to_string(path)
62 .with_context(|| format!("Failed to read lockfile {}", path.display()))?;
63 let lock: Lockfile = serde_json::from_str(&content)
64 .with_context(|| format!("Failed to parse lockfile {}", path.display()))?;
65 Ok(Some(lock))
66 }
67
68 pub fn write(&self, path: &Path) -> Result<()> {
70 let content = serde_json::to_string_pretty(self).context("Failed to serialize lockfile")?;
71 fs::write(path, content)
72 .with_context(|| format!("Failed to write lockfile {}", path.display()))?;
73 Ok(())
74 }
75
76 pub fn insert(&mut self, entry: LockEntry) {
78 self.packages.insert(entry.name.clone(), entry);
79 }
80
81 pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
83 self.packages.remove(name)
84 }
85
86 pub fn contains(&self, name: &str) -> bool {
88 self.packages.contains_key(name)
89 }
90
91 pub fn get(&self, name: &str) -> Option<&LockEntry> {
93 self.packages.get(name)
94 }
95}
96
97impl Default for Lockfile {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105pub struct ResourceCounts {
106 pub extensions: usize,
108 pub skills: usize,
110 pub prompts: usize,
112 pub themes: usize,
114}
115
116impl std::fmt::Display for ResourceCounts {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 let mut parts = Vec::new();
119 if self.extensions > 0 {
120 parts.push(format!("{} ext", self.extensions));
121 }
122 if self.skills > 0 {
123 parts.push(format!("{} skill", self.skills));
124 }
125 if self.prompts > 0 {
126 parts.push(format!("{} prompt", self.prompts));
127 }
128 if self.themes > 0 {
129 parts.push(format!("{} theme", self.themes));
130 }
131 if parts.is_empty() {
132 write!(f, "-")?;
133 } else {
134 write!(f, "{}", parts.join(", "))?;
135 }
136 Ok(())
137 }
138}
139
140pub(crate) fn compute_dir_hash(dir: &Path) -> Option<String> {
142 let mut hasher = Sha256::new();
143 let mut files = collect_file_paths(dir);
144 files.sort();
145
146 for file_path in &files {
147 if let Ok(content) = fs::read(file_path) {
148 hasher.update(&content);
149 }
150 }
151
152 let result = hasher.finalize();
153 Some(format!("sha256-{:x}", result))
154}
155
156pub(crate) fn verify_lockfile_integrity(install_dir: &Path, expected: &str) -> Result<(), String> {
170 let expected_hex = expected.strip_prefix("sha256-").ok_or_else(|| {
171 format!("lockfile integrity value not in `sha256-<hex>` form: {expected}")
172 })?;
173
174 let actual = compute_dir_hash(install_dir)
175 .ok_or_else(|| format!("could not hash install dir {}", install_dir.display()))?;
176 let actual_hex = actual
177 .strip_prefix("sha256-")
178 .ok_or_else(|| format!("recomputed hash not in expected form: {actual}"))?;
179
180 if actual_hex.eq_ignore_ascii_case(expected_hex) {
181 Ok(())
182 } else {
183 Err(format!(
184 "sha256 mismatch: expected sha256-{expected_hex}, got {actual_hex}"
185 ))
186 }
187}
188
189pub(crate) fn collect_file_paths(dir: &Path) -> Vec<PathBuf> {
191 let mut paths = Vec::new();
192 if !dir.exists() {
193 return paths;
194 }
195
196 let entries = match fs::read_dir(dir) {
197 Ok(e) => e,
198 Err(_) => return paths,
199 };
200
201 for entry in entries.flatten() {
202 let path = entry.path();
203 if path.is_dir() {
204 paths.extend(collect_file_paths(&path));
205 } else {
206 paths.push(path);
207 }
208 }
209
210 paths
211}