Skip to main content

crate_seq_core/
check.rs

1//! `check_crate`: diff local ledger state against the live crates.io registry.
2
3#[cfg(test)]
4#[path = "check_tests.rs"]
5mod tests;
6
7use std::path::{Path, PathBuf};
8
9use crate_seq_ledger::{detect_tag_pattern, load, LedgerEntry, LedgerStatus, VersionSource};
10use crate_seq_registry::CratesIoClient;
11
12use crate::Error;
13
14/// Orphaned entry: a `Pending` ledger entry whose backing ref is absent.
15///
16/// For `GitTag` entries the tag does not exist in git. For `Snapshot` entries
17/// no tarball with the recorded SHA-256 exists in the snapshot store.
18#[derive(Debug, Clone)]
19pub struct OrphanedEntry {
20    /// Version of the orphaned entry.
21    pub version: semver::Version,
22    /// The tag ref recorded in the ledger (which does not exist).
23    pub recorded_ref: String,
24}
25
26/// Report from diffing the local ledger against the live crates.io state.
27#[derive(Debug)]
28pub struct CheckReport {
29    /// Crate name.
30    pub crate_name: String,
31    /// The version currently published as latest on crates.io. `None` if never published.
32    pub registry_latest: Option<semver::Version>,
33    /// Pending versions (sorted ascending).
34    pub pending: Vec<LedgerEntry>,
35    /// Skipped versions.
36    pub skipped: Vec<LedgerEntry>,
37    /// Yanked versions.
38    pub yanked: Vec<LedgerEntry>,
39    /// Pending entries whose backing ref does not exist.
40    pub orphaned: Vec<OrphanedEntry>,
41}
42
43/// Resolves the tag pattern from ledger settings or auto-detection.
44fn resolve_tag_pattern(settings: &crate_seq_ledger::LedgerSettings, crate_name: &str) -> String {
45    settings
46        .tag_pattern
47        .clone()
48        .unwrap_or_else(|| detect_tag_pattern(crate_name, false))
49}
50
51/// Finds the highest non-yanked version from registry metadata.
52fn registry_latest(
53    metadata: Option<&crate_seq_registry::CrateMetadata>,
54) -> Option<semver::Version> {
55    metadata?
56        .versions
57        .iter()
58        .filter(|v| !v.yanked)
59        .map(|v| &v.version)
60        .max()
61        .cloned()
62}
63
64/// Collects entries filtered by status, sorted ascending.
65fn entries_with_status(entries: &[LedgerEntry], status: LedgerStatus) -> Vec<LedgerEntry> {
66    let mut out: Vec<LedgerEntry> = entries
67        .iter()
68        .filter(|e| e.status == status)
69        .cloned()
70        .collect();
71    out.sort_by(|a, b| a.version.cmp(&b.version));
72    out
73}
74
75/// Returns `true` if a tarball matching `sha256` exists in `snapshot_store`.
76fn snapshot_hash_exists(sha256: &str, snapshot_store: &Path) -> bool {
77    let Ok(entries) = std::fs::read_dir(snapshot_store) else {
78        return false;
79    };
80    for entry in entries.flatten() {
81        let path = entry.path();
82        if path.extension().and_then(|e| e.to_str()) != Some("gz") {
83            continue;
84        }
85        if matches!(crate_seq_snapshot::hash_tarball(&path), Ok(h) if h == sha256) {
86            return true;
87        }
88    }
89    false
90}
91
92/// Identifies pending entries whose backing ref is absent.
93///
94/// `GitTag` entries are checked against `git_tag_names`. `Snapshot` entries are
95/// checked by scanning `snapshot_store` for a matching SHA-256.
96fn find_orphaned(
97    pending: &[LedgerEntry],
98    git_tag_names: &std::collections::HashSet<String>,
99    snapshot_store: &Path,
100) -> Vec<OrphanedEntry> {
101    pending
102        .iter()
103        .filter(|e| match e.source {
104            VersionSource::GitTag => !git_tag_names.contains(&e.ref_),
105            VersionSource::Snapshot => !snapshot_hash_exists(&e.ref_, snapshot_store),
106        })
107        .map(|e| OrphanedEntry {
108            version: e.version.clone(),
109            recorded_ref: e.ref_.clone(),
110        })
111        .collect()
112}
113
114/// Returns the default snapshot store adjacent to the ledger file.
115fn default_snapshot_store(ledger_path: &Path) -> PathBuf {
116    ledger_path
117        .parent()
118        .unwrap_or_else(|| Path::new("."))
119        .join(".crate-seq-snapshots")
120}
121
122/// Diffs the ledger at `ledger_path` against the live crates.io registry.
123///
124/// Queries crates.io and git; does not modify any state. `snapshot_store`
125/// overrides the default `.crate-seq-snapshots/` directory used when checking
126/// for orphaned snapshot entries.
127///
128/// # Errors
129///
130/// Returns [`Error::Ledger`] if the ledger cannot be loaded, [`Error::Registry`]
131/// on crates.io HTTP failure, or [`Error::Git`] on git discovery failure.
132pub fn check_crate(
133    ledger_path: &Path,
134    repo_path: &Path,
135    crate_seq_version: &str,
136    snapshot_store: Option<PathBuf>,
137) -> Result<CheckReport, Error> {
138    let client = CratesIoClient::new(crate_seq_version)?;
139    check_crate_with_client(ledger_path, repo_path, &client, snapshot_store)
140}
141
142/// Inner implementation that accepts an injected `CratesIoClient`.
143///
144/// Separated from `check_crate` to allow tests to inject a mock registry client
145/// without hitting the live crates.io API.
146pub(crate) fn check_crate_with_client(
147    ledger_path: &Path,
148    repo_path: &Path,
149    client: &CratesIoClient,
150    snapshot_store: Option<PathBuf>,
151) -> Result<CheckReport, Error> {
152    let ledger = load(ledger_path)?;
153    let crate_name = ledger.crate_config.name.clone();
154    let tag_pattern = resolve_tag_pattern(&ledger.settings, &crate_name);
155
156    let metadata = client.fetch_crate_metadata(&crate_name)?;
157
158    let tags = crate_seq_git::discover_tags(repo_path, &tag_pattern)?;
159    let tag_names: std::collections::HashSet<String> = tags.into_iter().map(|t| t.name).collect();
160
161    let store = snapshot_store.unwrap_or_else(|| default_snapshot_store(ledger_path));
162
163    let pending = entries_with_status(&ledger.entries, LedgerStatus::Pending);
164    let skipped = entries_with_status(&ledger.entries, LedgerStatus::Skipped);
165    let yanked = entries_with_status(&ledger.entries, LedgerStatus::Yanked);
166    let orphaned = find_orphaned(&pending, &tag_names, &store);
167
168    Ok(CheckReport {
169        crate_name,
170        registry_latest: registry_latest(metadata.as_ref()),
171        pending,
172        skipped,
173        yanked,
174        orphaned,
175    })
176}