s3util_rs/config/args/
cp.rs1use 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 #[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 #[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 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}