cargo-port 0.2.0

A TUI for inspecting and managing Rust projects
use std::path::Path;

use tui_pane::Appearance;

use super::cargo_metadata::CargoMetadataError;
use super::disk_usage::DirSizes;
use crate::cache_paths;
use crate::channel::Sender;
use crate::ci::CiRun;
use crate::ci::OwnerRepo;
use crate::http::ServiceKind;
use crate::http::ServiceSignal;
use crate::lint::CacheUsage;
use crate::lint::CachedLintStatus;
use crate::lint::LintRun;
use crate::lint::LintRunOrigin;
use crate::lint::LintStatus;
use crate::project::AbsolutePath;
use crate::project::CheckoutInfo;
use crate::project::LanguageStats;
use crate::project::ManifestFingerprint;
use crate::project::ProjectPrData;
use crate::project::PullRequestGoneReason;
use crate::project::PullRequestInfo;
use crate::project::RepoInfo;
use crate::project::RootItem;
use crate::project::Submodule;
use crate::project::TestCounts;
use crate::project::WorkspaceMetadata;
use crate::sccache::StatsResult as SccacheStatsResult;

/// Messages sent from background threads to the main event loop.
pub enum BackgroundMsg {
    /// Disk usage (bytes) computed for a single project path.
    DiskUsage { path: AbsolutePath, bytes: u64 },
    /// Batch of disk usage results for projects under a common root.
    /// Each entry carries both the total and the in-target /
    /// non-target split used by the detail-pane breakdown.
    DiskUsageBatch {
        root_path: AbsolutePath,
        entries:   Vec<(AbsolutePath, DirSizes)>,
    },
    /// GitHub Actions CI runs fetched for a project.
    CiRuns {
        path:         AbsolutePath,
        runs:         Vec<CiRun>,
        github_total: u32,
    },
    /// A GitHub repo fetch has been queued (for startup tracking).
    RepoFetchQueued { repo: OwnerRepo },
    /// A `spawn_repo_fetch_for_git_info` thread has finished. Sent
    /// regardless of whether the spawn hit the network or returned a
    /// cached result. Drives both the startup "GitHub repos" toast
    /// progress (no-op on cache hit, since no `RepoFetchQueued` was
    /// sent) and the `repo_fetch_in_flight` dedup set.
    RepoFetchComplete { repo: OwnerRepo },
    /// Per-checkout git state for a project (branch, status, ahead/
    /// behind, `last_commit`, `primary_tracked_ref`). Sent by
    /// `CheckoutInfo::get` for every affected checkout — primary AND
    /// each linked worktree on a refresh — since each working tree has
    /// its own HEAD/index/branch.
    CheckoutInfo {
        path: AbsolutePath,
        info: CheckoutInfo,
    },
    /// Per-repo git state (remotes, workflows, default branch, last
    /// fetched, etc.). Sent by `RepoInfo::get` once per repo refresh.
    /// `path` is the primary checkout's path so `handle_repo_info` can
    /// enforce the "only the primary writes `RepoInfo`" policy.
    RepoInfo { path: AbsolutePath, info: RepoInfo },
    /// First commit date detected for a project (deferred post-scan,
    /// batched by repo root to avoid redundant `git log` calls).
    GitFirstCommit {
        path:         AbsolutePath,
        first_commit: Option<String>,
    },
    /// A project-detail worker has declared all startup follow-up work it can
    /// enqueue for this path. Used by startup readiness to wait for dynamic
    /// declarations such as submodule crates.io fetches.
    ProjectDetailsDeclared { path: AbsolutePath },
    /// Crates.io version and download count fetched for a project.
    CratesIoVersion {
        path:       AbsolutePath,
        version:    String,
        prerelease: Option<String>,
        downloads:  u64,
    },
    /// A crates.io fetch has been queued for `name`. Drives the
    /// "Fetching crates.io info" running toast. Mirrors
    /// [`Self::RepoFetchQueued`].
    CratesIoFetchQueued { name: String },
    /// A crates.io fetch finished for `name` (success or failure).
    /// Mirrors [`Self::RepoFetchComplete`].
    CratesIoFetchComplete { name: String },
    /// GitHub repo metadata (stars, description) fetched.
    RepoMeta {
        path:        AbsolutePath,
        stars:       u64,
        description: Option<String>,
    },
    /// Open pull requests authored by the current GitHub user for a repo.
    PullRequests {
        repo: OwnerRepo,
        data: ProjectPrData,
    },
    /// Number of language scan work units that will contribute startup progress.
    LanguageStatsProgressPlan { units: usize },
    /// A background poll for one PR's `checks` state has stopped.
    PullRequestCheckPollStopped { repo: OwnerRepo, number: u32 },
    /// A previously-open PR disappeared from the open-PR list and was
    /// classified by querying that PR number directly.
    PullRequestDisappeared {
        repo:         OwnerRepo,
        pull_request: PullRequestInfo,
        reason:       PullRequestGoneReason,
    },
    /// Complete project tree from the streaming scan, plus disk entry
    /// paths for background disk usage computation.
    ScanResult {
        projects:     Vec<RootItem>,
        disk_entries: Vec<(String, AbsolutePath)>,
    },
    /// A new project discovered by the watcher after the initial scan.
    ProjectDiscovered { item: RootItem },
    /// An existing project re-scanned by the watcher (e.g. after a
    /// Cargo.toml change adds/removes workspace members).
    ProjectRefreshed { item: RootItem },
    /// Git submodules detected for a project.
    Submodules {
        path:       AbsolutePath,
        submodules: Vec<Submodule>,
    },
    /// Live lint status update from the lint runtime (a lint run started,
    /// passed, failed, etc.). `origin` makes startup catch-up runs distinct
    /// from later file-triggered runs, so their running toasts cannot merge.
    LintStatus {
        path:   AbsolutePath,
        status: LintStatus,
        origin: LintRunOrigin,
    },
    /// Startup lint cache check result. Sent once per registered project when
    /// the lint runtime reads terminal cached results from disk during
    /// initialization. Distinct from `LintStatus` so startup/cache hydration
    /// cannot represent live running lint work.
    LintStartupStatus {
        path:   AbsolutePath,
        status: CachedLintStatus,
    },
    /// Lint cache pruned — old runs evicted to stay within the configured
    /// cache size limit.
    LintCachePruned {
        runs_evicted:    usize,
        bytes_reclaimed: u64,
    },
    /// Total bytes retained under the lint cache root, computed off the
    /// main thread. Drives the lint cache usage display. Spawned by
    /// `App::refresh_lint_cache_usage_from_disk`; the disk walk would
    /// otherwise block the first paint when the cache has thousands of
    /// archived run files.
    LintCacheUsage { usage: CacheUsage },
    /// Per-project lint history read from disk off the main thread. Spawned
    /// by `App::refresh_lint_runs_from_disk`; reading and JSON-parsing the
    /// history file for every Rust project synchronously would otherwise
    /// freeze the first content paint for over a second on a large tree.
    LintHistoryLoaded {
        entries: Vec<(AbsolutePath, Vec<LintRun>)>,
    },
    /// An external service (GitHub, crates.io) is reachable.
    ServiceReachable { service: ServiceKind },
    /// An external service recovered after being unreachable or
    /// rate-limited.
    ServiceRecovered { service: ServiceKind },
    /// Network failure reaching the service (DNS, connection, timeout,
    /// 5xx).
    ServiceUnreachable { service: ServiceKind },
    /// The retry thread has confirmed the service is still unreachable
    /// after the [`SERVICE_UNAVAILABLE_GRACE`](crate::constants::SERVICE_UNAVAILABLE_GRACE)
    /// window. Surfaces the user-visible "unreachable" toast on the
    /// main thread — single transient timeouts never make it this far.
    ServiceUnreachableConfirmed { service: ServiceKind },
    /// Service is reachable but currently rate-limited.
    ServiceRateLimited { service: ServiceKind },
    /// Language statistics (file counts + LOC by language) computed by tokei.
    LanguageStatsBatch {
        entries: Vec<(AbsolutePath, LanguageStats)>,
    },
    /// Test-function counts (unit / integration) from the source scan.
    TestCountsBatch {
        entries: Vec<(AbsolutePath, TestCounts)>,
    },
    /// Result of an on-demand `sccache --show-stats` request.
    SccacheStats {
        request_id: u64,
        result:     SccacheStatsResult,
    },
    /// `cargo metadata --no-deps --offline` result for one workspace root.
    /// The `fingerprint` was captured *before* the spawn; callers recompute
    /// at merge time and discard the result on mismatch. `generation`
    /// coalesces rapid re-dispatches — arrivals stamped with an older
    /// generation are dropped rather than merged.
    CargoMetadata {
        workspace_root: AbsolutePath,
        generation:     u64,
        fingerprint:    ManifestFingerprint,
        result:         Result<WorkspaceMetadata, CargoMetadataError>,
    },
    /// Disk walk result for an out-of-tree `target_directory`. Emitted by
    /// [`crate::scan::spawn_out_of_tree_target_walk`] when workspace metadata whose
    /// `target_directory` sits outside its `workspace_root` lands. The
    /// receiver stamps `bytes` onto the cached metadata so the detail pane
    /// can surface sharer target sizes that the per-project walker can't see.
    OutOfTreeTargetSize {
        workspace_root: AbsolutePath,
        target_dir:     AbsolutePath,
        bytes:          u64,
    },
    /// OS appearance (light/dark) just changed. Emitted by the
    /// `dark-light` poller (Phase 5; the variant exists from Phase 3
    /// so the apply path can be wired before the poller ships). The
    /// receiver stashes the value on the `Themes` subsystem and
    /// re-resolves the active theme against the current config.
    AppearanceChanged(Appearance),
}

