1use std::path::{Path, PathBuf};
6
7use globset::{Glob, GlobSet, GlobSetBuilder};
8
9use crate::config::Limits;
10use crate::error::{Error, Result};
11use crate::guard::Guard;
12use crate::local::LocalScope;
13use crate::remote::Remote;
14
15pub const DEFAULT_EXCLUDES: &[&str] = &[
18 ".git",
19 "node_modules",
20 "target",
21 ".venv",
22 "venv",
23 "__pycache__",
24 ".DS_Store",
25 ".idea",
26 ".mypy_cache",
27 ".pytest_cache",
28 "dist",
29 "build",
30];
31
32#[derive(Debug, Default)]
33pub struct TransferReport {
34 pub files: usize,
35 pub bytes: u64,
36 pub skipped: Vec<String>,
37 pub destination: String,
38}
39
40pub struct Transfer<'a> {
41 pub remote: &'a Remote,
42 pub guard: &'a Guard,
43 pub local: &'a LocalScope,
44 pub limits: &'a Limits,
45}
46
47impl Transfer<'_> {
48 pub async fn upload(
52 &self,
53 local_raw: &str,
54 remote_raw: &str,
55 extra_excludes: &[String],
56 ) -> Result<TransferReport> {
57 let local = self.local.resolve_existing(local_raw)?;
58 let remote_base = self.remote.resolve(remote_raw).await?;
59 let excludes = build_excludes(extra_excludes)?;
60 let mut report = TransferReport::default();
61
62 if local.is_file() {
63 let dest = if remote_raw.ends_with('/')
64 || self.remote.exists(&remote_base).await? == Some(true)
65 {
66 let name = local.file_name().unwrap().to_string_lossy();
67 format!("{remote_base}/{name}")
68 } else {
69 remote_base
70 };
71 let bytes = self.send_file(&local, &dest, &mut report).await?;
72 report.bytes = bytes;
73 report.destination = dest;
74 return Ok(report);
75 }
76 if !local.is_dir() {
77 return Err(Error::Invalid(format!(
78 "`{}` is neither a file nor a directory",
79 local.display()
80 )));
81 }
82
83 let mut plan: Vec<(PathBuf, String)> = Vec::new();
85 let mut total: u64 = 0;
86 let walker = walkdir::WalkDir::new(&local)
87 .follow_links(false)
88 .into_iter();
89 for entry in walker.filter_entry(|e| !is_excluded(e.path(), &local, &excludes)) {
90 let entry = entry.map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?;
91 let ft = entry.file_type();
92 if ft.is_symlink() {
93 report
94 .skipped
95 .push(format!("{} (symlink)", entry.path().display()));
96 continue;
97 }
98 if !ft.is_file() {
99 continue;
100 }
101 let rel = entry.path().strip_prefix(&local).unwrap();
102 let rel_str = rel.to_string_lossy().replace('\\', "/");
103 total += entry.metadata().map(|m| m.len()).unwrap_or(0);
104 plan.push((
105 entry.path().to_path_buf(),
106 format!("{remote_base}/{rel_str}"),
107 ));
108 if plan.len() > self.limits.max_transfer_files {
109 return Err(Error::Invalid(format!(
110 "more than {} files; narrow the upload or add excludes",
111 self.limits.max_transfer_files
112 )));
113 }
114 }
115 if total > self.limits.max_transfer_bytes {
116 return Err(self.too_large(&format!("`{local_raw}` ({total} bytes)")));
117 }
118
119 for (path, _) in &plan {
122 self.guard.ensure_upload_file(path)?;
123 }
124
125 self.remote.ensure_dir(&remote_base).await?;
129 for (path, dest) in &plan {
130 let (_, bytes) = self.remote.upload_file(path, dest).await?;
131 report.files += 1;
132 report.bytes += bytes;
133 }
134 report.destination = remote_base;
135 Ok(report)
136 }
137
138 async fn send_file(
139 &self,
140 local: &Path,
141 dest: &str,
142 report: &mut TransferReport,
143 ) -> Result<u64> {
144 let meta = std::fs::metadata(local)?;
145 if meta.len() > self.limits.max_transfer_bytes {
146 return Err(self.too_large(&format!("`{}` ({} bytes)", local.display(), meta.len())));
147 }
148 self.guard.ensure_upload_file(local)?;
149 let (_, bytes) = self.remote.upload_file(local, dest).await?;
150 report.files += 1;
151 Ok(bytes)
152 }
153
154 fn too_large(&self, what: &str) -> Error {
158 Error::Invalid(format!(
159 "{what} exceeds the transfer limit of {} bytes; raise `max_transfer_bytes` under \
160 [limits] in .farhand.toml to allow it",
161 self.limits.max_transfer_bytes
162 ))
163 }
164
165 pub async fn download(&self, remote_raw: &str, local_raw: &str) -> Result<TransferReport> {
169 let remote_path = self.remote.resolve(remote_raw).await?;
170 let local = self.local.resolve_for_write(local_raw)?;
171 let mut report = TransferReport::default();
172 let max = self.limits.max_transfer_bytes;
173
174 match self.remote.exists(&remote_path).await? {
175 None => Err(Error::Invalid(format!(
176 "`{remote_path}` does not exist on the remote"
177 ))),
178 Some(false) => {
179 let dest = if local.is_dir() || local_raw.ends_with('/') {
180 let name = remote_path
183 .rsplit(['/', '\\'])
184 .next()
185 .unwrap_or(&remote_path);
186 local.join(name)
187 } else {
188 local
189 };
190 let dest = self.local_target(&dest)?;
191 let len = self.remote.file_size(&remote_path).await?;
192 if len > max {
193 return Err(self.too_large(&format!("`{remote_path}` ({len} bytes)")));
194 }
195 if let Some(parent) = dest.parent() {
196 std::fs::create_dir_all(parent)?;
197 }
198 let bytes = self.remote.download_file(&remote_path, &dest).await?;
199 report.files = 1;
200 report.bytes = bytes;
201 report.destination = dest.display().to_string();
202 Ok(report)
203 }
204 Some(true) => {
205 let files = self
206 .remote
207 .walk_files(&remote_path, self.limits.max_transfer_files)
208 .await?;
209 if files.len() > self.limits.max_transfer_files {
210 return Err(Error::Invalid(format!(
211 "more than {} files; narrow the download",
212 self.limits.max_transfer_files
213 )));
214 }
215 let mut plan: Vec<(String, PathBuf)> = Vec::with_capacity(files.len());
220 for f in files {
221 let rel = f
223 .strip_prefix(&remote_path)
224 .unwrap_or(&f)
225 .trim_start_matches(['/', '\\'])
226 .replace('\\', "/");
227 if rel.is_empty() || rel.split('/').any(|seg| seg == "..") {
228 report.skipped.push(f.clone());
229 continue;
230 }
231 let dest = self.local_target(&local.join(&rel))?;
232 plan.push((f, dest));
233 }
234 for (f, dest) in plan {
235 let len = self.remote.file_size(&f).await?;
236 if report.bytes + len > max {
237 return Err(self.too_large(&format!(
238 "`{remote_raw}` (at least {} bytes)",
239 report.bytes + len
240 )));
241 }
242 if let Some(parent) = dest.parent() {
243 std::fs::create_dir_all(parent)?;
244 }
245 let bytes = self.remote.download_file(&f, &dest).await?;
246 report.files += 1;
247 report.bytes += bytes;
248 }
249 report.destination = local.display().to_string();
250 Ok(report)
251 }
252 }
253 }
254}
255
256impl Transfer<'_> {
257 fn local_target(&self, dest: &Path) -> Result<PathBuf> {
261 let dest = self.local.resolve_for_write(&dest.to_string_lossy())?;
262 let rel = self
263 .local
264 .roots()
265 .iter()
266 .find_map(|r| dest.strip_prefix(r).ok())
267 .unwrap_or(&dest);
268 self.guard.ensure_download(rel)?;
269 Ok(dest)
270 }
271}
272
273fn build_excludes(extra: &[String]) -> Result<GlobSet> {
274 let mut b = GlobSetBuilder::new();
275 for g in DEFAULT_EXCLUDES
276 .iter()
277 .map(|s| s.to_string())
278 .chain(extra.iter().cloned())
279 {
280 b.add(Glob::new(&g).map_err(|e| Error::Invalid(format!("exclude `{g}`: {e}")))?);
281 }
282 b.build().map_err(|e| Error::Invalid(e.to_string()))
283}
284
285fn is_excluded(path: &Path, root: &Path, set: &GlobSet) -> bool {
286 if path == root {
287 return false;
288 }
289 let name = path
290 .file_name()
291 .map(|n| n.to_string_lossy())
292 .unwrap_or_default();
293 if set.is_match(name.as_ref()) {
294 return true;
295 }
296 path.strip_prefix(root)
297 .map(|rel| set.is_match(rel))
298 .unwrap_or(false)
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn default_excludes_match_names_anywhere() {
307 let set = build_excludes(&["*.log".into()]).unwrap();
308 let root = Path::new("/p");
309 assert!(is_excluded(Path::new("/p/a/node_modules"), root, &set));
310 assert!(is_excluded(Path::new("/p/x.log"), root, &set));
311 assert!(!is_excluded(Path::new("/p/src/main.rs"), root, &set));
312 assert!(!is_excluded(root, root, &set));
313 }
314}