Skip to main content

bijux_cli/features/plugins/
diagnostics.rs

1#![forbid(unsafe_code)]
2
3use std::fs;
4use std::path::Path;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use sha2::Digest;
8
9use crate::contracts::PluginKind;
10
11use super::entrypoint::{
12    delegated_entrypoint_candidates, installed_manifest_root, is_executable,
13    resolve_delegated_entrypoint, resolve_external_exec_entrypoint,
14};
15use super::errors::PluginError;
16use super::manifest::is_version_compatible;
17use super::models::PluginLoadDiagnostic;
18use super::registry::{load_registry, save_registry};
19use super::runtime::detected_python_interpreters;
20
21fn manifest_anchor_diagnostics(
22    record: &super::models::PluginRecord,
23) -> Result<Vec<PluginLoadDiagnostic>, PluginError> {
24    let Some(manifest_path) = record.manifest_path.as_deref() else {
25        return Ok(Vec::new());
26    };
27    let path = Path::new(manifest_path);
28    let mut diagnostics = Vec::new();
29    match fs::read_to_string(path) {
30        Ok(text) => {
31            let digest = sha2::Sha256::digest(text.as_bytes());
32            let current_checksum = format!("{digest:x}");
33            if current_checksum != record.manifest_checksum_sha256 {
34                diagnostics.push(PluginLoadDiagnostic {
35                    namespace: record.manifest.namespace.0.clone(),
36                    severity: "error".to_string(),
37                    message: "manifest file drifted since install".to_string(),
38                });
39            }
40        }
41        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
42            diagnostics.push(PluginLoadDiagnostic {
43                namespace: record.manifest.namespace.0.clone(),
44                severity: "error".to_string(),
45                message: "manifest file was not found".to_string(),
46            });
47        }
48        Err(error) => return Err(error.into()),
49    }
50
51    Ok(diagnostics)
52}
53
54fn record_load_diagnostics(
55    record: &super::models::PluginRecord,
56    host_version: &str,
57) -> Result<Vec<PluginLoadDiagnostic>, PluginError> {
58    let namespace = record.manifest.namespace.0.clone();
59    let mut diagnostics = manifest_anchor_diagnostics(record)?;
60    let python_runtimes = detected_python_interpreters();
61
62    if record.state == crate::contracts::PluginLifecycleState::Broken {
63        diagnostics.push(PluginLoadDiagnostic {
64            namespace: namespace.clone(),
65            severity: "error".to_string(),
66            message: "plugin is marked broken".to_string(),
67        });
68    }
69
70    if !is_version_compatible(&record.manifest.compatibility, host_version)? {
71        diagnostics.push(PluginLoadDiagnostic {
72            namespace: namespace.clone(),
73            severity: "warning".to_string(),
74            message: format!("plugin compatibility does not include host {host_version}"),
75        });
76    }
77
78    if record.manifest.kind == PluginKind::ExternalExec
79        && !resolve_external_exec_entrypoint(
80            record.manifest_path.as_deref(),
81            &record.source,
82            &record.manifest.entrypoint,
83        )
84        .exists()
85    {
86        diagnostics.push(PluginLoadDiagnostic {
87            namespace: namespace.clone(),
88            severity: "error".to_string(),
89            message: "external-exec entrypoint was not found".to_string(),
90        });
91    }
92
93    if record.manifest.kind == PluginKind::ExternalExec {
94        let path = resolve_external_exec_entrypoint(
95            record.manifest_path.as_deref(),
96            &record.source,
97            &record.manifest.entrypoint,
98        );
99        if path.exists() && !is_executable(&path)? {
100            diagnostics.push(PluginLoadDiagnostic {
101                namespace: namespace.clone(),
102                severity: "error".to_string(),
103                message: "external-exec entrypoint is not executable".to_string(),
104            });
105        }
106    }
107
108    if matches!(record.manifest.kind, PluginKind::Delegated | PluginKind::Python)
109        && python_runtimes.iter().all(|candidate| !candidate.supported)
110    {
111        let message = if let Some(found) = python_runtimes.first() {
112            format!(
113                "python 3.11 or newer is required for delegated/python plugins; found {} ({})",
114                found.command, found.version
115            )
116        } else {
117            "python 3.11 or newer is required for delegated/python plugins".to_string()
118        };
119        diagnostics.push(PluginLoadDiagnostic {
120            namespace: namespace.clone(),
121            severity: "error".to_string(),
122            message,
123        });
124    }
125
126    if matches!(record.manifest.kind, PluginKind::Delegated | PluginKind::Python)
127        && resolve_delegated_entrypoint(
128            record.manifest_path.as_deref(),
129            &record.source,
130            &record.manifest.entrypoint,
131        )
132        .is_none()
133        && installed_manifest_root(record.manifest_path.as_deref(), &record.source).is_some_and(
134            |root| !delegated_entrypoint_candidates(&root, &record.manifest.entrypoint).is_empty(),
135        )
136    {
137        diagnostics.push(PluginLoadDiagnostic {
138            namespace,
139            severity: "error".to_string(),
140            message: "delegated entrypoint was not found".to_string(),
141        });
142    }
143
144    Ok(diagnostics)
145}
146
147/// Generate load-time diagnostics for broken or incompatible plugins.
148pub fn load_time_diagnostics(
149    registry_path: &Path,
150    host_version: &str,
151) -> Result<Vec<PluginLoadDiagnostic>, PluginError> {
152    let registry = load_registry(registry_path)?;
153    let mut diagnostics = Vec::new();
154
155    for record in registry.plugins.values() {
156        diagnostics.extend(record_load_diagnostics(record, host_version)?);
157    }
158
159    Ok(diagnostics)
160}
161
162/// Return compatibility warnings for plugin surfaces.
163pub fn compatibility_warnings(
164    registry_path: &Path,
165    host_version: &str,
166) -> Result<Vec<String>, PluginError> {
167    let diagnostics = load_time_diagnostics(registry_path, host_version)?;
168    Ok(diagnostics
169        .into_iter()
170        .filter(|diagnostic| diagnostic.severity == "warning")
171        .map(|diagnostic| format!("{}: {}", diagnostic.namespace, diagnostic.message))
172        .collect())
173}
174
175/// Try to repair a corrupted registry by quarantining the old file and writing a fresh empty registry.
176pub fn self_repair_registry(path: &Path) -> Result<super::models::PluginRegistry, PluginError> {
177    match load_registry(path) {
178        Ok(registry) => Ok(registry),
179        Err(PluginError::RegistryCorrupted) => {
180            if path.exists() {
181                let timestamp = SystemTime::now()
182                    .duration_since(UNIX_EPOCH)
183                    .map_or(0, |duration| duration.as_secs());
184                let quarantine = path.with_extension(format!("corrupt-{timestamp}.json"));
185                fs::rename(path, quarantine)?;
186            }
187            let repaired = super::models::PluginRegistry::default();
188            save_registry(path, &repaired)?;
189            Ok(repaired)
190        }
191        Err(error) => Err(error),
192    }
193}
194
195/// Remove stale transactional backup file left next to registry path.
196pub fn prune_registry_backup(path: &Path) -> Result<bool, PluginError> {
197    let backup = path.with_extension("bak");
198    if !backup.exists() {
199        return Ok(false);
200    }
201    fs::remove_file(backup)?;
202    Ok(true)
203}