use std::ffi::OsStr;
use std::path::PathBuf;
use clap::{Args, ValueEnum, ValueHint};
use clap_complete::{ArgValueCompleter, CompletionCandidate};
use treeboot_core::{
FileOperationCompletionOptions, FileOperationKind, FileOperationOptions, MetadataField,
SymlinkMode, SyncCompare,
};
use super::environment_input;
#[derive(Debug, Args, Clone, Default)]
struct ManualArgs {
#[arg(short, long, value_hint = ValueHint::DirPath)]
root: Option<PathBuf>,
#[arg(short, long, value_hint = ValueHint::AnyPath)]
target: Option<PathBuf>,
#[arg(long)]
required: bool,
#[arg(short = 'S', long)]
strict: bool,
#[arg(short, long)]
force: bool,
#[arg(short = 'n', long)]
dry_run: bool,
#[arg(short, long)]
verbose: bool,
#[arg(
required = true,
num_args = 1..,
value_hint = ValueHint::AnyPath,
add = ArgValueCompleter::new(root_source_completer),
)]
sources: Vec<PathBuf>,
}
#[derive(Debug, Args, Clone, Default)]
pub(crate) struct CopyArgs {
#[command(flatten)]
manual: ManualArgs,
#[arg(long, value_enum)]
symlinks: Option<CliSymlinkMode>,
#[arg(long = "include")]
include: Vec<String>,
#[arg(long = "ignore")]
ignore: Vec<String>,
#[arg(long = "ignore-metadata", value_enum)]
ignore_metadata: Vec<CliMetadataField>,
}
#[derive(Debug, Args, Clone, Default)]
pub(crate) struct SymlinkArgs {
#[command(flatten)]
manual: ManualArgs,
}
#[derive(Debug, Args, Clone, Default)]
pub(crate) struct SyncArgs {
#[command(flatten)]
manual: ManualArgs,
#[arg(long, value_enum)]
symlinks: Option<CliSymlinkMode>,
#[arg(long = "include", conflicts_with = "delete")]
include: Vec<String>,
#[arg(long = "ignore")]
ignore: Vec<String>,
#[arg(long = "ignore-metadata", value_enum)]
ignore_metadata: Vec<CliMetadataField>,
#[arg(long, value_enum)]
compare: Option<CliSyncCompare>,
#[arg(short = 'D', long, conflicts_with = "no_delete")]
delete: bool,
#[arg(long, conflicts_with = "delete")]
no_delete: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CliSymlinkMode {
Preserve,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CliSyncCompare {
Metadata,
Checksum,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CliMetadataField {
Permissions,
Owner,
Group,
Ownership,
}
fn root_source_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let root = completion_root_override();
treeboot_core::file_operation_source_candidates(FileOperationCompletionOptions {
cwd: std::env::current_dir().ok(),
root,
environment: environment_input(),
current: PathBuf::from(current),
})
.into_iter()
.map(CompletionCandidate::new)
.collect()
}
fn completion_root_override() -> Option<PathBuf> {
let mut args = std::env::args_os();
for arg in args.by_ref() {
if arg == "--" {
break;
}
}
while let Some(arg) = args.next() {
if let Some(value) = arg.to_string_lossy().strip_prefix("--root=") {
return Some(PathBuf::from(value));
}
if arg == "--root" || arg == "-r" {
return args.next().map(PathBuf::from);
}
}
None
}
impl CopyArgs {
pub(crate) fn into_options(self) -> FileOperationOptions {
FileOperationOptions {
symlinks: self.symlinks.map(Into::into),
include: self.include,
ignore: self.ignore,
ignore_metadata: normalize_ignored_metadata(self.ignore_metadata),
..self.manual.into_options(FileOperationKind::Copy)
}
}
}
impl SymlinkArgs {
pub(crate) fn into_options(self) -> FileOperationOptions {
self.manual.into_options(FileOperationKind::Symlink)
}
}
impl SyncArgs {
pub(crate) fn into_options(self) -> FileOperationOptions {
FileOperationOptions {
symlinks: self.symlinks.map(Into::into),
include: self.include,
ignore: self.ignore,
ignore_metadata: normalize_ignored_metadata(self.ignore_metadata),
compare: self.compare.map(Into::into),
delete: sync_delete_option(self.delete, self.no_delete),
..self.manual.into_options(FileOperationKind::Sync)
}
}
}
impl ManualArgs {
fn into_options(self, operation: FileOperationKind) -> FileOperationOptions {
let mut options = match operation {
FileOperationKind::Copy => FileOperationOptions::copy(self.sources),
FileOperationKind::Symlink => FileOperationOptions::symlink(self.sources),
FileOperationKind::Sync => FileOperationOptions::sync(self.sources),
};
options.cwd = None;
options.root = self.root;
options.environment = environment_input();
options.target = self.target;
options.required = self.required;
options.strict = self.strict;
options.force = self.force;
options.dry_run = self.dry_run;
options.verbose = self.verbose;
options
}
}
const fn sync_delete_option(delete: bool, no_delete: bool) -> Option<bool> {
if delete {
Some(true)
} else if no_delete {
Some(false)
} else {
None
}
}
impl From<CliSymlinkMode> for SymlinkMode {
fn from(value: CliSymlinkMode) -> Self {
match value {
CliSymlinkMode::Preserve => Self::Preserve,
}
}
}
impl From<CliSyncCompare> for SyncCompare {
fn from(value: CliSyncCompare) -> Self {
match value {
CliSyncCompare::Metadata => Self::Metadata,
CliSyncCompare::Checksum => Self::Checksum,
}
}
}
fn normalize_ignored_metadata(fields: Vec<CliMetadataField>) -> Vec<MetadataField> {
let mut normalized = Vec::new();
for field in fields {
for expanded in field.expanded() {
if !normalized.contains(expanded) {
normalized.push(*expanded);
}
}
}
normalized
}
impl CliMetadataField {
const fn expanded(self) -> &'static [MetadataField] {
match self {
Self::Permissions => &[MetadataField::Permissions],
Self::Owner => &[MetadataField::Owner],
Self::Group => &[MetadataField::Group],
Self::Ownership => &[MetadataField::Owner, MetadataField::Group],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn manual_args() -> ManualArgs {
ManualArgs {
sources: vec![PathBuf::from(".env")],
..ManualArgs::default()
}
}
#[test]
fn sync_delete_option_should_normalize_delete_flags() {
assert_eq!(sync_delete_option(false, false), None);
assert_eq!(sync_delete_option(true, false), Some(true));
assert_eq!(sync_delete_option(false, true), Some(false));
assert_eq!(sync_delete_option(true, true), Some(true));
}
#[test]
fn copy_args_should_preserve_manual_options_and_symlink_mode() {
let args = CopyArgs {
manual: ManualArgs {
target: Some(PathBuf::from("local")),
required: true,
strict: true,
force: true,
dry_run: true,
verbose: true,
..manual_args()
},
symlinks: Some(CliSymlinkMode::Preserve),
include: Vec::new(),
ignore: vec!["**/vendor/**".to_owned()],
ignore_metadata: vec![CliMetadataField::Ownership],
};
let options = args.into_options();
assert_eq!(options.operation, FileOperationKind::Copy);
assert_eq!(options.target, Some(PathBuf::from("local")));
assert!(options.required);
assert!(options.strict);
assert!(options.force);
assert!(options.dry_run);
assert!(options.verbose);
assert_eq!(options.symlinks, Some(SymlinkMode::Preserve));
assert_eq!(options.ignore, vec!["**/vendor/**"]);
assert_eq!(
options.ignore_metadata,
vec![MetadataField::Owner, MetadataField::Group]
);
}
#[test]
fn symlink_args_should_set_symlink_operation() {
let options = SymlinkArgs {
manual: manual_args(),
}
.into_options();
assert_eq!(options.operation, FileOperationKind::Symlink);
}
#[test]
fn sync_args_should_normalize_sync_specific_options() {
let options = SyncArgs {
manual: manual_args(),
symlinks: Some(CliSymlinkMode::Preserve),
include: Vec::new(),
ignore: vec!["cache/".to_owned()],
ignore_metadata: vec![CliMetadataField::Permissions],
compare: Some(CliSyncCompare::Checksum),
delete: true,
no_delete: false,
}
.into_options();
assert_eq!(options.operation, FileOperationKind::Sync);
assert_eq!(options.symlinks, Some(SymlinkMode::Preserve));
assert_eq!(options.ignore, vec!["cache/"]);
assert_eq!(options.ignore_metadata, vec![MetadataField::Permissions]);
assert_eq!(options.compare, Some(SyncCompare::Checksum));
assert_eq!(options.delete, Some(true));
}
#[test]
fn ignored_metadata_should_deduplicate_expanded_fields() {
assert_eq!(
normalize_ignored_metadata(vec![
CliMetadataField::Owner,
CliMetadataField::Ownership,
CliMetadataField::Permissions,
]),
vec![
MetadataField::Owner,
MetadataField::Group,
MetadataField::Permissions,
]
);
}
#[test]
fn cli_value_enums_should_convert_to_core_options() {
assert_eq!(
SymlinkMode::from(CliSymlinkMode::Preserve),
SymlinkMode::Preserve
);
assert_eq!(
SyncCompare::from(CliSyncCompare::Metadata),
SyncCompare::Metadata
);
assert_eq!(
SyncCompare::from(CliSyncCompare::Checksum),
SyncCompare::Checksum
);
}
}