cargo_hold/cli/mod.rs
1//! Command-line interface definitions for cargo-hold.
2//!
3//! This module defines the CLI structure using clap, including all subcommands
4//! and their arguments. The main entry point is the [`Cli`] struct.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use cargo_hold::cli::{Cli, Commands};
10//!
11//! // Parse command-line arguments
12//! let cli = Cli::parse_args();
13//!
14//! // Access the parsed command
15//! match &cli.command() {
16//! Commands::Anchor => println!("Running anchor command"),
17//! Commands::Voyage { gc, .. } => {
18//! println!("Running voyage with size limit: {:?}", gc.max_target_size());
19//! }
20//! _ => {}
21//! }
22//! ```
23
24use std::path::{Path, PathBuf};
25
26use clap::{Args, Parser, Subcommand};
27
28use crate::error::{HoldError, Result};
29
30#[cfg(test)]
31mod tests;
32
33/// Main command-line interface for cargo-hold.
34///
35/// This struct represents the top-level CLI configuration, containing both
36/// global options that apply to all commands and the specific subcommand
37/// to execute.
38#[derive(Parser)]
39#[command(
40 name = "cargo-hold",
41 bin_name = "cargo-hold",
42 author,
43 version,
44 about = "A CI tool to ensure Cargo's incremental compilation is reliable",
45 long_about = None,
46 propagate_version = true
47)]
48pub struct Cli {
49 #[command(flatten)]
50 global_opts: GlobalOpts,
51
52 #[command(subcommand)]
53 command: Commands,
54}
55
56/// Global options that apply to all cargo-hold commands.
57///
58/// These options control the overall behavior of cargo-hold, including
59/// where to find the target directory, where to store metadata, and
60/// output verbosity levels.
61#[derive(Parser)]
62pub struct GlobalOpts {
63 /// Path to the target directory (defaults to ./target)
64 #[arg(
65 long,
66 global = true,
67 default_value = "target",
68 env = "CARGO_HOLD_TARGET_DIR"
69 )]
70 target_dir: PathBuf,
71
72 /// Path to the metadata file (defaults to
73 /// `<target-dir>/cargo-hold.metadata`)
74 #[arg(long, global = true, env = "CARGO_HOLD_METADATA_PATH")]
75 metadata_path: Option<PathBuf>,
76
77 /// Enable verbose output (use multiple times for more verbosity)
78 #[arg(short, long, global = true, action = clap::ArgAction::Count, env = "CARGO_HOLD_VERBOSE")]
79 verbose: u8,
80
81 /// Silence all output except for errors
82 #[arg(
83 short,
84 long,
85 global = true,
86 conflicts_with = "verbose",
87 env = "CARGO_HOLD_QUIET"
88 )]
89 quiet: bool,
90}
91
92/// Shared garbage collection arguments.
93#[derive(Args, Debug, Clone, Default)]
94pub struct GcArgs {
95 /// Maximum target directory size (e.g., "5G", "500M", or bytes)
96 #[arg(long, env = "CARGO_HOLD_MAX_TARGET_SIZE")]
97 max_target_size: Option<String>,
98
99 /// Additional binaries to preserve in ~/.cargo/bin (comma-separated)
100 #[arg(
101 long,
102 value_delimiter = ',',
103 env = "CARGO_HOLD_PRESERVE_CARGO_BINARIES"
104 )]
105 preserve_cargo_binaries: Vec<String>,
106}
107
108impl GcArgs {
109 /// Build GC args for programmatic use.
110 pub fn new(max_target_size: Option<String>, preserve_cargo_binaries: Vec<String>) -> Self {
111 Self {
112 max_target_size,
113 preserve_cargo_binaries,
114 }
115 }
116
117 /// Get the max target size flag.
118 pub fn max_target_size(&self) -> Option<&str> {
119 self.max_target_size.as_deref()
120 }
121
122 /// Get the list of binaries to preserve.
123 pub fn preserve_cargo_binaries(&self) -> &[String] {
124 &self.preserve_cargo_binaries
125 }
126}
127
128impl GlobalOpts {
129 /// Create a new builder for constructing `GlobalOpts` programmatically.
130 pub fn builder() -> GlobalOptsBuilder {
131 GlobalOptsBuilder::default()
132 }
133
134 /// Get the effective metadata path
135 pub fn get_metadata_path(&self) -> PathBuf {
136 let path = self
137 .metadata_path()
138 .map(|p| p.to_path_buf())
139 .unwrap_or_else(|| self.target_dir().join("cargo-hold.metadata"));
140
141 normalize_path(path)
142 }
143
144 /// Get the absolute target directory path
145 pub fn get_target_dir(&self) -> PathBuf {
146 normalize_path(self.target_dir())
147 }
148
149 /// Get the target directory
150 pub fn target_dir(&self) -> &Path {
151 &self.target_dir
152 }
153
154 /// Get the metadata path option
155 pub fn metadata_path(&self) -> Option<&Path> {
156 self.metadata_path.as_deref()
157 }
158
159 /// Get the verbose level
160 pub fn verbose(&self) -> u8 {
161 self.verbose
162 }
163
164 /// Check if quiet mode is enabled
165 pub fn quiet(&self) -> bool {
166 self.quiet
167 }
168}
169
170/// Builder for constructing `GlobalOpts` programmatically.
171///
172/// This builder provides a fluent API for creating `GlobalOpts` instances
173/// without going through command-line parsing. Useful for testing and
174/// programmatic usage.
175#[derive(Default)]
176pub struct GlobalOptsBuilder {
177 target_dir: Option<PathBuf>,
178 metadata_path: Option<PathBuf>,
179 verbose: u8,
180 quiet: bool,
181}
182
183impl GlobalOptsBuilder {
184 /// Set the target directory path.
185 pub fn target_dir(mut self, dir: impl Into<PathBuf>) -> Self {
186 self.target_dir = Some(dir.into());
187 self
188 }
189
190 /// Set the metadata file path.
191 pub fn metadata_path(mut self, path: Option<impl Into<PathBuf>>) -> Self {
192 self.metadata_path = path.map(|p| p.into());
193 self
194 }
195
196 /// Set the verbosity level (0 = normal, 1+ = verbose).
197 pub fn verbose(mut self, level: u8) -> Self {
198 self.verbose = level;
199 self
200 }
201
202 /// Enable or disable quiet mode.
203 pub fn quiet(mut self, quiet: bool) -> Self {
204 self.quiet = quiet;
205 self
206 }
207
208 /// Build the `GlobalOpts` instance with the configured values.
209 pub fn build(self) -> GlobalOpts {
210 GlobalOpts {
211 target_dir: self.target_dir.unwrap_or_else(|| PathBuf::from("target")),
212 metadata_path: self.metadata_path,
213 verbose: self.verbose,
214 quiet: self.quiet,
215 }
216 }
217}
218
219impl Cli {
220 /// Get the global options
221 pub fn global_opts(&self) -> &GlobalOpts {
222 &self.global_opts
223 }
224
225 /// Get the command
226 pub fn command(&self) -> &Commands {
227 &self.command
228 }
229
230 /// Create a builder for programmatic construction
231 pub fn builder() -> CliBuilder {
232 CliBuilder::default()
233 }
234}
235
236/// Builder for [`Cli`]
237#[derive(Debug, Default)]
238pub struct CliBuilder {
239 target_dir: Option<PathBuf>,
240 metadata_path: Option<PathBuf>,
241 verbose: u8,
242 quiet: bool,
243 command: Option<Commands>,
244}
245
246impl CliBuilder {
247 /// Set the target directory
248 pub fn target_dir(mut self, dir: impl Into<PathBuf>) -> Self {
249 self.target_dir = Some(dir.into());
250 self
251 }
252
253 /// Set the metadata path
254 pub fn metadata_path(mut self, path: impl Into<PathBuf>) -> Self {
255 self.metadata_path = Some(path.into());
256 self
257 }
258
259 /// Set the verbose level
260 pub fn verbose(mut self, level: u8) -> Self {
261 self.verbose = level;
262 self
263 }
264
265 /// Enable quiet mode
266 pub fn quiet(mut self, enabled: bool) -> Self {
267 self.quiet = enabled;
268 self
269 }
270
271 /// Set the command
272 pub fn command(mut self, command: Commands) -> Self {
273 self.command = Some(command);
274 self
275 }
276
277 /// Build the Cli instance
278 pub fn build(self) -> Result<Cli> {
279 let command = self
280 .command
281 .ok_or(HoldError::ConfigError("Command is required".to_string()))?;
282
283 Ok(Cli {
284 global_opts: GlobalOpts::builder()
285 .target_dir(self.target_dir.unwrap_or_else(|| PathBuf::from("target")))
286 .metadata_path(self.metadata_path)
287 .verbose(self.verbose)
288 .quiet(self.quiet)
289 .build(),
290 command,
291 })
292 }
293}
294
295/// Normalize a path to be absolute and clean, without requiring it to exist.
296///
297/// This function:
298/// - Converts relative paths to absolute using the current directory
299/// - Removes `.` and `..` components where possible
300/// - Does NOT resolve symlinks (preserves user intent)
301/// - Does NOT require the path to exist
302///
303/// For paths that must exist, consider using canonicalize() instead.
304fn normalize_path(path: impl AsRef<Path>) -> PathBuf {
305 let path = path.as_ref();
306
307 // First, make it absolute if it's relative
308 let absolute = if path.is_relative() {
309 std::env::current_dir()
310 .unwrap_or_else(|_| PathBuf::from("."))
311 .join(path)
312 } else {
313 path.to_path_buf()
314 };
315
316 // Clean up the path by resolving . and .. components
317 let mut components = Vec::new();
318 for component in absolute.components() {
319 use std::path::Component;
320 match component {
321 Component::ParentDir => {
322 // Pop the last component if it's not a ParentDir
323 if let Some(last) = components.last()
324 && !matches!(last, Component::ParentDir)
325 {
326 components.pop();
327 continue;
328 }
329 components.push(component);
330 }
331 Component::CurDir => {
332 // Skip . components
333 continue;
334 }
335 _ => components.push(component),
336 }
337 }
338
339 // Reconstruct the path
340 let mut result = PathBuf::new();
341 for component in components {
342 result.push(component);
343 }
344
345 result
346}
347
348/// Available cargo-hold subcommands.
349///
350/// Each variant represents a different operation that cargo-hold can perform,
351/// from managing timestamps and metadata to cleaning up build artifacts.
352#[derive(Debug, Subcommand)]
353pub enum Commands {
354 /// Anchor your build state (recommended CI command)
355 ///
356 /// This is the main command that performs the complete workflow:
357 /// 1. Restores timestamps from the metadata file based on content changes
358 /// 2. Scans all Git-tracked files for modifications
359 /// 3. Updates and saves the metadata with the current state
360 ///
361 /// Use this command in CI before running `cargo build` to ensure
362 /// incremental compilation works correctly with cached artifacts.
363 Anchor,
364
365 /// Salvage file timestamps from the metadata
366 ///
367 /// Restores timestamps based on the previous build state:
368 /// - Unchanged files: Restored to their original timestamps
369 /// - Modified files: Given a new monotonic timestamp
370 /// - New files: Given a new monotonic timestamp
371 ///
372 /// This prevents unnecessary rebuilds while ensuring changed files
373 /// are properly recompiled.
374 Salvage,
375
376 /// Stow files in the cargo hold
377 ///
378 /// Scans all Git-tracked files and saves their current state:
379 /// - Computes BLAKE3 hashes for content-based change detection
380 /// - Records file sizes and modification times
381 /// - Saves metadata to enable future timestamp restoration
382 ///
383 /// Run this after a successful build to update the metadata.
384 Stow,
385
386 /// Bilge out the metadata file
387 ///
388 /// Removes the metadata file, forcing a fresh start on the next run.
389 /// Use this when:
390 /// - You want to reset the timestamp tracking state
391 /// - The metadata file has become corrupted
392 /// - You're troubleshooting incremental compilation issues
393 Bilge,
394
395 /// Heave ho! Clean up old build artifacts
396 ///
397 /// Performs garbage collection on build artifacts to reclaim disk space:
398 /// - First ensures target directory is under size limit (if specified)
399 /// - Then removes artifacts older than the age threshold (default: 7 days)
400 /// - Both conditions are always applied together for consistent cleanup
401 /// - Always preserves: Binaries, important Cargo files, and recent
402 /// artifacts
403 /// - Also cleans: ~/.cargo/registry/cache and ~/.cargo/git/checkouts
404 ///
405 /// Artifacts are removed by crate (all related files together) to maintain
406 /// build consistency.
407 Heave {
408 #[command(flatten)]
409 gc: GcArgs,
410
411 /// Show what would be deleted without actually deleting
412 #[arg(long, env = "CARGO_HOLD_DRY_RUN")]
413 dry_run: bool,
414
415 /// Enable debug output for garbage collection
416 #[arg(long, env = "CARGO_HOLD_DEBUG")]
417 debug: bool,
418
419 /// Age threshold in days for removing artifacts (default: 7)
420 #[arg(long, default_value = "7", env = "CARGO_HOLD_AGE_THRESHOLD_DAYS")]
421 age_threshold_days: u32,
422
423 /// Enable auto max-target-size suggestions derived from prior runs.
424 #[arg(long, default_value_t = true, env = "CARGO_HOLD_AUTO_MAX_TARGET_SIZE")]
425 auto_max_target_size: bool,
426 },
427
428 /// Full voyage - anchor and heave in one command
429 ///
430 /// Combines the anchor and heave commands for a complete CI workflow:
431 /// 1. First runs anchor to restore timestamps and update metadata
432 /// 2. Then runs heave to clean up old artifacts and manage disk usage
433 ///
434 /// This is ideal for CI pipelines that need both timestamp management
435 /// and disk space control in a single command.
436 Voyage {
437 #[command(flatten)]
438 gc: GcArgs,
439
440 /// Minimum hours between GC runs; anchor always runs (default: no
441 /// cooldown)
442 #[arg(long, env = "CARGO_HOLD_GC_MIN_INTERVAL_HOURS")]
443 gc_min_interval_hours: Option<u64>,
444
445 /// Run GC even within the cooldown (still respects --gc-dry-run)
446 #[arg(long)]
447 force_gc: bool,
448
449 /// Show what would be deleted without actually deleting
450 #[arg(long, env = "CARGO_HOLD_GC_DRY_RUN")]
451 gc_dry_run: bool,
452
453 /// Enable debug output for garbage collection
454 #[arg(long, env = "CARGO_HOLD_GC_DEBUG")]
455 gc_debug: bool,
456
457 /// Age threshold in days for garbage collection (default: 7)
458 #[arg(long, default_value = "7", env = "CARGO_HOLD_GC_AGE_THRESHOLD_DAYS")]
459 gc_age_threshold_days: u32,
460
461 /// Enable auto max-target-size suggestions derived from prior runs.
462 #[arg(long, default_value_t = true, env = "CARGO_HOLD_AUTO_MAX_TARGET_SIZE")]
463 gc_auto_max_target_size: bool,
464 },
465}
466
467impl Cli {
468 /// Parse command line arguments, handling the cargo subcommand case
469 pub fn parse_args() -> Self {
470 let args: Vec<String> = std::env::args().collect();
471
472 // Skip Cargo's leading "hold" argument to parse the actual subcommand.
473 if args.len() >= 2 && args[1] == "hold" {
474 // Skip the "hold" argument by reconstructing args without it
475 let mut new_args = vec![args[0].clone()]; // program name
476 new_args.extend_from_slice(&args[2..]); // rest of arguments after "hold"
477 return Self::parse_from(new_args);
478 }
479
480 // Normal parsing if not invoked through cargo
481 Self::parse()
482 }
483}