cargo-hold 1.3.4

cargo-hold: A CI tool to ensure Cargo's incremental compilation is reliable by managing your caches intelligently
Documentation
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
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
//! Command-line interface definitions for cargo-hold.
//!
//! This module defines the CLI structure using clap, including all subcommands
//! and their arguments. The main entry point is the [`Cli`] struct.
//!
//! # Example
//!
//! ```no_run
//! use cargo_hold::cli::{Cli, Commands};
//!
//! // Parse command-line arguments
//! let cli = Cli::parse_args();
//!
//! // Access the parsed command
//! match &cli.command() {
//!     Commands::Anchor => println!("Running anchor command"),
//!     Commands::Voyage { gc, .. } => {
//!         println!("Running voyage with size limit: {:?}", gc.max_target_size());
//!     }
//!     _ => {}
//! }
//! ```

use std::path::{Path, PathBuf};

use clap::{Args, Parser, Subcommand};

use crate::error::{HoldError, Result};

#[cfg(test)]
mod tests;

/// Main command-line interface for cargo-hold.
///
/// This struct represents the top-level CLI configuration, containing both
/// global options that apply to all commands and the specific subcommand
/// to execute.
#[derive(Parser)]
#[command(
    name = "cargo-hold",
    bin_name = "cargo-hold",
    author,
    version,
    about = "A CI tool to ensure Cargo's incremental compilation is reliable",
    long_about = None,
    propagate_version = true
)]
pub struct Cli {
    #[command(flatten)]
    global_opts: GlobalOpts,

    #[command(subcommand)]
    command: Commands,
}

/// Global options that apply to all cargo-hold commands.
///
/// These options control the overall behavior of cargo-hold, including
/// where to find the target directory, where to store metadata, and
/// output verbosity levels.
#[derive(Parser)]
pub struct GlobalOpts {
    /// Path to the target directory (defaults to ./target)
    #[arg(
        long,
        global = true,
        default_value = "target",
        env = "CARGO_HOLD_TARGET_DIR"
    )]
    target_dir: PathBuf,

    /// Path to the metadata file (defaults to
    /// `<target-dir>/cargo-hold.metadata`)
    #[arg(long, global = true, env = "CARGO_HOLD_METADATA_PATH")]
    metadata_path: Option<PathBuf>,

    /// Enable verbose output (use multiple times for more verbosity)
    #[arg(short, long, global = true, action = clap::ArgAction::Count, env = "CARGO_HOLD_VERBOSE")]
    verbose: u8,

    /// Silence all output except for errors
    #[arg(
        short,
        long,
        global = true,
        conflicts_with = "verbose",
        env = "CARGO_HOLD_QUIET"
    )]
    quiet: bool,
}

/// Shared garbage collection arguments.
#[derive(Args, Debug, Clone, Default)]
pub struct GcArgs {
    /// Maximum target directory size (e.g., "5G", "500M", or bytes)
    #[arg(long, env = "CARGO_HOLD_MAX_TARGET_SIZE")]
    max_target_size: Option<String>,

    /// Additional binaries to preserve in ~/.cargo/bin (comma-separated)
    #[arg(
        long,
        value_delimiter = ',',
        env = "CARGO_HOLD_PRESERVE_CARGO_BINARIES"
    )]
    preserve_cargo_binaries: Vec<String>,
}

impl GcArgs {
    /// Build GC args for programmatic use.
    pub fn new(max_target_size: Option<String>, preserve_cargo_binaries: Vec<String>) -> Self {
        Self {
            max_target_size,
            preserve_cargo_binaries,
        }
    }

    /// Get the max target size flag.
    pub fn max_target_size(&self) -> Option<&str> {
        self.max_target_size.as_deref()
    }

    /// Get the list of binaries to preserve.
    pub fn preserve_cargo_binaries(&self) -> &[String] {
        &self.preserve_cargo_binaries
    }
}

impl GlobalOpts {
    /// Create a new builder for constructing `GlobalOpts` programmatically.
    pub fn builder() -> GlobalOptsBuilder {
        GlobalOptsBuilder::default()
    }

    /// Get the effective metadata path
    pub fn get_metadata_path(&self) -> PathBuf {
        let path = self
            .metadata_path()
            .map(|p| p.to_path_buf())
            .unwrap_or_else(|| self.target_dir().join("cargo-hold.metadata"));

        normalize_path(path)
    }

    /// Get the absolute target directory path
    pub fn get_target_dir(&self) -> PathBuf {
        normalize_path(self.target_dir())
    }

    /// Get the target directory
    pub fn target_dir(&self) -> &Path {
        &self.target_dir
    }

    /// Get the metadata path option
    pub fn metadata_path(&self) -> Option<&Path> {
        self.metadata_path.as_deref()
    }

    /// Get the verbose level
    pub fn verbose(&self) -> u8 {
        self.verbose
    }

