bijux_cli/features/plugins/
diagnostics.rs1#![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
147pub 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
162pub 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
175pub 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
195pub 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}