1#[doc(hidden)]
2pub mod cargo;
3#[doc(hidden)]
4pub mod npm;
5#[doc(hidden)]
6pub mod pnpm;
7
8use std::path::Path;
9
10use serde::Deserialize;
11
12use crate::{Evaluation, Evidence, ExecutionError, Expected, Outcome, Status, Thresholds};
13
14#[doc(hidden)]
20pub fn run_and_check_root(
21 cmd: &str,
22 args: &[&str],
23 target: &Path,
24 extract_root_path: impl FnOnce(serde_json::Value) -> Option<String>,
25) -> bool {
26 let Ok(output) = std::process::Command::new(cmd)
27 .args(args)
28 .current_dir(target)
29 .output()
30 else {
31 return false;
32 };
33
34 if !output.status.success() {
35 return false;
36 }
37
38 let root_path = String::from_utf8(output.stdout)
39 .ok()
40 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
41 .and_then(extract_root_path);
42
43 let Some(canonical_target) = target.canonicalize().ok() else {
44 return false;
45 };
46
47 root_path
48 .as_deref()
49 .is_some_and(|root| Path::new(root) == canonical_target)
50}
51
52fn prefix_locations(deps: &mut [OutdatedDependency], prefix: &Path) {
53 if prefix.as_os_str().is_empty() {
54 return;
55 }
56 for dep in deps {
57 dep.location = dep
58 .location
59 .as_ref()
60 .map(|loc| format!("{}/{loc}", prefix.display()));
61 }
62}
63
64pub const CHECK_NAME: &str = "dependency-freshness";
65
66#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum Level {
69 Patch,
70 Minor,
71 #[default]
72 Major,
73}
74
75impl std::fmt::Display for Level {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 Self::Patch => f.write_str("patch"),
79 Self::Minor => f.write_str("minor"),
80 Self::Major => f.write_str("major"),
81 }
82 }
83}
84
85const DEFAULT_THRESHOLDS: Thresholds = Thresholds {
86 warn: None,
87 fail: Some(0),
88};
89
90const ZERO_TOLERANCE: Thresholds = Thresholds {
91 warn: None,
92 fail: Some(0),
93};
94
95#[derive(Debug, Default, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct Definition {
98 pub level: Option<Level>,
99 pub thresholds: Option<Thresholds>,
100}
101
102#[derive(Debug)]
103pub struct OutdatedDependency {
104 pub name: String,
105 pub current: semver::Version,
106 pub latest: semver::Version,
107 pub location: Option<String>,
108}
109
110impl OutdatedDependency {
111 #[must_use]
112 pub fn kind(&self) -> Level {
113 if self.current.major != self.latest.major {
114 Level::Major
115 } else if self.current.minor != self.latest.minor {
116 Level::Minor
117 } else {
118 Level::Patch
119 }
120 }
121
122 fn gap(&self) -> u64 {
123 match self.kind() {
124 Level::Major => self.latest.major.saturating_sub(self.current.major),
125 Level::Minor => self.latest.minor.saturating_sub(self.current.minor),
126 Level::Patch => self.latest.patch.saturating_sub(self.current.patch),
127 }
128 }
129
130 fn measure_gap(&self, level: Level, configured_thresholds: &Thresholds) -> (u64, Thresholds) {
131 use std::cmp::Ordering;
132 match self.kind().cmp(&level) {
133 Ordering::Greater => (self.gap(), ZERO_TOLERANCE),
134 Ordering::Equal => (self.gap(), configured_thresholds.clone()),
135 Ordering::Less => (0, configured_thresholds.clone()),
136 }
137 }
138
139 fn to_evidence(&self) -> Evidence {
140 Evidence {
141 rule: Some(format!("outdated-{}", self.kind())),
142 location: self.location.clone(),
143 found: format!("{} {}", self.name, self.current),
144 expected: Some(Expected::Text(self.latest.to_string())),
145 }
146 }
147}
148
149pub fn check(target: &Path, definition: &Definition) -> Result<Vec<Evaluation>, ExecutionError> {
158 let resolved = target.canonicalize().map_err(|_| ExecutionError {
159 code: "invalid_target".into(),
160 message: format!("path does not exist: {}", target.display()),
161 recovery: "provide a valid directory path".into(),
162 })?;
163
164 let outdated = fetch_outdated(&resolved).map_err(classify_error)?;
165
166 Ok(evaluate(&resolved, &outdated, definition))
167}
168
169#[derive(Debug)]
170pub enum FetchError {
171 InvalidTarget(String),
173 Failed(String),
175}
176
177impl std::fmt::Display for FetchError {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 match self {
180 Self::InvalidTarget(msg) => write!(f, "invalid target: {msg}"),
181 Self::Failed(msg) => write!(f, "fetch failed: {msg}"),
182 }
183 }
184}
185
186fn classify_error(err: FetchError) -> ExecutionError {
187 match err {
188 FetchError::InvalidTarget(msg) => ExecutionError {
189 code: "invalid_target".into(),
190 message: msg,
191 recovery: "check that you're pointing at a project root".into(),
192 },
193 FetchError::Failed(msg) => ExecutionError {
194 code: "tool_failed".into(),
195 message: msg,
196 recovery: "check network connectivity and project setup, then try again".into(),
197 },
198 }
199}
200
201#[doc(hidden)]
204pub trait PackageManager: Send + Sync {
205 fn is_project_root(&self, dir: &Path) -> bool;
208
209 fn fetch_outdated(&self, dir: &Path) -> Result<Vec<OutdatedDependency>, FetchError>;
211}
212
213struct Manifest {
215 dir: std::path::PathBuf,
216 pm: Box<dyn PackageManager>,
217}
218
219impl Manifest {
220 fn detect(path: &Path) -> Option<Self> {
221 let dir = path.parent()?.to_path_buf();
222 let pm: Box<dyn PackageManager> = match path.file_name()?.to_str()? {
223 "Cargo.toml" => Box::new(cargo::Cargo),
224 "package.json" if dir.join("pnpm-lock.yaml").exists() => Box::new(pnpm::Pnpm),
225 "package.json" if dir.join("package-lock.json").exists() => Box::new(npm::Npm),
226 _ => return None,
227 };
228 Some(Self { dir, pm })
229 }
230
231 fn is_project_root(&self) -> bool {
232 self.pm.is_project_root(&self.dir)
233 }
234
235 fn fetch_outdated(&self) -> Result<Vec<OutdatedDependency>, FetchError> {
236 self.pm.fetch_outdated(&self.dir)
237 }
238}
239
240fn collect_projects(target: &Path) -> Result<Vec<Manifest>, FetchError> {
241 let walker = ignore::WalkBuilder::new(target)
242 .standard_filters(true)
243 .build();
244
245 let manifests: Vec<_> = walker
246 .filter_map(Result::ok)
247 .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
248 .filter_map(|entry| Manifest::detect(entry.path()))
249 .collect();
250
251 std::thread::scope(|scope| {
252 let handles: Vec<_> = manifests
253 .into_iter()
254 .map(|manifest| {
255 let dir = manifest.dir.display().to_string();
256 (
257 scope.spawn(move || manifest.is_project_root().then_some(manifest)),
258 dir,
259 )
260 })
261 .collect();
262
263 let mut projects = Vec::new();
264 for (handle, dir) in handles {
265 match handle.join() {
266 Ok(Some(manifest)) => projects.push(manifest),
267 Ok(None) => {}
268 Err(_) => {
269 return Err(FetchError::Failed(format!(
270 "root detection failed for {dir}"
271 )));
272 }
273 }
274 }
275 Ok(projects)
276 })
277}
278
279fn diagnose_empty_discovery(target: &Path) -> FetchError {
280 let has_package_json = ignore::WalkBuilder::new(target)
281 .standard_filters(true)
282 .build()
283 .filter_map(Result::ok)
284 .any(|entry| entry.file_name() == "package.json");
285
286 if has_package_json {
287 FetchError::InvalidTarget(
288 "found package.json but no lock file — run `npm install` or `pnpm install` first"
289 .into(),
290 )
291 } else {
292 FetchError::InvalidTarget("no Cargo.toml or package.json found".into())
293 }
294}
295
296#[doc(hidden)]
304pub fn fetch_outdated(target: &Path) -> Result<Vec<OutdatedDependency>, FetchError> {
305 let projects = collect_projects(target)?;
306
307 if projects.is_empty() {
308 return Err(diagnose_empty_discovery(target));
309 }
310
311 let mut all_outdated = Vec::new();
312
313 for project in &projects {
314 let mut deps = project.fetch_outdated()?;
315 let prefix = project.dir.strip_prefix(target).unwrap_or(&project.dir);
316 prefix_locations(&mut deps, prefix);
317 all_outdated.extend(deps);
318 }
319
320 Ok(all_outdated)
321}
322
323fn evaluate(
324 target: &Path,
325 outdated: &[OutdatedDependency],
326 definition: &Definition,
327) -> Vec<Evaluation> {
328 let level = definition.level.unwrap_or_default();
329 let configured_thresholds = definition.thresholds.clone().unwrap_or(DEFAULT_THRESHOLDS);
330
331 if outdated.is_empty() {
332 return vec![Evaluation::completed(
333 target.display().to_string(),
334 0,
335 configured_thresholds,
336 vec![],
337 )];
338 }
339
340 outdated
341 .iter()
342 .map(|dependency| evaluate_dependency(dependency, level, &configured_thresholds))
343 .collect()
344}
345
346fn evaluate_dependency(
347 dependency: &OutdatedDependency,
348 level: Level,
349 configured_thresholds: &Thresholds,
350) -> Evaluation {
351 let (observed, effective_thresholds) = dependency.measure_gap(level, configured_thresholds);
352 let status = crate::derive_status(observed, &effective_thresholds);
353 let evidence = if status == Status::Pass {
354 vec![]
355 } else {
356 vec![dependency.to_evidence()]
357 };
358
359 Evaluation {
360 target: dependency.name.clone(),
361 outcome: Outcome::Completed {
362 status,
363 observed,
364 thresholds: effective_thresholds,
365 evidence,
366 },
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::{Expected, Status};
374 use googletest::prelude::*;
375 use test_case::test_case;
376
377 fn evaluate_all(deps: &[OutdatedDependency], definition: &Definition) -> Vec<Evaluation> {
378 evaluate(Path::new("/any"), deps, definition)
379 }
380
381 fn evaluate_one(dep: OutdatedDependency, definition: &Definition) -> Evaluation {
382 let mut evals = evaluate_all(&[dep], definition);
383 assert_eq!(evals.len(), 1, "expected exactly one evaluation");
384 evals.remove(0)
385 }
386
387 fn dep(name: &str, current: &str, latest: &str) -> OutdatedDependency {
388 OutdatedDependency {
389 name: name.into(),
390 current: current.parse().unwrap(),
391 latest: latest.parse().unwrap(),
392 location: None,
393 }
394 }
395
396 fn patch_level_with_thresholds(warn: u64, fail: u64) -> Definition {
397 Definition {
398 level: Some(Level::Patch),
399 thresholds: Some(Thresholds {
400 warn: Some(warn),
401 fail: Some(fail),
402 }),
403 }
404 }
405
406 fn major_level_with_thresholds(warn: u64, fail: u64) -> Definition {
407 Definition {
408 level: Some(Level::Major),
409 thresholds: Some(Thresholds {
410 warn: Some(warn),
411 fail: Some(fail),
412 }),
413 }
414 }
415
416 fn extract_status(outcome: &Outcome) -> Status {
417 match outcome {
418 Outcome::Completed { status, .. } => *status,
419 other @ Outcome::Errored(_) => panic!("expected Completed, got {other:?}"),
420 }
421 }
422
423 fn extract_observed(outcome: &Outcome) -> u64 {
424 match outcome {
425 Outcome::Completed { observed, .. } => *observed,
426 other @ Outcome::Errored(_) => panic!("expected Completed, got {other:?}"),
427 }
428 }
429
430 fn extract_evidence(outcome: &Outcome) -> &[Evidence] {
431 match outcome {
432 Outcome::Completed { evidence, .. } => evidence,
433 other @ Outcome::Errored(_) => panic!("expected Completed, got {other:?}"),
434 }
435 }
436
437 #[test]
438 fn single_major_dep_at_default_level_fails() {
439 let eval = evaluate_one(dep("a", "1.0.0", "2.0.0"), &Definition::default());
440
441 assert!(eval.is_fail());
442 }
443
444 #[test]
445 fn major_gap_is_observed_value() {
446 let eval = evaluate_one(dep("a", "1.0.0", "3.0.0"), &Definition::default());
447
448 assert_that!(extract_observed(&eval.outcome), eq(2));
449 }
450
451 #[test]
452 fn evaluation_target_is_dependency_name() {
453 let eval = evaluate_one(dep("serde", "1.0.0", "2.0.0"), &Definition::default());
454
455 assert_eq!(eval.target, "serde");
456 }
457
458 #[test_case("1.0.1", Status::Pass ; "below warn threshold passes")]
459 #[test_case("1.0.4", Status::Warn ; "between thresholds warns")]
460 #[test_case("1.0.8", Status::Fail ; "above fail threshold fails")]
461 fn same_level_gap_at_patch_level(latest: &str, expected: Status) {
462 let definition = patch_level_with_thresholds(2, 5);
463
464 let eval = evaluate_one(dep("a", "1.0.0", latest), &definition);
465
466 assert_eq!(extract_status(&eval.outcome), expected);
467 }
468
469 #[test]
470 fn passing_evaluation_has_no_evidence() {
471 let definition = patch_level_with_thresholds(2, 5);
472
473 let eval = evaluate_one(dep("a", "1.0.0", "1.0.1"), &definition);
474
475 assert_that!(extract_evidence(&eval.outcome), is_empty());
476 }
477
478 #[test]
479 fn non_passing_evidence_includes_rule_found_and_expected() {
480 let definition = patch_level_with_thresholds(2, 5);
481
482 let eval = evaluate_one(dep("serde", "1.0.0", "1.0.4"), &definition);
483
484 let evidence = &extract_evidence(&eval.outcome)[0];
485 assert_that!(evidence.rule, some(eq("outdated-patch")));
486 assert_eq!(evidence.found, "serde 1.0.0");
487 assert_eq!(evidence.expected, Some(Expected::Text("1.0.4".into())));
488 }
489
490 #[test]
491 fn superior_drift_fails_with_gap_at_superior_level() {
492 let definition = patch_level_with_thresholds(2, 5);
493
494 let eval = evaluate_one(dep("a", "1.0.1", "1.1.0"), &definition);
495
496 assert!(eval.is_fail());
497 assert_that!(extract_observed(&eval.outcome), eq(1));
498 assert_that!(
499 extract_evidence(&eval.outcome)[0].rule,
500 some(eq("outdated-minor"))
501 );
502 }
503
504 #[test]
505 fn kind_below_configured_level_passes_with_zero_observed() {
506 let definition = major_level_with_thresholds(1, 3);
507
508 let eval = evaluate_one(dep("a", "1.0.0", "1.0.5"), &definition);
509
510 assert!(eval.is_pass());
511 assert_that!(extract_observed(&eval.outcome), eq(0));
512 }
513
514 #[test]
515 fn no_outdated_deps_returns_passing_evaluation() {
516 let evals = evaluate_all(&[], &Definition::default());
517
518 assert_eq!(evals.len(), 1);
519 assert!(evals[0].is_pass());
520 }
521
522 #[test]
523 fn multiple_deps_return_one_evaluation_per_dep() {
524 let deps = [dep("a", "1.0.0", "2.0.0"), dep("b", "1.0.0", "3.0.0")];
525
526 let evals = evaluate_all(&deps, &Definition::default());
527
528 assert_eq!(evals.len(), 2);
529 assert_eq!(evals[0].target, "a");
530 assert_eq!(evals[1].target, "b");
531 }
532
533 #[test]
534 fn nonexistent_path_returns_invalid_target_error() {
535 let result = check(Path::new("/nonexistent/path"), &Definition::default());
536
537 assert!(result.is_err());
538 assert_eq!(result.unwrap_err().code, "invalid_target");
539 }
540
541 #[test_case(FetchError::InvalidTarget("msg".into()), "invalid_target" ; "invalid target")]
542 #[test_case(FetchError::Failed("msg".into()), "tool_failed" ; "failed")]
543 fn classify_error_maps_to_correct_code(err: FetchError, expected_code: &str) {
544 let result = classify_error(err);
545
546 assert_eq!(result.code, expected_code);
547 }
548
549 #[test]
550 fn mixed_levels_evaluate_independently() {
551 let deps = [dep("a", "1.0.0", "1.0.5"), dep("b", "1.0.0", "2.0.0")];
552 let definition = Definition {
553 level: Some(Level::Patch),
554 thresholds: Some(Thresholds {
555 warn: Some(2),
556 fail: Some(10),
557 }),
558 };
559
560 let evals = evaluate_all(&deps, &definition);
561
562 assert!(evals[0].is_warn(), "patch drift at patch level should warn");
563 assert!(
564 evals[1].is_fail(),
565 "major drift at patch level should fail (superior drift)"
566 );
567 }
568
569 #[test]
570 fn gap_saturates_on_downgrade() {
571 let d = dep("a", "2.0.0", "1.0.0");
572
573 assert_eq!(d.gap(), 0);
574 }
575
576 #[test]
577 fn evidence_carries_manifest_location() {
578 let definition = patch_level_with_thresholds(2, 5);
579 let d = OutdatedDependency {
580 name: "serde".into(),
581 current: "1.0.0".parse().unwrap(),
582 latest: "1.0.4".parse().unwrap(),
583 location: Some("crates/scute-mcp/Cargo.toml".into()),
584 };
585
586 let eval = evaluate_one(d, &definition);
587
588 let evidence = extract_evidence(&eval.outcome);
589 assert_eq!(
590 evidence[0].location.as_deref(),
591 Some("crates/scute-mcp/Cargo.toml")
592 );
593 }
594}