Skip to main content

scute_core/dependency_freshness/
mod.rs

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/// Shared root-detection logic for package managers that shell out to a CLI,
15/// parse JSON, extract a root path, and compare it to `target`.
16///
17/// `extract_root_path` receives the parsed JSON and should return the root
18/// directory as a string, or `None` if the field is missing.
19#[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
149/// Run the dependency-freshness check against a project directory.
150///
151/// Discovers supported package managers (Cargo, npm, pnpm) and checks each one found.
152///
153/// # Errors
154///
155/// Returns `Err` when the target path doesn't exist, no supported project
156/// is found, or the dependency data can't be fetched.
157pub 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    /// Target path doesn't contain a valid project.
172    InvalidTarget(String),
173    /// Something else went wrong. The String carries details for debugging.
174    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/// A package manager that can detect project roots and fetch outdated
202/// dependencies.
203#[doc(hidden)]
204pub trait PackageManager: Send + Sync {
205    /// Returns true for standalone projects and workspace roots, false for
206    /// workspace members whose root is an ancestor.
207    fn is_project_root(&self, dir: &Path) -> bool;
208
209    /// Returns dependencies with locations relative to `dir`.
210    fn fetch_outdated(&self, dir: &Path) -> Result<Vec<OutdatedDependency>, FetchError>;
211}
212
213/// A discovered manifest file paired with its package manager.
214struct 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/// Walk `target` for supported package managers, identify project roots,
297/// and collect outdated dependencies from each one.
298///
299/// Dependency locations are prefixed with the project's relative path
300/// from `target`, so callers always get target-relative paths.
301///
302/// Fails fast: if any project root errors out, the whole call fails.
303#[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}