Skip to main content

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}