cargo-port 0.1.3

A TUI for inspecting and managing Rust projects
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
use std::path::Path;

use crate::cache_paths;
use crate::channel::Sender;
use crate::http::ServiceKind;
use crate::http::ServiceSignal;
use crate::lint::LintRunOrigin;
use crate::project::AbsolutePath;
use crate::project::CheckoutInfo;
use crate::project::ManifestFingerprint;
use crate::project::RepoInfo;
use crate::project::RootItem;
use crate::project::Submodule;
use crate::project::WorkspaceMetadata;

mod cargo_metadata;
mod ci_cache;
mod constants;
mod discovery;
mod disk_usage;
mod language_stats;
mod test_counts;
mod tree;

pub(crate) use cargo_metadata::CargoMetadataError;
pub(crate) use cargo_metadata::MetadataDispatchContext;
pub(crate) use cargo_metadata::cargo_metadata_roots_for_item;
pub(crate) use cargo_metadata::spawn_cargo_metadata_refresh;
pub(crate) use cargo_metadata::spawn_out_of_tree_target_walk;
pub(crate) use cargo_metadata::spawn_streaming_scan;
pub(crate) use ci_cache::CiFetchResult;
pub(crate) use ci_cache::CratesIoInfo;
pub(crate) use ci_cache::ci_cache_dir_pub;
pub(crate) use ci_cache::clear_exhausted;
pub(crate) use ci_cache::fetch_ci_runs_cached;
pub(crate) use ci_cache::fetch_older_runs;
pub(crate) use ci_cache::is_exhausted;
pub(crate) use ci_cache::mark_exhausted;
pub(crate) use discovery::CachedRepoData;
pub(crate) use discovery::FetchContext;
pub(crate) use discovery::ProjectDetailRequest;
pub(crate) use discovery::RepoCache;
pub(crate) use discovery::RepoMetaInfo;
pub(crate) use discovery::discover_project_item;
pub(crate) use discovery::fetch_project_details;
pub(crate) use discovery::invalidate_cached_repo_data;
pub(crate) use discovery::load_cached_repo_data;
pub(crate) use discovery::new_repo_cache;
pub(crate) use discovery::resolve_include_dirs;
pub(crate) use discovery::store_cached_repo_data;
pub(crate) use disk_usage::DirSizes;
pub(crate) use disk_usage::disk_usage_batch_for_item;
pub(crate) use language_stats::collect_language_stats_single;
pub(crate) use test_counts::collect_test_counts_single;
pub(crate) use tree::build_tree;
pub(crate) use tree::cargo_project_to_item;
pub(crate) use tree::dir_size;
pub(crate) use tree::normalize_workspace_path;
use tui_pane::Appearance;

use crate::ci::CiRun;
use crate::ci::OwnerRepo;
use crate::lint::CacheUsage;
use crate::lint::CachedLintStatus;
use crate::lint::LintRun;
use crate::lint::LintStatus;
use crate::project::LanguageStats;
use crate::project::ProjectPrData;
use crate::project::PullRequestGoneReason;
use crate::project::PullRequestInfo;
use crate::project::TestCounts;
use crate::sccache::StatsResult as SccacheStatsResult;

/// Messages sent from background threads to the main event loop.
pub(crate) 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
    /// [`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(crate) 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(super) 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(crate) fn emit_service_signal(tx: &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 _ = tx.send(msg);
}

pub(crate) fn emit_service_recovered(tx: &Sender<BackgroundMsg>, service: ServiceKind) {
    let _ = tx.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(crate) fn emit_git_info(tx: &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 _ = tx.send(BackgroundMsg::RepoInfo {
        path: path.clone(),
        info: repo,
    });
    if let Some(checkout) = checkout {
        let _ = tx.send(BackgroundMsg::CheckoutInfo {
            path: path.clone(),
            info: checkout,
        });
    }
}

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