Skip to main content

crate_seq_core/
check.rs

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