impl BackgroundMsg {
    /// If this message can change what the detail pane would render for a
    /// project at some path, return that path. Otherwise return `None`.
    ///
    /// This is exhaustive on every variant *by design* — adding a new
    /// `BackgroundMsg` without classifying it here is a compile error.
    /// That's the type-level guarantee: invalidation policy can't drift
    /// out of sync with the message catalog.
    ///
    /// "Affects detail" means the message could change a field in
    /// `PaneDataStore`'s built detail set (`package`, `git`, `targets`,
    /// `ci`, `lints`). Service-level signals, fetch lifecycle, and batch
    /// notifications that are processed via dedicated paths return
    /// `None` — they invalidate via their own routes (or don't need to).
    pub fn detail_relevance(&self) -> Option<&Path> {
        match self {
            // Per-project path bearing — each maps to a field rendered
            // inside the detail set.
            Self::DiskUsage { path, .. }              // package.disk
            | Self::CiRuns { path, .. }                // ci.runs
            | Self::CheckoutInfo { path, .. }          // git.branch / git.status
            | Self::RepoInfo { path, .. }              // git.remotes / git.workflows
            | Self::GitFirstCommit { path, .. }        // git.inception
            | Self::CratesIoVersion { path, .. }      // package.crates_version
            | Self::RepoMeta { path, .. }              // git.stars / git.description
            | Self::Submodules { path, .. }            // submodules detail
            | Self::LintStartupStatus { path, .. } => Some(path.as_path()),

            // Discovery/refresh of an item is detail-relevant for that
            // item's path (ahead/behind cache, package fields, etc.).
            Self::ProjectDiscovered { item }
            | Self::ProjectRefreshed { item } => Some(item.path()),

            // Workspace-wide metadata feeds package + targets fields for
            // every member of the workspace, but the path we have is the
            // workspace root — `detail_path_is_affected` will widen the
            // match correctly.
            Self::CargoMetadata { workspace_root, .. }
            | Self::OutOfTreeTargetSize { workspace_root, .. } => Some(workspace_root.as_path()),

            // Wholesale tree replacement bumps `data_generation` via the
            // dedicated `apply_tree_build` / scan-result paths. No
            // per-message bump needed.
            Self::ScanResult { .. }
            // Batch arrivals are aggregated and the handler bumps
            // generation explicitly (see `handle_disk_usage_batch_msg`).
            | Self::DiskUsageBatch { .. }
            // Counted language progress only feeds the startup panel.
            | Self::LanguageStatsProgressPlan { .. }
            // Language stats live in `RustInfo`, not in the detail set.
            | Self::LanguageStatsBatch { .. }
            // Test counts feed the detail set's Tests section, but as a
            // batch the handler bumps generation explicitly (see
            // `handle_test_counts_batch`).
            | Self::TestCountsBatch { .. }
            // On-demand tooling overlay data does not affect project detail.
            | Self::SccacheStats { .. }
            // Fetch lifecycle is reflected via toasts, not detail data.
            | Self::RepoFetchQueued { .. }
            | Self::RepoFetchComplete { .. }
            | Self::PullRequests { .. }
            | Self::PullRequestCheckPollStopped { .. }
            | Self::PullRequestDisappeared { .. }
            | Self::CratesIoFetchQueued { .. }
            | Self::CratesIoFetchComplete { .. }
            | Self::ProjectDetailsDeclared { .. }
            // Live lint statuses resolve the lint-owning project before
            // invalidating detail panes; doing it in the handler keeps
            // owner remaps and toast state tied to the same model update.
            | Self::LintStatus { .. }
            // Cache pruning and cache usage refreshes are internal to
            // the lint subsystem.
            | Self::LintCachePruned { .. }
            | Self::LintCacheUsage { .. }
            // Startup history load is a batch spanning every project; the
            // handler bumps generation explicitly after applying it.
            | Self::LintHistoryLoaded { .. }
            // Service availability is a separate UI surface.
            | Self::ServiceReachable { .. }
            | Self::ServiceRecovered { .. }
            | Self::ServiceUnreachable { .. }
            | Self::ServiceUnreachableConfirmed { .. }
            | Self::ServiceRateLimited { .. }
            // OS appearance changes swap the active theme; no per-project
            // detail data flows through this variant.
            | Self::AppearanceChanged(_) => None,
        }
    }
}