    /// Check if quiet mode is enabled
    pub fn quiet(&self) -> bool {
        self.quiet
    }
}

/// Builder for constructing `GlobalOpts` programmatically.
///
/// This builder provides a fluent API for creating `GlobalOpts` instances
/// without going through command-line parsing. Useful for testing and
/// programmatic usage.
#[derive(Default)]
pub struct GlobalOptsBuilder {
    target_dir: Option<PathBuf>,
    metadata_path: Option<PathBuf>,
    verbose: u8,
    quiet: bool,
}

impl GlobalOptsBuilder {
    /// Set the target directory path.
    pub fn target_dir(mut self, dir: impl Into<PathBuf>) -> Self {
        self.target_dir = Some(dir.into());
        self
    }

    /// Set the metadata file path.
    pub fn metadata_path(mut self, path: Option<impl Into<PathBuf>>) -> Self {
        self.metadata_path = path.map(|p| p.into());
        self
    }

    /// Set the verbosity level (0 = normal, 1+ = verbose).
    pub fn verbose(mut self, level: u8) -> Self {
        self.verbose = level;
        self
    }

    /// Enable or disable quiet mode.
    pub fn quiet(mut self, quiet: bool) -> Self {
        self.quiet = quiet;
        self
    }

    /// Build the `GlobalOpts` instance with the configured values.
    pub fn build(self) -> GlobalOpts {
        GlobalOpts {
            target_dir: self.target_dir.unwrap_or_else(|| PathBuf::from("target")),
            metadata_path: self.metadata_path,
            verbose: self.verbose,
            quiet: self.quiet,
        }
    }
}

impl Cli {
    /// Get the global options
    pub fn global_opts(&self) -> &GlobalOpts {
        &self.global_opts
    }

    /// Get the command
    pub fn command(&self) -> &Commands {
        &self.command
    }

    /// Create a builder for programmatic construction
    pub fn builder() -> CliBuilder {
        CliBuilder::default()
    }
}

/// Builder for [`Cli`]
#[derive(Debug, Default)]
pub struct CliBuilder {
    target_dir: Option<PathBuf>,
    metadata_path: Option<PathBuf>,
    verbose: u8,
    quiet: bool,
    command: Option<Commands>,
}

impl CliBuilder {
    /// Set the target directory
    pub fn target_dir(mut self, dir: impl Into<PathBuf>) -> Self {
        self.target_dir = Some(dir.into());
        self
    }

    /// Set the metadata path
    pub fn metadata_path(mut self, path: impl Into<PathBuf>) -> Self {
        self.metadata_path = Some(path.into());
        self
    }

    /// Set the verbose level
    pub fn verbose(mut self, level: u8) -> Self {
        self.verbose = level;
        self
    }

    /// Enable quiet mode
    pub fn quiet(mut self, enabled: bool) -> Self {
        self.quiet = enabled;
        self
    }

    /// Set the command
    pub fn command(mut self, command: Commands) -> Self {
        self.command = Some(command);
        self
    }

    /// Build the Cli instance
    pub fn build(self) -> Result<Cli> {
        let command = self
            .command
            .ok_or(HoldError::ConfigError("Command is required".to_string()))?;

        Ok(Cli {
            global_opts: GlobalOpts::builder()
                .target_dir(self.target_dir.unwrap_or_else(|| PathBuf::from("target")))
                .metadata_path(self.metadata_path)
                .verbose(self.verbose)
                .quiet(self.quiet)
                .build(),
            command,
        })
    }
}

/// Normalize a path to be absolute and clean, without requiring it to exist.
///
/// This function:
/// - Converts relative paths to absolute using the current directory
/// - Removes `.` and `..` components where possible
/// - Does NOT resolve symlinks (preserves user intent)
/// - Does NOT require the path to exist
///
/// For paths that must exist, consider using canonicalize() instead.
fn normalize_path(path: impl AsRef<Path>) -> PathBuf {
    let path = path.as_ref();

    // First, make it absolute if it's relative
    let absolute = if path.is_relative() {
        std::env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("."))
            .join(path)
    } else {
        path.to_path_buf()
    };

    // Clean up the path by resolving . and .. components
    let mut components = Vec::new();
    for component in absolute.components() {
        use std::path::Component;
        match component {
            Component::ParentDir => {
                // Pop the last component if it's not a ParentDir
                if let Some(last) = components.last()
                    && !matches!(last, Component::ParentDir)
                {
                    components.pop();
                    continue;
                }
                components.push(component);
            }
            Component::CurDir => {
                // Skip . components
                continue;
            }
            _ => components.push(component),
        }
    }

    // Reconstruct the path
    let mut result = PathBuf::new();
    for component in components {
        result.push(component);
    }

    result
}

