Skip to main content

s3util_rs/config/args/
cp.rs

1use crate::config::Config;
2use crate::config::args::common::{self, CommonTransferArgs};
3use crate::config::args::value_parser::storage_path;
4use clap::Parser;
5
6const BOTH_STDIO_SPECIFIED: &str = "source and target cannot both be stdin/stdout (-)\n";
7const STDIO_INCOMPATIBLE_WITH_SERVER_SIDE_COPY: &str =
8    "stdin/stdout (-) is incompatible with --server-side-copy\n";
9const SKIP_EXISTING_INCOMPATIBLE_WITH_STDIO_TARGET: &str =
10    "--skip-existing is not supported with stdout target (-)\n";
11const SKIP_EXISTING_INCOMPATIBLE_WITH_IF_NONE_MATCH: &str =
12    "--skip-existing cannot be used with --if-none-match\n";
13
14#[derive(Parser, Clone, Debug)]
15pub struct CpArgs {
16    #[arg(env, help = "s3://<BUCKET_NAME>[/prefix], local path, or - for stdin/stdout", value_parser = storage_path::check_storage_path, required_unless_present = "auto_complete_shell")]
17    pub source: Option<String>,
18
19    #[arg(env, help = "s3://<BUCKET_NAME>[/prefix], local path, or - for stdin/stdout", value_parser = storage_path::check_storage_path, required_unless_present = "auto_complete_shell")]
20    pub target: Option<String>,
21
22    #[command(flatten)]
23    pub common: CommonTransferArgs,
24
25    /// Skip the copy if the target already exists.
26    #[arg(
27        long,
28        env,
29        default_value_t = false,
30        help_heading = "Advanced",
31        long_help = r#"Skip the copy if the target already exists.
32The target's content is not verified — only its existence is checked
33(S3 HeadObject for s3:// targets, filesystem exists check for local targets).
34When the target exists the command exits 0 with no transfer performed.
35Cannot be used with --if-none-match or with stdout target (-)."#
36    )]
37    pub skip_existing: bool,
38}
39
40impl CpArgs {
41    pub fn auto_complete_shell(&self) -> Option<clap_complete::shells::Shell> {
42        self.common.auto_complete_shell
43    }
44
45    pub(crate) fn source_str(&self) -> &str {
46        self.source.as_deref().unwrap_or("")
47    }
48
49    pub(crate) fn target_str(&self) -> &str {
50        self.target.as_deref().unwrap_or("")
51    }
52
53    pub(crate) fn is_source_stdio(&self) -> bool {
54        common::is_source_stdio(self.source_str())
55    }
56
57    pub(crate) fn is_target_stdio(&self) -> bool {
58        common::is_target_stdio(self.target_str())
59    }
60
61    pub(crate) fn check_both_stdio(&self) -> Result<(), String> {
62        if self.is_source_stdio() && self.is_target_stdio() {
63            return Err(BOTH_STDIO_SPECIFIED.to_string());
64        }
65        Ok(())
66    }
67
68    pub(crate) fn check_stdio_server_side_copy_conflict(&self) -> Result<(), String> {
69        if self.common.server_side_copy && (self.is_source_stdio() || self.is_target_stdio()) {
70            return Err(STDIO_INCOMPATIBLE_WITH_SERVER_SIDE_COPY.to_string());
71        }
72        Ok(())
73    }
74
75    pub(crate) fn check_skip_existing_stdio_target(&self) -> Result<(), String> {
76        if self.skip_existing && self.is_target_stdio() {
77            return Err(SKIP_EXISTING_INCOMPATIBLE_WITH_STDIO_TARGET.to_string());
78        }
79        Ok(())
80    }
81
82    pub(crate) fn check_skip_existing_if_none_match_conflict(&self) -> Result<(), String> {
83        if self.skip_existing && self.common.if_none_match {
84            return Err(SKIP_EXISTING_INCOMPATIBLE_WITH_IF_NONE_MATCH.to_string());
85        }
86        Ok(())
87    }
88
89    /// Kept as a `&self` method so the existing direct-call test in
90    /// `tests.rs` continues to compile.
91    #[cfg(test)]
92    pub(crate) fn check_at_least_one_s3_or_stdio(&self) -> Result<(), String> {
93        common::check_at_least_one_s3_or_stdio(self.source_str(), self.target_str())
94    }
95
96    pub(crate) fn validate_storage_config(&self) -> Result<(), String> {
97        self.check_skip_existing_stdio_target()?;
98        self.check_skip_existing_if_none_match_conflict()?;
99        self.check_both_stdio()?;
100        self.check_stdio_server_side_copy_conflict()?;
101        self.common
102            .validate_common_storage_config(self.source_str(), self.target_str())
103    }
104}
105
106impl TryFrom<CpArgs> for Config {
107    type Error = String;
108
109    fn try_from(value: CpArgs) -> Result<Self, Self::Error> {
110        value.validate_storage_config()?;
111        let skip_existing = value.skip_existing;
112        let mut config = crate::config::args::common::build_config_from_common(
113            value.common,
114            value.source,
115            value.target,
116        )?;
117        config.skip_existing = skip_existing;
118        Ok(config)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::config::args::{Commands, parse_from_args};
126
127    fn cp_args_from(extra: &[&str]) -> CpArgs {
128        let mut args: Vec<String> = vec!["s3util".to_string(), "cp".to_string()];
129        for e in extra {
130            args.push((*e).to_string());
131        }
132        let cli = parse_from_args(args).unwrap();
133        let Commands::Cp(cp_args) = cli.command else {
134            panic!("expected Cp variant");
135        };
136        cp_args
137    }
138
139    #[test]
140    fn skip_existing_parses_to_true() {
141        let cp_args = cp_args_from(&["--skip-existing", "/tmp/a", "s3://b/k"]);
142        assert!(cp_args.skip_existing);
143    }
144
145    #[test]
146    fn skip_existing_default_is_false() {
147        let cp_args = cp_args_from(&["/tmp/a", "s3://b/k"]);
148        assert!(!cp_args.skip_existing);
149    }
150
151    #[test]
152    fn skip_existing_with_stdio_target_rejected() {
153        let cp_args = cp_args_from(&["--skip-existing", "s3://b/k", "-"]);
154        let err = cp_args.validate_storage_config().unwrap_err();
155        assert!(
156            err.contains("stdout target"),
157            "expected stdout-target error, got: {err}"
158        );
159    }
160
161    #[test]
162    fn skip_existing_with_if_none_match_rejected() {
163        let cp_args = cp_args_from(&["--skip-existing", "--if-none-match", "/tmp/a", "s3://b/k"]);
164        let err = cp_args.validate_storage_config().unwrap_err();
165        assert!(
166            err.contains("--if-none-match"),
167            "expected --if-none-match error, got: {err}"
168        );
169    }
170
171    #[test]
172    fn skip_existing_alone_with_s3_target_accepted() {
173        let cp_args = cp_args_from(&["--skip-existing", "s3://src/k", "s3://dst/k"]);
174        cp_args
175            .validate_storage_config()
176            .expect("validation must succeed");
177    }
178
179    #[test]
180    fn skip_existing_alone_with_local_target_accepted() {
181        // Local source + local target would fail check_both_local; use S3 source + local target.
182        let dir = tempfile::tempdir().unwrap();
183        let target = dir.path().join("dst.dat").to_string_lossy().to_string();
184        let cp_args = cp_args_from(&["--skip-existing", "s3://b/k", &target]);
185        cp_args
186            .validate_storage_config()
187            .expect("validation must succeed");
188    }
189
190    #[test]
191    fn skip_existing_with_stdio_source_accepted() {
192        let cp_args = cp_args_from(&["--skip-existing", "-", "s3://b/k"]);
193        cp_args
194            .validate_storage_config()
195            .expect("validation must succeed");
196    }
197
198    #[test]
199    fn skip_existing_with_server_side_copy_accepted() {
200        let cp_args = cp_args_from(&[
201            "--skip-existing",
202            "--server-side-copy",
203            "s3://src/k",
204            "s3://dst/k",
205        ]);
206        cp_args
207            .validate_storage_config()
208            .expect("validation must succeed");
209    }
210}