pub const fn combine_service_signal(
    left: Option<ServiceSignal>,
    right: Option<ServiceSignal>,
) -> Option<ServiceSignal> {
    // Priority: Unreachable > RateLimited > Reachable — any bad signal
    // wins over a good one, and network failure trumps rate-limit when
    // both show up in the same batch.
    match (left, right) {
        (Some(ServiceSignal::Unreachable(service)), _)
        | (_, Some(ServiceSignal::Unreachable(service))) => {
            Some(ServiceSignal::Unreachable(service))
        },
        (Some(ServiceSignal::RateLimited(service)), _)
        | (_, Some(ServiceSignal::RateLimited(service))) => {
            Some(ServiceSignal::RateLimited(service))
        },
        (Some(ServiceSignal::Reachable(service)), _)
        | (_, Some(ServiceSignal::Reachable(service))) => Some(ServiceSignal::Reachable(service)),
        (None, None) => None,
    }
}

pub fn emit_service_signal(sender: &Sender<BackgroundMsg>, signal: Option<ServiceSignal>) {
    let msg = match signal {
        Some(ServiceSignal::Reachable(service)) => BackgroundMsg::ServiceReachable { service },
        Some(ServiceSignal::Unreachable(service)) => BackgroundMsg::ServiceUnreachable { service },
        Some(ServiceSignal::RateLimited(service)) => BackgroundMsg::ServiceRateLimited { service },
        None => return,
    };
    let _ = sender.send(msg);
}

pub fn emit_service_recovered(sender: &Sender<BackgroundMsg>, service: ServiceKind) {
    let _ = sender.send(BackgroundMsg::ServiceRecovered { service });
}

/// Probe per-repo + per-checkout git state for a single project and
/// emit them as two background messages. Used by the initial scan and
/// project-discovery enrichment paths, where each project is processed
/// independently. The watcher's refresh path uses a smarter
/// orchestration that probes `RepoInfo` once per repo and reuses it
/// across sibling worktrees.
pub fn emit_git_info(sender: &Sender<BackgroundMsg>, path: &AbsolutePath) {
    let Some(repo) = RepoInfo::get(path.as_path()) else {
        return;
    };
    let checkout = CheckoutInfo::get(path.as_path(), repo.local_main_branch.as_deref());
    let _ = sender.send(BackgroundMsg::RepoInfo {
        path: path.clone(),
        info: repo,
    });
    if let Some(checkout) = checkout {
        let _ = sender.send(BackgroundMsg::CheckoutInfo {
            path: path.clone(),
            info: checkout,
        });
    }
}

/// Base cache directory for CI metadata.
pub fn cache_dir() -> AbsolutePath { cache_paths::ci_cache_root() }