Skip to main content

batuta/stack/publish_status/
scanner.rs

1//! Publish status scanner implementation.
2//!
3//! Scans workspace for PAIML crates and returns publish status with caching.
4
5use anyhow::Result;
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use super::cache::{compute_cache_key, PublishStatusCache};
10use super::git::{determine_action, get_git_status, get_local_version};
11use super::types::{CacheEntry, CrateStatus, GitStatus, PublishAction, PublishStatusReport};
12use crate::stack::PAIML_CRATES;
13
14// ============================================================================
15// PUB-006: Scanner Implementation
16// ============================================================================
17
18/// Scan workspace for PAIML crates and return publish status
19pub struct PublishStatusScanner {
20    /// Workspace root (parent of crate directories)
21    pub(crate) workspace_root: PathBuf,
22    /// Cache
23    pub(crate) cache: PublishStatusCache,
24    /// crates.io client (for async fetches)
25    #[cfg(feature = "native")]
26    pub(crate) crates_io: Option<crate::stack::crates_io::CratesIoClient>,
27}
28
29impl PublishStatusScanner {
30    /// Create scanner for workspace
31    #[must_use]
32    pub fn new(workspace_root: PathBuf) -> Self {
33        Self {
34            workspace_root,
35            cache: PublishStatusCache::load(),
36            #[cfg(feature = "native")]
37            crates_io: None,
38        }
39    }
40
41    /// Initialize crates.io client
42    #[cfg(feature = "native")]
43    pub fn with_crates_io(mut self) -> Self {
44        self.crates_io =
45            Some(crate::stack::crates_io::CratesIoClient::new().with_persistent_cache());
46        self
47    }
48
49    /// Find all PAIML crate directories in workspace
50    #[must_use]
51    pub fn find_crate_dirs(&self) -> Vec<(String, PathBuf)> {
52        PAIML_CRATES
53            .iter()
54            .filter_map(|name| {
55                let path = self.workspace_root.join(name);
56                if path.join("Cargo.toml").exists() {
57                    Some(((*name).to_string(), path))
58                } else {
59                    None
60                }
61            })
62            .collect()
63    }
64
65    /// Check single crate status (with cache)
66    pub fn check_crate(&mut self, name: &str, path: &Path) -> CrateStatus {
67        // Compute cache key
68        let cache_key = match compute_cache_key(path) {
69            Ok(key) => key,
70            Err(e) => {
71                return CrateStatus {
72                    name: name.to_string(),
73                    local_version: None,
74                    crates_io_version: None,
75                    git_status: GitStatus::default(),
76                    action: PublishAction::Error,
77                    path: path.to_path_buf(),
78                    error: Some(e.to_string()),
79                };
80            }
81        };
82
83        // Check cache
84        if let Some(entry) = self.cache.get(name, &cache_key) {
85            if !entry.is_crates_io_stale() {
86                // Cache hit - O(1)
87                return entry.status.clone();
88            }
89        }
90
91        // Cache miss - need to refresh
92        self.refresh_crate(name, path, &cache_key)
93    }
94
95    /// Refresh crate status (cache miss path)
96    pub(crate) fn refresh_crate(
97        &mut self,
98        name: &str,
99        path: &Path,
100        cache_key: &str,
101    ) -> CrateStatus {
102        let local_version = get_local_version(path).ok();
103        let git_status = get_git_status(path).unwrap_or_default();
104
105        // crates.io version fetched separately (async)
106        let crates_io_version = None; // Will be filled by async scan
107
108        let action =
109            determine_action(local_version.as_deref(), crates_io_version.as_deref(), &git_status);
110
111        let status = CrateStatus {
112            name: name.to_string(),
113            local_version,
114            crates_io_version,
115            git_status,
116            action,
117            path: path.to_path_buf(),
118            error: None,
119        };
120
121        // Update cache
122        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
123
124        self.cache.insert(
125            name.to_string(),
126            CacheEntry {
127                cache_key: cache_key.to_string(),
128                status: status.clone(),
129                crates_io_checked_at: now,
130                created_at: now,
131            },
132        );
133
134        status
135    }
136
137    /// Scan all crates and return report
138    #[cfg(feature = "native")]
139    pub async fn scan(&mut self) -> Result<PublishStatusReport> {
140        use std::time::Instant;
141
142        let start = Instant::now();
143        let crate_dirs = self.find_crate_dirs();
144        let mut statuses = Vec::with_capacity(crate_dirs.len());
145        let mut cache_hits = 0;
146
147        // First pass: check cache and collect statuses
148        for (name, path) in &crate_dirs {
149            let cache_key = compute_cache_key(path).unwrap_or_default();
150
151            if let Some(entry) = self.cache.get(name, &cache_key) {
152                if !entry.is_crates_io_stale() {
153                    cache_hits += 1;
154                    statuses.push(entry.status.clone());
155                    continue;
156                }
157            }
158
159            // Need refresh - get local info first
160            let mut status = self.refresh_crate(name, path, &cache_key);
161
162            // Fetch crates.io version
163            if let Some(ref mut client) = self.crates_io {
164                if let Ok(response) = client.get_crate(name).await {
165                    status.crates_io_version = Some(response.krate.max_version.clone());
166                    status.action = determine_action(
167                        status.local_version.as_deref(),
168                        status.crates_io_version.as_deref(),
169                        &status.git_status,
170                    );
171
172                    // Update cache with crates.io version
173                    let now =
174                        SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
175
176                    self.cache.insert(
177                        name.clone(),
178                        CacheEntry {
179                            cache_key: cache_key.clone(),
180                            status: status.clone(),
181                            crates_io_checked_at: now,
182                            created_at: now,
183                        },
184                    );
185                }
186            }
187
188            statuses.push(status);
189        }
190
191        // Save cache
192        let _ = self.cache.save();
193
194        let elapsed_ms = start.elapsed().as_millis() as u64;
195        Ok(PublishStatusReport::from_statuses(statuses, cache_hits, elapsed_ms))
196    }
197
198    /// Synchronous scan (for non-async contexts)
199    #[cfg(feature = "native")]
200    pub fn scan_sync(&mut self) -> Result<PublishStatusReport> {
201        let rt = tokio::runtime::Runtime::new()?;
202        rt.block_on(self.scan())
203    }
204}