/// Available cargo-hold subcommands.
///
/// Each variant represents a different operation that cargo-hold can perform,
/// from managing timestamps and metadata to cleaning up build artifacts.
#[derive(Debug, Subcommand)]
pub enum Commands {
    /// Anchor your build state (recommended CI command)
    ///
    /// This is the main command that performs the complete workflow:
    /// 1. Restores timestamps from the metadata file based on content changes
    /// 2. Scans all Git-tracked files for modifications
    /// 3. Updates and saves the metadata with the current state
    ///
    /// Use this command in CI before running `cargo build` to ensure
    /// incremental compilation works correctly with cached artifacts.
    Anchor,

    /// Salvage file timestamps from the metadata
    ///
    /// Restores timestamps based on the previous build state:
    /// - Unchanged files: Restored to their original timestamps
    /// - Modified files: Given a new monotonic timestamp
    /// - New files: Given a new monotonic timestamp
    ///
    /// This prevents unnecessary rebuilds while ensuring changed files
    /// are properly recompiled.
    Salvage,

    /// Stow files in the cargo hold
    ///
    /// Scans all Git-tracked files and saves their current state:
    /// - Computes BLAKE3 hashes for content-based change detection
    /// - Records file sizes and modification times
    /// - Saves metadata to enable future timestamp restoration
    ///
    /// Run this after a successful build to update the metadata.
    Stow,

    /// Bilge out the metadata file
    ///
    /// Removes the metadata file, forcing a fresh start on the next run.
    /// Use this when:
    /// - You want to reset the timestamp tracking state
    /// - The metadata file has become corrupted
    /// - You're troubleshooting incremental compilation issues
    Bilge,

    /// Heave ho! Clean up old build artifacts
    ///
    /// Performs garbage collection on build artifacts to reclaim disk space:
    /// - First ensures target directory is under size limit (if specified)
    /// - Then removes artifacts older than the age threshold (default: 7 days)
    /// - Both conditions are always applied together for consistent cleanup
    /// - Always preserves: Binaries, important Cargo files, and recent
    ///   artifacts
    /// - Also cleans: ~/.cargo/registry/cache and ~/.cargo/git/checkouts
    ///
    /// Artifacts are removed by crate (all related files together) to maintain
    /// build consistency.
    Heave {
        #[command(flatten)]
        gc: GcArgs,

        /// Show what would be deleted without actually deleting
        #[arg(long, env = "CARGO_HOLD_DRY_RUN")]
        dry_run: bool,

        /// Enable debug output for garbage collection
        #[arg(long, env = "CARGO_HOLD_DEBUG")]
        debug: bool,

        /// Age threshold in days for removing artifacts (default: 7)
        #[arg(long, default_value = "7", env = "CARGO_HOLD_AGE_THRESHOLD_DAYS")]
        age_threshold_days: u32,

        /// Enable auto max-target-size suggestions derived from prior runs.
        #[arg(long, default_value_t = true, env = "CARGO_HOLD_AUTO_MAX_TARGET_SIZE")]
        auto_max_target_size: bool,
    },

    /// Full voyage - anchor and heave in one command
    ///
    /// Combines the anchor and heave commands for a complete CI workflow:
    /// 1. First runs anchor to restore timestamps and update metadata
    /// 2. Then runs heave to clean up old artifacts and manage disk usage
    ///
    /// This is ideal for CI pipelines that need both timestamp management
    /// and disk space control in a single command.
    Voyage {
        #[command(flatten)]
        gc: GcArgs,

        /// Show what would be deleted without actually deleting
        #[arg(long, env = "CARGO_HOLD_GC_DRY_RUN")]
        gc_dry_run: bool,

        /// Enable debug output for garbage collection
        #[arg(long, env = "CARGO_HOLD_GC_DEBUG")]
        gc_debug: bool,

        /// Age threshold in days for garbage collection (default: 7)
        #[arg(long, default_value = "7", env = "CARGO_HOLD_GC_AGE_THRESHOLD_DAYS")]
        gc_age_threshold_days: u32,

        /// Enable auto max-target-size suggestions derived from prior runs.
        #[arg(long, default_value_t = true, env = "CARGO_HOLD_AUTO_MAX_TARGET_SIZE")]
        gc_auto_max_target_size: bool,
    },
}

impl Cli {
    /// Parse command line arguments, handling the cargo subcommand case
    pub fn parse_args() -> Self {
        let args: Vec<String> = std::env::args().collect();

        // When invoked as `cargo hold`, cargo passes "hold" as the first argument
        // We need to skip it to parse the actual subcommand
        if args.len() >= 2 && args[1] == "hold" {
            // Skip the "hold" argument by reconstructing args without it
            let mut new_args = vec![args[0].clone()]; // program name
            new_args.extend_from_slice(&args[2..]); // rest of arguments after "hold"
            return Self::parse_from(new_args);
        }

        // Normal parsing if not invoked through cargo
        Self::parse()
    }
}