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}