1use super::version::Version;
14use super::{DoctorFinding, DoctorSeverity};
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18use thiserror::Error;
19
20pub const CLASS: &str = "version-alignment";
21pub const PIN_SCHEMA_VERSION: u32 = 1;
22pub const HOST_CHECK: &str = "version-alignment.host";
23pub const REQUIRED_CLI_CHECK: &str = "version-alignment.required-cli";
24pub const ACCEPTANCE_BOUNDARY: &str = "version-number gate only; does not diff surfaces between tags or query a registry for newer releases";
25
26#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
28pub struct PinManifest {
29 pub schema_version: u32,
30 pub nils_cli: NilsCliPin,
31 #[serde(default)]
32 pub required_clis: Vec<RequiredCli>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
36pub struct NilsCliPin {
37 pub pinned_tag: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
42pub struct RequiredCli {
43 pub bin: String,
44 pub min: String,
45}
46
47pub struct AlignmentInputs<'a> {
49 pub manifest: &'a PinManifest,
50 pub host_raw: &'a str,
52 pub required_raw: &'a BTreeMap<String, String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57pub struct VersionAlignmentReport {
58 pub pinned_tag: String,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub host_observed: Option<String>,
61 pub items: Vec<AlignmentItem>,
62 pub findings: Vec<DoctorFinding>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub acceptance_boundary: Option<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct AlignmentItem {
69 pub check: &'static str,
70 pub target: String,
71 pub expected: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub observed: Option<String>,
74 pub severity: DoctorSeverity,
75}
76
77pub fn evaluate(inputs: &AlignmentInputs) -> VersionAlignmentReport {
79 let manifest = inputs.manifest;
80 let mut items = Vec::new();
81 let mut findings = Vec::new();
82
83 let pinned = Version::parse(&manifest.nils_cli.pinned_tag);
86 let host = Version::parse(inputs.host_raw);
87 let host_observed = host.map(|v| v.to_string());
88 let (host_severity, host_message) = match (pinned, host) {
89 (None, _) => (
90 DoctorSeverity::Block,
91 format!(
92 "pinned_tag `{}` is not a parseable version",
93 manifest.nils_cli.pinned_tag
94 ),
95 ),
96 (Some(_), None) => (
97 DoctorSeverity::Block,
98 format!(
99 "host version `{}` is not parseable; cannot verify alignment",
100 inputs.host_raw
101 ),
102 ),
103 (Some(pin), Some(found)) if found == pin => (
104 DoctorSeverity::Ok,
105 format!(
106 "host is aligned with pinned {}",
107 manifest.nils_cli.pinned_tag
108 ),
109 ),
110 (Some(_), Some(found)) => (
111 DoctorSeverity::Block,
112 format!(
113 "host {} drifted from pinned {}",
114 found, manifest.nils_cli.pinned_tag
115 ),
116 ),
117 };
118 items.push(AlignmentItem {
119 check: HOST_CHECK,
120 target: "agent-runtime".to_string(),
121 expected: manifest.nils_cli.pinned_tag.clone(),
122 observed: host_observed.clone(),
123 severity: host_severity,
124 });
125 if host_severity != DoctorSeverity::Ok {
126 findings.push(DoctorFinding::block(
127 "host",
128 HOST_CHECK,
129 None,
130 None,
131 host_message,
132 ));
133 }
134
135 for cli in &manifest.required_clis {
137 let raw = inputs
138 .required_raw
139 .get(&cli.bin)
140 .map(String::as_str)
141 .unwrap_or("");
142 let min = Version::parse(&cli.min);
143 let observed = Version::parse(raw);
144 let observed_str = observed.map(|v| v.to_string());
145 let (severity, message) = match (min, observed) {
146 (None, _) => (
147 DoctorSeverity::Block,
148 format!(
149 "required_clis[{}].min `{}` is not a parseable version",
150 cli.bin, cli.min
151 ),
152 ),
153 (Some(_), None) => (
154 DoctorSeverity::Block,
155 format!(
156 "{} not found on PATH or `--version` unparseable: {:?}",
157 cli.bin, raw
158 ),
159 ),
160 (Some(floor), Some(found)) if found >= floor => (
161 DoctorSeverity::Ok,
162 format!("{} {} meets floor {}", cli.bin, found, cli.min),
163 ),
164 (Some(_), Some(found)) => (
165 DoctorSeverity::Block,
166 format!("{} {} is below required floor {}", cli.bin, found, cli.min),
167 ),
168 };
169 items.push(AlignmentItem {
170 check: REQUIRED_CLI_CHECK,
171 target: cli.bin.clone(),
172 expected: cli.min.clone(),
173 observed: observed_str,
174 severity,
175 });
176 if severity != DoctorSeverity::Ok {
177 findings.push(DoctorFinding::block(
178 &cli.bin,
179 REQUIRED_CLI_CHECK,
180 None,
181 None,
182 message,
183 ));
184 }
185 }
186
187 VersionAlignmentReport {
188 pinned_tag: manifest.nils_cli.pinned_tag.clone(),
189 host_observed,
190 items,
191 findings,
192 acceptance_boundary: Some(ACCEPTANCE_BOUNDARY.to_string()),
193 }
194}
195
196#[derive(Debug, Error)]
197pub enum VersionAlignmentError {
198 #[error("--class version-alignment requires --pin <manifest>")]
199 MissingPin,
200 #[error("missing pin manifest: {path}")]
201 Missing { path: PathBuf },
202 #[error("io error reading {path}: {source}")]
203 Io {
204 path: PathBuf,
205 #[source]
206 source: std::io::Error,
207 },
208 #[error("parse error in {path}: {source}")]
209 Parse {
210 path: PathBuf,
211 #[source]
212 source: serde_yaml_ng::Error,
213 },
214 #[error("schema_version mismatch in {path}: expected {expected}, got {found}")]
215 SchemaVersion {
216 path: PathBuf,
217 expected: u32,
218 found: u32,
219 },
220}
221
222pub fn check(
226 pin_path: &Path,
227 host_version: &str,
228) -> Result<VersionAlignmentReport, VersionAlignmentError> {
229 if !pin_path.exists() {
230 return Err(VersionAlignmentError::Missing {
231 path: pin_path.to_path_buf(),
232 });
233 }
234 let raw = std::fs::read_to_string(pin_path).map_err(|source| VersionAlignmentError::Io {
235 path: pin_path.to_path_buf(),
236 source,
237 })?;
238 let manifest: PinManifest =
239 serde_yaml_ng::from_str(&raw).map_err(|source| VersionAlignmentError::Parse {
240 path: pin_path.to_path_buf(),
241 source,
242 })?;
243 if manifest.schema_version != PIN_SCHEMA_VERSION {
244 return Err(VersionAlignmentError::SchemaVersion {
245 path: pin_path.to_path_buf(),
246 expected: PIN_SCHEMA_VERSION,
247 found: manifest.schema_version,
248 });
249 }
250
251 let mut required_raw = BTreeMap::new();
252 for cli in &manifest.required_clis {
253 let raw = super::version::run_probe_command(&format!("{} --version", cli.bin));
254 required_raw.insert(cli.bin.clone(), raw);
255 }
256
257 Ok(evaluate(&AlignmentInputs {
258 manifest: &manifest,
259 host_raw: host_version,
260 required_raw: &required_raw,
261 }))
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn manifest(pinned: &str, required: &[(&str, &str)]) -> PinManifest {
269 PinManifest {
270 schema_version: PIN_SCHEMA_VERSION,
271 nils_cli: NilsCliPin {
272 pinned_tag: pinned.to_string(),
273 },
274 required_clis: required
275 .iter()
276 .map(|(bin, min)| RequiredCli {
277 bin: bin.to_string(),
278 min: min.to_string(),
279 })
280 .collect(),
281 }
282 }
283
284 fn eval(m: &PinManifest, host_raw: &str, reqs: &[(&str, &str)]) -> VersionAlignmentReport {
285 let required_raw: BTreeMap<String, String> = reqs
286 .iter()
287 .map(|(bin, raw)| (bin.to_string(), raw.to_string()))
288 .collect();
289 evaluate(&AlignmentInputs {
290 manifest: m,
291 host_raw,
292 required_raw: &required_raw,
293 })
294 }
295
296 #[test]
297 fn aligned_host_is_ok() {
298 let m = manifest("v0.17.7", &[]);
299 let report = eval(&m, "agent-runtime 0.17.7 (v0.17.7, rustc 1.96.0)", &[]);
300 assert_eq!(report.items.len(), 1);
301 assert_eq!(report.items[0].check, HOST_CHECK);
302 assert_eq!(report.items[0].severity, DoctorSeverity::Ok);
303 assert!(
304 report.findings.is_empty(),
305 "findings: {:?}",
306 report.findings
307 );
308 }
309
310 #[test]
311 fn host_ahead_of_pin_blocks() {
312 let m = manifest("v0.17.6", &[]);
314 let report = eval(&m, "agent-runtime 0.17.7 (v0.17.7)", &[]);
315 assert_eq!(report.items[0].severity, DoctorSeverity::Block);
316 assert_eq!(report.findings.len(), 1);
317 assert_eq!(report.findings[0].severity, DoctorSeverity::Block);
318 assert_eq!(report.findings[0].check, HOST_CHECK);
319 }
320
321 #[test]
322 fn host_behind_pin_blocks() {
323 let m = manifest("v0.18.0", &[]);
324 let report = eval(&m, "agent-runtime 0.17.7", &[]);
325 assert_eq!(report.items[0].severity, DoctorSeverity::Block);
326 assert_eq!(report.findings.len(), 1);
327 }
328
329 #[test]
330 fn host_unparseable_blocks_fail_closed() {
331 let m = manifest("v0.17.7", &[]);
332 let report = eval(&m, "agent-runtime (no version available)", &[]);
333 assert_eq!(report.items[0].severity, DoctorSeverity::Block);
334 }
335
336 #[test]
337 fn required_cli_meets_min_is_ok() {
338 let m = manifest("v0.17.7", &[("plan-issue", "0.17.4")]);
339 let report = eval(
340 &m,
341 "0.17.7",
342 &[("plan-issue", "plan-issue 0.17.7 (v0.17.7)")],
343 );
344 assert_eq!(report.items.len(), 2);
345 assert!(
346 report
347 .items
348 .iter()
349 .all(|i| i.severity == DoctorSeverity::Ok),
350 "items: {:?}",
351 report.items
352 );
353 assert!(report.findings.is_empty());
354 }
355
356 #[test]
357 fn required_cli_below_min_blocks() {
358 let m = manifest("v0.17.7", &[("forge-cli", "0.16.0")]);
359 let report = eval(&m, "0.17.7", &[("forge-cli", "forge-cli 0.15.0 (v0.15.0)")]);
360 let item = report
361 .items
362 .iter()
363 .find(|i| i.target == "forge-cli")
364 .expect("forge-cli item present");
365 assert_eq!(item.check, REQUIRED_CLI_CHECK);
366 assert_eq!(item.severity, DoctorSeverity::Block);
367 assert!(report.findings.iter().any(|f| f.product == "forge-cli"));
368 }
369
370 #[test]
371 fn required_cli_missing_blocks() {
372 let m = manifest("v0.17.7", &[("ghost-cli", "0.1.0")]);
373 let report = eval(
374 &m,
375 "0.17.7",
376 &[(
377 "ghost-cli",
378 "failed to run `ghost-cli --version`: No such file or directory (os error 2)",
379 )],
380 );
381 let item = report
382 .items
383 .iter()
384 .find(|i| i.target == "ghost-cli")
385 .expect("ghost-cli item present");
386 assert_eq!(item.severity, DoctorSeverity::Block);
387 }
388}