Skip to main content

zoi_package/
doctor_system.rs

1/// Implements proactive "System Health Checks" for the Zoi environment.
2///
3/// These checks (run via `zoi doctor`) help users identify and fix:
4/// - Orphaned Packages: Dependencies no longer required by any package.
5/// - Broken Symlinks: Stale binary shims in the PATH.
6/// - Integrity Issues: Mismatches between the store and the lockfile.
7/// - Path Configuration: Missing Zoi binary directories in the host PATH.
8/// - Registry Staleness: Repositories that haven't been synced recently.
9use anyhow::{Result, anyhow};
10use rayon::prelude::*;
11use std::collections::{HashMap, HashSet};
12use std::fs;
13use std::path::PathBuf;
14use std::time::SystemTime;
15use walkdir::WalkDir;
16use zoi_core::config;
17use zoi_core::pgp;
18use zoi_core::recorder;
19use zoi_core::sysroot;
20use zoi_core::types::{InstallReason, Scope};
21use zoi_core::utils;
22use zoi_resolver::local;
23use zoi_resolver::resolve;
24
25fn get_bin_root(scope: Scope) -> Result<PathBuf> {
26    match scope {
27        Scope::User => {
28            let home_dir =
29                utils::get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
30            Ok(sysroot::apply_sysroot(home_dir.join(".zoi/pkgs/bin")))
31        }
32        Scope::System => {
33            if cfg!(target_os = "windows") {
34                Ok(sysroot::apply_sysroot(PathBuf::from(
35                    "C:\\ProgramData\\zoi\\pkgs\\bin",
36                )))
37            } else {
38                Ok(sysroot::apply_sysroot(PathBuf::from("/usr/local/bin")))
39            }
40        }
41        Scope::Project => {
42            let current_dir = std::env::current_dir()?;
43            Ok(current_dir.join(".zoi").join("pkgs").join("bin"))
44        }
45    }
46}
47
48pub fn check_broken_symlinks() -> Result<Vec<PathBuf>> {
49    let scopes = [Scope::User, Scope::System, Scope::Project];
50
51    let broken_links: Vec<PathBuf> = scopes
52        .into_par_iter()
53        .map(|scope| {
54            let mut links = Vec::new();
55            if let Ok(root) = get_bin_root(scope)
56                && root.exists()
57                && let Ok(entries) = fs::read_dir(root)
58            {
59                for entry in entries.flatten() {
60                    if let Ok(ft) = entry.file_type()
61                        && ft.is_symlink()
62                    {
63                        let path = entry.path();
64                        if !path.exists() {
65                            links.push(path);
66                        }
67                    }
68                }
69            }
70            links
71        })
72        .flatten()
73        .collect();
74
75    Ok(broken_links)
76}
77
78pub fn check_path_configuration() -> Result<Option<String>> {
79    if let Some(home) = utils::get_user_home() {
80        let zoi_bin_dir = sysroot::apply_sysroot(home.join(".zoi").join("pkgs").join("bin"));
81        if !zoi_bin_dir.exists() {
82            return Ok(None);
83        }
84
85        if let Ok(path_var) = std::env::var("PATH")
86            && !std::env::split_paths(&path_var).any(|p| p == zoi_bin_dir)
87        {
88            return Ok(Some(format!(
89                "Zoi's user binary directory ({}) is not in your PATH.",
90                zoi_bin_dir.display()
91            )));
92        }
93    }
94    Ok(None)
95}
96
97pub fn check_outdated_repos() -> Result<Option<String>> {
98    let db_root = sysroot::apply_sysroot(resolve::get_db_root()?);
99    let config = config::read_config()?;
100
101    if let Some(default_reg) = config.default_registry
102        && !default_reg.handle.is_empty()
103    {
104        let repo_path = db_root.join(default_reg.handle);
105        let fetch_head = repo_path.join(".git/FETCH_HEAD");
106        if fetch_head.exists() {
107            let metadata = fs::metadata(fetch_head)?;
108            if let Ok(modified) = metadata.modified()
109                && let Ok(since_modified) = SystemTime::now().duration_since(modified)
110                && since_modified.as_secs() > 60 * 60 * 24 * 7
111            {
112                return Ok(Some(format!(
113                    "Default repository has not been synced in over a week (last sync: {} days ago).",
114                    since_modified.as_secs() / (60 * 60 * 24)
115                )));
116            }
117        } else if repo_path.join(".git").exists() {
118            return Ok(Some(
119                "Default repository has never been synced.".to_string(),
120            ));
121        }
122    }
123
124    Ok(None)
125}
126
127pub fn check_duplicate_packages() -> Result<Vec<(String, Vec<String>)>> {
128    let db_root = sysroot::apply_sysroot(resolve::get_db_root()?);
129    if !db_root.exists() {
130        return Ok(Vec::new());
131    }
132
133    let mut package_map: HashMap<String, Vec<String>> = HashMap::new();
134
135    if let Ok(entries) = fs::read_dir(&db_root) {
136        for entry in entries.flatten() {
137            let registry_handle = entry.file_name().to_string_lossy().to_string();
138            if !entry.path().is_dir()
139                || registry_handle.starts_with('.')
140                || registry_handle == "git"
141            {
142                continue;
143            }
144
145            for pkg_entry in WalkDir::new(entry.path())
146                .into_iter()
147                .filter_map(|e| e.ok())
148                .filter(|e| e.file_name().to_string_lossy().ends_with(".pkg.lua"))
149            {
150                let pkg_path = pkg_entry.path();
151                if let Ok(rel_path) = pkg_path.strip_prefix(entry.path()) {
152                    let pkg_id = rel_path.to_string_lossy().to_string().replace('\\', "/");
153                    package_map
154                        .entry(pkg_id)
155                        .or_default()
156                        .push(registry_handle.clone());
157                }
158            }
159        }
160    }
161
162    let mut duplicates: Vec<_> = package_map
163        .into_iter()
164        .filter(|(_, registries)| registries.len() > 1)
165        .collect();
166    duplicates.sort_by(|a, b| a.0.cmp(&b.0));
167
168    Ok(duplicates)
169}
170
171pub fn check_pgp_configuration() -> Result<Vec<String>> {
172    let config = config::read_config()?;
173    let mut missing_keys = Vec::new();
174
175    if let Some(enforcement) = config.policy.signature_enforcement
176        && enforcement.enable
177    {
178        for key in enforcement.trusted_keys {
179            match pgp::get_certs_by_name_or_fingerprint(std::slice::from_ref(&key)) {
180                Ok(certs) if certs.is_empty() => {
181                    missing_keys.push(key);
182                }
183                Err(_) => {
184                    missing_keys.push(key);
185                }
186                _ => {}
187            }
188        }
189    }
190
191    Ok(missing_keys)
192}
193
194pub fn validate_lockfile_integrity() -> Result<Vec<String>> {
195    let recorded_packages = recorder::get_recorded_packages()?;
196
197    let missing_packages: Vec<String> = recorded_packages
198        .into_par_iter()
199        .filter_map(|pkg_record| {
200            let manifest = local::is_package_installed(
201                &pkg_record.name,
202                pkg_record.sub_package.as_deref(),
203                Scope::User,
204            )
205            .ok()
206            .flatten()
207            .or_else(|| {
208                local::is_package_installed(
209                    &pkg_record.name,
210                    pkg_record.sub_package.as_deref(),
211                    Scope::System,
212                )
213                .ok()
214                .flatten()
215            })
216            .or_else(|| {
217                local::is_package_installed(
218                    &pkg_record.name,
219                    pkg_record.sub_package.as_deref(),
220                    Scope::Project,
221                )
222                .ok()
223                .flatten()
224            });
225
226            if manifest.is_none() {
227                let name = if let Some(sub) = pkg_record.sub_package {
228                    format!("{}:{}", pkg_record.name, sub)
229                } else {
230                    pkg_record.name
231                };
232                Some(name)
233            } else {
234                None
235            }
236        })
237        .collect();
238
239    Ok(missing_packages)
240}
241
242pub fn check_orphaned_packages() -> Result<Vec<String>> {
243    let all_installed = local::get_installed_packages()?;
244
245    let orphaned: Vec<String> = all_installed
246        .into_par_iter()
247        .filter_map(|package| {
248            if !matches!(package.reason, InstallReason::Dependency { .. }) {
249                return None;
250            }
251
252            let package_dir = local::get_package_dir(
253                package.scope,
254                &package.registry_handle,
255                &package.repo,
256                &package.name,
257            )
258            .ok()?;
259
260            let dependents = local::get_dependents(&package_dir).ok()?;
261
262            if dependents.is_empty() {
263                let name = if let Some(sub) = package.sub_package {
264                    format!("{}:{}", package.name, sub)
265                } else {
266                    package.name
267                };
268                Some(name)
269            } else {
270                None
271            }
272        })
273        .collect();
274
275    Ok(orphaned)
276}
277
278pub fn check_ghost_dependents() -> Result<Vec<(PathBuf, String)>> {
279    let scopes = [Scope::User, Scope::System, Scope::Project];
280    let mut ghost_links = Vec::new();
281
282    let all_installed = local::get_installed_packages()?;
283    let mut installed_ids = HashSet::new();
284    for manifest in all_installed {
285        let full_id = format!(
286            "#{}@{}/{}@{}",
287            manifest.registry_handle, manifest.repo, manifest.name, manifest.version
288        );
289        installed_ids.insert(full_id);
290
291        if let Some(sub) = manifest.sub_package {
292            let full_id_sub = format!(
293                "#{}@{}/{}:{}@{}",
294                manifest.registry_handle, manifest.repo, manifest.name, sub, manifest.version
295            );
296            installed_ids.insert(full_id_sub);
297        }
298    }
299
300    for scope in scopes {
301        if let Ok(store_root) = local::get_store_base_dir(scope)
302            && store_root.exists()
303        {
304            for entry in fs::read_dir(store_root)? {
305                let path = entry?.path();
306                if path.is_dir() {
307                    let dependents_dir = path.join("dependents");
308                    if dependents_dir.exists() {
309                        for dep_entry in fs::read_dir(dependents_dir)? {
310                            let dep_path = dep_entry?.path();
311                            if dep_path.is_file()
312                                && let Some(file_name) =
313                                    dep_path.file_name().and_then(|s| s.to_str())
314                                && let Ok(decoded) = hex::decode(file_name)
315                                && let Ok(parent_id) = String::from_utf8(decoded)
316                                && !installed_ids.contains(&parent_id)
317                            {
318                                ghost_links.push((dep_path, parent_id));
319                            }
320                        }
321                    }
322                }
323            }
324        }
325    }
326
327    Ok(ghost_links)
328}
329
330pub fn prune_ghost_dependents(ghost_links: &[(PathBuf, String)]) -> Result<()> {
331    for (path, _) in ghost_links {
332        fs::remove_file(path)?;
333    }
334    Ok(())
335}
336
337pub struct ToolCheckResult {
338    pub essential_missing: Vec<String>,
339    pub recommended_missing: Vec<String>,
340}
341
342pub fn check_external_tools() -> ToolCheckResult {
343    let mut essential_missing = Vec::new();
344    let mut recommended_missing = Vec::new();
345
346    let essential = ["git"];
347    let recommended = ["gpg", "bwrap"];
348
349    for tool in essential {
350        if !utils::command_exists(tool) {
351            essential_missing.push(tool.to_string());
352        }
353    }
354
355    for tool in recommended {
356        if !utils::command_exists(tool) {
357            recommended_missing.push(tool.to_string());
358        }
359    }
360
361    ToolCheckResult {
362        essential_missing,
363        recommended_missing,
364    }
365}