use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Instant;
use crate::project::AbsolutePath;
use crate::project::ManifestFingerprint;
use crate::project::WorkspaceMetadataStore;
use crate::tui::app::DirtyState;
use crate::tui::app::DiscoveryShimmer;
#[cfg(test)]
use crate::tui::app::RetrySpawnMode;
use crate::tui::app::ScanState;
use crate::tui::app::TargetDirIndex;
pub(crate) struct Scan {
pub(crate) state: ScanState,
dirty: DirtyState,
data_generation: u64,
discovery_shimmers: HashMap<AbsolutePath, DiscoveryShimmer>,
pending_git_first_commit: HashMap<AbsolutePath, String>,
metadata_store: Arc<Mutex<WorkspaceMetadataStore>>,
pub(crate) target_dir_index: TargetDirIndex,
priority_fetch_path: Option<AbsolutePath>,
confirm_verifying: Option<AbsolutePath>,
#[cfg(test)]
retry_spawn_mode: RetrySpawnMode,
}
impl Scan {
pub fn new(state: ScanState, metadata_store: Arc<Mutex<WorkspaceMetadataStore>>) -> Self {
Self {
state,
dirty: DirtyState::initial(),
data_generation: 0,
discovery_shimmers: HashMap::new(),
pending_git_first_commit: HashMap::new(),
metadata_store,
target_dir_index: TargetDirIndex::new(),
priority_fetch_path: None,
confirm_verifying: None,
#[cfg(test)]
retry_spawn_mode: RetrySpawnMode::Enabled,
}
}
pub const fn is_complete(&self) -> bool { self.state.phase.is_complete() }
pub fn needs_animation(&self) -> bool { !self.is_complete() || self.has_active_shimmers() }
pub const fn terminal_is_dirty(&self) -> bool { self.dirty.terminal.is_dirty() }
pub const fn mark_terminal_dirty(&mut self) { self.dirty.terminal.mark_dirty(); }
pub const fn clear_terminal_dirty(&mut self) { self.dirty.terminal.mark_clean(); }
pub const fn generation(&self) -> u64 { self.data_generation }
pub const fn bump_generation(&mut self) { self.data_generation += 1; }
pub const fn discovery_shimmers(&self) -> &HashMap<AbsolutePath, DiscoveryShimmer> {
&self.discovery_shimmers
}
pub const fn discovery_shimmers_mut(&mut self) -> &mut HashMap<AbsolutePath, DiscoveryShimmer> {
&mut self.discovery_shimmers
}
pub fn prune_shimmers(&mut self, now: Instant) {
self.discovery_shimmers
.retain(|_, shimmer| shimmer.is_active_at(now));
}
pub fn has_active_shimmers(&self) -> bool { !self.discovery_shimmers.is_empty() }
#[cfg(test)]
pub const fn pending_git_first_commit(&self) -> &HashMap<AbsolutePath, String> {
&self.pending_git_first_commit
}
pub const fn pending_git_first_commit_mut(&mut self) -> &mut HashMap<AbsolutePath, String> {
&mut self.pending_git_first_commit
}
pub const fn metadata_store(&self) -> &Arc<Mutex<WorkspaceMetadataStore>> {
&self.metadata_store
}
pub fn metadata_store_handle(&self) -> Arc<Mutex<WorkspaceMetadataStore>> {
Arc::clone(&self.metadata_store)
}
pub fn resolve_target_dir(&self, path: &AbsolutePath) -> Option<AbsolutePath> {
self.metadata_store
.lock()
.ok()
.and_then(|store| store.resolved_target_dir(path).cloned())
}
pub const fn priority_fetch_path(&self) -> Option<&AbsolutePath> {
self.priority_fetch_path.as_ref()
}
pub fn set_priority_fetch_path(&mut self, path: Option<AbsolutePath>) {
self.priority_fetch_path = path;
}
pub const fn confirm_verifying(&self) -> Option<&AbsolutePath> {
self.confirm_verifying.as_ref()
}
pub fn set_confirm_verifying(&mut self, path: Option<AbsolutePath>) {
self.confirm_verifying = path;
}
pub fn clear_confirm_verifying_for(&mut self, workspace_root: &AbsolutePath) {
if self.confirm_verifying.as_ref() == Some(workspace_root) {
self.confirm_verifying = None;
}
}
#[cfg(test)]
pub const fn retry_spawn_mode(&self) -> RetrySpawnMode { self.retry_spawn_mode }
#[cfg(test)]
pub const fn set_retry_spawn_mode(&mut self, mode: RetrySpawnMode) {
self.retry_spawn_mode = mode;
}
pub fn should_verify_before_clean(&self, project_path: &AbsolutePath) -> bool {
let Ok(store) = self.metadata_store.lock() else {
return false;
};
let Some(workspace_root) = store.containing_workspace_root(project_path) else {
return true;
};
let Some(metadata) = store.get(workspace_root) else {
return true;
};
let Ok(current) = ManifestFingerprint::capture(workspace_root.as_path()) else {
return false;
};
current != metadata.fingerprint
}
pub fn handle_out_of_tree_target_size(
&self,
workspace_root: &AbsolutePath,
target_dir: &AbsolutePath,
bytes: u64,
) {
let Ok(mut store) = self.metadata_store.lock() else {
return;
};
if !store.set_out_of_tree_target_bytes(workspace_root, target_dir, bytes) {
tracing::debug!(
workspace_root = %workspace_root.as_path().display(),
target_dir = %target_dir.as_path().display(),
"out_of_tree_target_size_discarded_stale"
);
}
}
}
#[cfg(test)]
#[allow(
clippy::expect_used,
clippy::unwrap_used,
reason = "tests should panic on unexpected values"
)]
mod tests {
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use super::*;
fn fresh() -> Scan {
Scan::new(
ScanState::new(Instant::now()),
Arc::new(Mutex::new(WorkspaceMetadataStore::new())),
)
}
fn abs(p: &str) -> AbsolutePath { AbsolutePath::from(PathBuf::from(p)) }
#[test]
fn new_starts_with_zero_generation_and_clean_dirty() {
let scan = fresh();
assert_eq!(scan.generation(), 0);
assert!(scan.discovery_shimmers().is_empty());
assert!(scan.pending_git_first_commit().is_empty());
assert!(scan.priority_fetch_path().is_none());
assert!(scan.confirm_verifying().is_none());
}
#[test]
fn bump_generation_increments_monotonically() {
let mut scan = fresh();
scan.bump_generation();
scan.bump_generation();
assert_eq!(scan.generation(), 2);
}
#[test]
fn priority_fetch_path_round_trip() {
let mut scan = fresh();
let p = abs("/tmp/proj");
scan.set_priority_fetch_path(Some(p.clone()));
assert_eq!(scan.priority_fetch_path(), Some(&p));
scan.set_priority_fetch_path(None);
assert!(scan.priority_fetch_path().is_none());
}
#[test]
fn confirm_verifying_round_trip() {
let mut scan = fresh();
let p = abs("/tmp/ws");
scan.set_confirm_verifying(Some(p.clone()));
assert_eq!(scan.confirm_verifying(), Some(&p));
scan.set_confirm_verifying(None);
assert!(scan.confirm_verifying().is_none());
}
#[test]
fn discovery_shimmers_independent_of_pending_first_commit() {
let mut scan = fresh();
let p = abs("/tmp/proj");
scan.discovery_shimmers_mut().insert(
p.clone(),
DiscoveryShimmer::new(Instant::now(), Duration::from_millis(50)),
);
assert!(scan.discovery_shimmers().contains_key(&p));
assert!(scan.pending_git_first_commit().is_empty());
}
#[test]
fn pending_git_first_commit_round_trip() {
let mut scan = fresh();
let p = abs("/tmp/proj");
scan.pending_git_first_commit_mut()
.insert(p.clone(), "abc123".to_string());
assert_eq!(
scan.pending_git_first_commit().get(&p).map(String::as_str),
Some("abc123")
);
}
#[test]
fn metadata_store_returns_shared_arc() {
let scan = fresh();
let arc1 = Arc::clone(scan.metadata_store());
let arc2 = Arc::clone(scan.metadata_store());
assert!(Arc::ptr_eq(&arc1, &arc2));
}
}