Skip to main content

zeph_worktree/
usage.rs

1// SPDX-License-Identifier: MIT
2//! Disk-usage accounting and quota-status types for [`crate::WorktreeManager`].
3
4use std::fmt::Write as _;
5use std::path::PathBuf;
6
7use zeph_config::WorktreeConfig;
8
9/// Total and per-worktree disk usage, as computed by
10/// [`WorktreeManager::disk_usage`][crate::WorktreeManager::disk_usage].
11///
12/// `total_bytes` is a sum of logical file sizes (`std::fs::Metadata::len`) across
13/// every regular file under each worktree's directory tree — not on-disk block
14/// usage. Content shared via hardlinks across worktrees (e.g. zeph-session blobs)
15/// can be double-counted, so treat this as an approximation suitable for a soft
16/// warn threshold, not exact `du` output.
17///
18/// # Examples
19///
20/// ```
21/// use zeph_worktree::WorktreeDiskUsage;
22///
23/// let usage = WorktreeDiskUsage {
24///     total_bytes: 1024,
25///     per_worktree: vec![(std::path::PathBuf::from("/repo/worktrees/agent-1"), 1024)],
26/// };
27/// assert_eq!(usage.total_bytes, 1024);
28/// ```
29#[derive(Debug, Clone, Default)]
30pub struct WorktreeDiskUsage {
31    /// Sum of logical file sizes across every worktree under `root`.
32    pub total_bytes: u64,
33    /// Per-worktree logical size, in the same order `reconcile()` + `list()`
34    /// returned them.
35    pub per_worktree: Vec<(PathBuf, u64)>,
36}
37
38/// Result of one reconcile-and-quota sweep, as returned by
39/// [`WorktreeManager::sweep`][crate::WorktreeManager::sweep].
40///
41/// Carries enough information for a caller to decide whether to surface a status
42/// indicator: [`QuotaStatus::is_over_quota`] is `true` when either the count or
43/// disk-usage threshold configured in `WorktreeConfig` was exceeded at sweep time.
44///
45/// # Examples
46///
47/// ```
48/// use zeph_worktree::QuotaStatus;
49///
50/// let status = QuotaStatus {
51///     count: 3,
52///     max_worktrees: Some(5),
53///     total_bytes: 1_000_000,
54///     disk_quota_bytes: None,
55///     reclaimed: 0,
56///     over_count: false,
57///     over_disk: false,
58/// };
59/// assert!(!status.is_over_quota());
60/// ```
61#[derive(Debug, Clone, Default)]
62pub struct QuotaStatus {
63    /// Number of git-registered secondary worktrees under `root` after reclamation.
64    pub count: usize,
65    /// The configured `worktree.max_worktrees` limit, if set.
66    pub max_worktrees: Option<usize>,
67    /// Total logical disk usage across all worktrees, in bytes, after reclamation.
68    ///
69    /// `0` when disk accounting was not performed for this sweep (see
70    /// [`WorktreeManager::sweep`][crate::WorktreeManager::sweep]'s use of
71    /// `config.disk_quota_mb` to decide whether to walk) — callers must check
72    /// `disk_quota_bytes.is_some()` before treating `0` as a meaningful "no usage" result.
73    pub total_bytes: u64,
74    /// The configured `worktree.disk_quota_mb` limit converted to bytes, if set.
75    pub disk_quota_bytes: Option<u64>,
76    /// Number of `prunable` entries automatically removed during this sweep.
77    pub reclaimed: usize,
78    /// `true` when `count >= max_worktrees` (only meaningful if `max_worktrees` is `Some`).
79    pub over_count: bool,
80    /// `true` when `total_bytes >= disk_quota_bytes` (only meaningful if
81    /// `disk_quota_bytes` is `Some`).
82    pub over_disk: bool,
83}
84
85impl QuotaStatus {
86    /// `true` if either the worktree-count or disk-usage threshold was exceeded.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use zeph_worktree::QuotaStatus;
92    ///
93    /// let status = QuotaStatus {
94    ///     over_count: true,
95    ///     ..Default::default()
96    /// };
97    /// assert!(status.is_over_quota());
98    /// ```
99    #[must_use]
100    pub fn is_over_quota(&self) -> bool {
101        self.over_count || self.over_disk
102    }
103}
104
105/// Formats a one-line disk-usage-and-quota summary, shared by the CLI's
106/// `zeph worktree list` (`src/commands/worktree.rs`) and the agent-side `/worktree list`
107/// slash command (`crates/zeph-core/src/agent/worktree_commands.rs`) so the two surfaces
108/// cannot silently diverge in how they report quota status.
109///
110/// `count` is the total number of git-registered secondary worktrees under `root`
111/// (active + stale), matching the same count [`WorktreeManager::create`][crate::WorktreeManager::create]
112/// and [`WorktreeManager::sweep`][crate::WorktreeManager::sweep] use for admission and
113/// quota evaluation.
114///
115/// # Examples
116///
117/// ```
118/// use zeph_config::WorktreeConfig;
119/// use zeph_worktree::{WorktreeDiskUsage, format_usage_summary};
120///
121/// let usage = WorktreeDiskUsage { total_bytes: 2 * 1_048_576, per_worktree: Vec::new() };
122/// let config = WorktreeConfig { max_worktrees: Some(5), disk_quota_mb: Some(1), ..Default::default() };
123/// let summary = format_usage_summary(&usage, 2, &config);
124/// assert!(summary.contains("OVER"), "got: {summary}");
125/// ```
126#[must_use]
127pub fn format_usage_summary(
128    usage: &WorktreeDiskUsage,
129    count: usize,
130    config: &WorktreeConfig,
131) -> String {
132    let used_mb = usage.total_bytes / 1_048_576;
133    let mut line = format!("{count} worktree(s), {used_mb} MB");
134
135    if let Some(max) = config.max_worktrees {
136        let status = if count >= max { "OVER" } else { "ok" };
137        let _ = write!(line, ", max_worktrees: {count}/{max} [{status}]");
138    }
139    if let Some(quota_mb) = config.disk_quota_mb {
140        let status = if used_mb >= quota_mb { "OVER" } else { "ok" };
141        let _ = write!(line, ", disk_quota_mb: {used_mb}/{quota_mb} [{status}]");
142    }
143
144    line
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn is_over_quota_true_when_over_count() {
153        let status = QuotaStatus {
154            over_count: true,
155            ..Default::default()
156        };
157        assert!(status.is_over_quota());
158    }
159
160    #[test]
161    fn is_over_quota_true_when_over_disk() {
162        let status = QuotaStatus {
163            over_disk: true,
164            ..Default::default()
165        };
166        assert!(status.is_over_quota());
167    }
168
169    #[test]
170    fn is_over_quota_false_when_neither() {
171        assert!(!QuotaStatus::default().is_over_quota());
172    }
173
174    #[test]
175    fn format_usage_summary_no_quota_configured() {
176        let usage = WorktreeDiskUsage {
177            total_bytes: 5 * 1_048_576,
178            per_worktree: Vec::new(),
179        };
180        let summary = format_usage_summary(&usage, 3, &WorktreeConfig::default());
181        assert_eq!(summary, "3 worktree(s), 5 MB");
182    }
183
184    #[test]
185    fn format_usage_summary_reports_ok_under_thresholds() {
186        let usage = WorktreeDiskUsage {
187            total_bytes: 1_048_576,
188            per_worktree: Vec::new(),
189        };
190        let config = WorktreeConfig {
191            max_worktrees: Some(5),
192            disk_quota_mb: Some(10),
193            ..Default::default()
194        };
195        let summary = format_usage_summary(&usage, 1, &config);
196        assert!(
197            summary.contains("max_worktrees: 1/5 [ok]"),
198            "got: {summary}"
199        );
200        assert!(
201            summary.contains("disk_quota_mb: 1/10 [ok]"),
202            "got: {summary}"
203        );
204        assert!(!summary.contains("OVER"), "got: {summary}");
205    }
206
207    #[test]
208    fn format_usage_summary_reports_over_at_thresholds() {
209        let usage = WorktreeDiskUsage {
210            total_bytes: 10 * 1_048_576,
211            per_worktree: Vec::new(),
212        };
213        let config = WorktreeConfig {
214            max_worktrees: Some(2),
215            disk_quota_mb: Some(10),
216            ..Default::default()
217        };
218        let summary = format_usage_summary(&usage, 2, &config);
219        assert!(
220            summary.contains("max_worktrees: 2/2 [OVER]"),
221            "got: {summary}"
222        );
223        assert!(
224            summary.contains("disk_quota_mb: 10/10 [OVER]"),
225            "got: {summary}"
226        );
227    }
228}