Skip to main content

zoi_core/
recorder.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::{LazyLock, Mutex};
4
5use anyhow::{Result, anyhow};
6
7use crate::types;
8
9/// Global mutex for recording package changes to prevent concurrent writes to
10/// the lockfile.
11static RECORD_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
12
13/// Returns the path to the lockfile for the given scope.
14fn get_lockfile_path(scope: types::Scope) -> Result<PathBuf> {
15    let path = if scope == types::Scope::Project {
16        std::env::current_dir()?.join("zoi.lock")
17    } else {
18        let home_dir = crate::utils::get_user_home()
19            .ok_or_else(|| anyhow!("Could not find home directory."))?;
20        home_dir.join(".zoi").join("pkgs").join("zoi.lock")
21    };
22
23    if let Some(parent) = path.parent() {
24        fs::create_dir_all(parent)?;
25    }
26    Ok(path)
27}
28
29/// Reads and parses the lockfile for the given scope.
30fn read_lockfile(scope: types::Scope) -> Result<types::ZoiLockV2> {
31    let path = get_lockfile_path(scope)?;
32    if !path.exists() || fs::read_to_string(&path)?.trim().is_empty() {
33        return Ok(types::ZoiLockV2 {
34            version: "2".to_string(),
35            ..Default::default()
36        });
37    }
38    let content = fs::read_to_string(path)?;
39    let lockfile = serde_json::from_str(&content)?;
40    Ok(lockfile)
41}
42
43/// Persists the state of the Zoi environment into the lockfile (`zoi.lock`).
44///
45/// Specification v2 uses a "Snapshot" model for reproducibility. Instead of
46/// just recording versions, Zoi computes:
47/// - `packages_hash`: A recursive SHA-512 hash of the entire package store.
48/// - `registries_hash`: A recursive SHA-512 hash of the metadata database.
49/// - Per-Package Hash: A hash of the specific version directory.
50///
51/// This ensures that a project environment can be verified for 100% bit-for-bit
52/// identicality across different machines.
53fn write_lockfile(
54    lockfile: &mut types::ZoiLockV2,
55    scope: types::Scope
56) -> Result<()> {
57    if crate::frozen::is_frozen() {
58        return Ok(());
59    }
60    let path = get_lockfile_path(scope)?;
61
62    if let Ok(store_dir) = crate::utils::get_store_base_dir(scope) {
63        lockfile.packages_hash = Some(format!(
64            "sha512-{}",
65            crate::hash::calculate_dir_hash(&store_dir).unwrap_or_default()
66        ));
67    }
68
69    let db_dir = if scope == types::Scope::Project {
70        std::env::current_dir()?
71            .join(".zoi")
72            .join("pkgs")
73            .join("db")
74    } else {
75        crate::utils::get_db_root().unwrap_or_default()
76    };
77
78    if db_dir.exists() {
79        lockfile.registries_hash = Some(format!(
80            "sha512-{}",
81            crate::hash::calculate_dir_hash(&db_dir).unwrap_or_default()
82        ));
83    }
84
85    let content = serde_json::to_string_pretty(lockfile)?;
86    fs::write(path, content)?;
87    Ok(())
88}
89
90/// Records a package installation or update in the lockfile.
91///
92/// # Errors
93///
94/// Returns an error if:
95/// - The lockfile cannot be read or written.
96/// - The lockfile mutex is poisoned.
97pub fn record_package(
98    pkg: &types::Package,
99    reason: &types::InstallReason,
100    _installed_dependencies: &[String],
101    registry_handle: &str,
102    repo_type: &str,
103    _chosen_options: &[String],
104    _chosen_optionals: &[String],
105    sub_package: Option<&str>
106) -> Result<()> {
107    let _lock = RECORD_MUTEX
108        .lock()
109        .map_err(|e| anyhow!("Mutex poisoned: {e}"))?;
110    let mut lockfile = read_lockfile(pkg.scope)?;
111
112    let package_key = if let Some(sub) = sub_package {
113        format!("@{}/{}:{}", pkg.repo.trim(), pkg.name.trim(), sub.trim())
114    } else {
115        format!("@{}/{}", pkg.repo.trim(), pkg.name.trim())
116    };
117
118    let os = std::env::consts::OS;
119    let arch = match std::env::consts::ARCH {
120        "x86_64" => "amd64",
121        "aarch64" => "arm64",
122        other => other
123    };
124    let platform = format!("{os}-{arch}");
125
126    let hash = compute_package_hash(pkg, registry_handle);
127
128    let detail = types::LockPackageDetailV2 {
129        name: pkg.name.clone(),
130        sub_package: sub_package.map(ToString::to_string),
131        repo: pkg.repo.clone(),
132        repo_type: repo_type.to_string(),
133        version: pkg.version.clone().unwrap_or_default(),
134        epoch: pkg.epoch,
135        revision: pkg.revision.clone(),
136        registry: registry_handle.to_string(),
137        why: match reason {
138            types::InstallReason::Direct => "direct".to_string(),
139            types::InstallReason::Dependency { .. } => "dependency".to_string()
140        },
141        description: pkg.description.clone(),
142        package_type_install: format!("{:?}", pkg.package_type).to_lowercase(),
143        install_method: if pkg.types.contains(&"source".to_string())
144            && !pkg.types.contains(&"pre-compiled".to_string())
145        {
146            "source".to_string()
147        } else {
148            "pre-compiled".to_string()
149        },
150        installed_sub_packages: sub_package
151            .map(|s| vec![s.to_string()])
152            .unwrap_or_default(),
153        platform,
154        hash,
155        dependencies: pkg.dependencies.clone().map(types::to_dependencies_v2)
156    };
157
158    lockfile.installed_packages.insert(package_key, detail);
159    lockfile.version = "2".to_string();
160
161    if !lockfile.registries.contains_key(registry_handle)
162        && let Some(reg_info) = resolve_registry_info(registry_handle)
163    {
164        lockfile
165            .registries
166            .insert(registry_handle.to_string(), reg_info);
167    }
168
169    write_lockfile(&mut lockfile, pkg.scope)
170}
171
172/// Calculates the current SHA-512 directory hash for an installed package
173/// version.
174///
175/// This is used to verify that the files in the store haven't been modified
176/// since they were originally staged.
177fn compute_package_hash(pkg: &types::Package, registry_handle: &str) -> String {
178    let Some(version) = &pkg.version else {
179        return String::new();
180    };
181    let Ok(store_base) = crate::utils::get_store_base_dir(pkg.scope) else {
182        return String::new();
183    };
184    let package_id = crate::utils::generate_package_id(
185        registry_handle,
186        &pkg.repo,
187        &pkg.name
188    );
189    let package_dir_name =
190        crate::utils::get_package_dir_name(&package_id, &pkg.name);
191    let version_dir = store_base.join(&package_dir_name).join(version);
192    if version_dir.exists() {
193        format!(
194            "sha512-{}",
195            crate::hash::calculate_dir_hash(&version_dir).unwrap_or_default()
196        )
197    } else {
198        String::new()
199    }
200}
201
202/// Resolves the registry information for a given registry handle.
203fn resolve_registry_info(
204    registry_handle: &str
205) -> Option<types::LockRegistryV2> {
206    let Ok(config) = crate::config::read_config() else {
207        return None;
208    };
209    let reg = config
210        .default_registry
211        .as_ref()
212        .filter(|r| r.handle == registry_handle)
213        .or_else(|| {
214            config
215                .added_registries
216                .iter()
217                .find(|r| r.handle == registry_handle)
218        })?;
219
220    let db_root = crate::utils::get_db_root().ok()?;
221    let reg_path = db_root.join(registry_handle);
222    let revision =
223        resolve_git_head(&reg_path).unwrap_or_else(|| "unknown".to_string());
224
225    Some(types::LockRegistryV2 {
226        url: reg.url.clone(),
227        revision
228    })
229}
230
231/// Resolves the current Git HEAD revision for a repository.
232fn resolve_git_head(repo_path: &Path) -> Option<String> {
233    let head_file = repo_path.join(".git").join("HEAD");
234    let content = fs::read_to_string(&head_file).ok()?;
235    let content = content.trim();
236    if let Some(ref_path) = content.strip_prefix("ref: ") {
237        let ref_file = repo_path.join(".git").join(ref_path);
238        fs::read_to_string(&ref_file)
239            .ok()
240            .map(|s| s.trim().to_string())
241    } else {
242        Some(content.to_string())
243    }
244}
245
246/// Updates the installation reason for a package in the lockfile.
247///
248/// # Errors
249///
250/// Returns an error if:
251/// - The lockfile cannot be read or written.
252/// - The lockfile mutex is poisoned.
253/// - The package is not found in the lockfile.
254pub fn update_package_reason(
255    manifest: &types::InstallManifest,
256    new_reason: &types::InstallReason
257) -> Result<()> {
258    let _lock = RECORD_MUTEX
259        .lock()
260        .map_err(|e| anyhow!("Mutex poisoned: {e}"))?;
261    let mut lockfile = read_lockfile(manifest.scope)?;
262    let repo = manifest.repo.trim();
263    let name = manifest.name.trim();
264
265    let package_key = if let Some(sub) = &manifest.sub_package {
266        format!("@{}/{}:{}", repo, name, sub.trim())
267    } else {
268        format!("@{repo}/{name}")
269    };
270
271    if let Some(pkg) = lockfile.installed_packages.get_mut(&package_key) {
272        pkg.why = match new_reason {
273            types::InstallReason::Direct => "direct".to_string(),
274            types::InstallReason::Dependency { .. } => "dependency".to_string()
275        };
276        lockfile.version = "2".to_string();
277        write_lockfile(&mut lockfile, manifest.scope)?;
278        Ok(())
279    } else {
280        Err(anyhow!("Package '{}' not found in record.", manifest.name))
281    }
282}
283
284/// Removes a package from the lockfile record.
285///
286/// # Errors
287///
288/// Returns an error if the lockfile cannot be read or written, or if the
289/// lockfile mutex is poisoned.
290pub fn remove_package_from_record(
291    manifest: &types::InstallManifest
292) -> Result<()> {
293    let _lock = RECORD_MUTEX
294        .lock()
295        .map_err(|e| anyhow!("Mutex poisoned: {e}"))?;
296    let mut lockfile = read_lockfile(manifest.scope)?;
297    let repo = manifest.repo.trim();
298    let name = manifest.name.trim();
299
300    let package_key = if let Some(sub) = &manifest.sub_package {
301        format!("@{}/{}:{}", repo, name, sub.trim())
302    } else {
303        format!("@{repo}/{name}")
304    };
305
306    if lockfile.installed_packages.remove(&package_key).is_some() {
307        lockfile.version = "2".to_string();
308        write_lockfile(&mut lockfile, manifest.scope)?;
309    }
310
311    Ok(())
312}
313
314/// Returns all recorded packages across all scopes.
315///
316/// # Errors
317///
318/// Returns an error if reading the lockfile for any scope fails.
319pub fn get_recorded_packages() -> Result<Vec<types::LockPackageDetailV2>> {
320    let mut all_packages = Vec::new();
321    for scope in [
322        types::Scope::User,
323        types::Scope::System,
324        types::Scope::Project
325    ] {
326        if let Ok(lockfile) = read_lockfile(scope) {
327            all_packages.extend(lockfile.installed_packages.into_values());
328        }
329    }
330    Ok(all_packages)
331}