Files
mdrs-client-rust/src/commands/mv.rs
T
Yoshihiro OKUMURA a16f73543d fix: stream transfers and close gaps against the Python client
A review against the Python client, which shares this client's config
and login cache, found the transfers holding whole files in memory and
several commands answering differently from their Python counterparts.

- Stream uploads and downloads instead of buffering the whole file, so
  memory no longer scales with file size times concurrency. Uploads
  still declare a Content-Length rather than going out chunked.
- Write a download beside its destination and move it into place once
  complete, and refuse a destination that cannot be written, so a
  failed transfer leaves what was already there untouched.
- Report the server's own error detail instead of the bare status.
- List locked sub-folders in `ls --json --recursive` using the given
  password and skip only those that cannot be unlocked, instead of
  failing the whole listing.
- Name each folder's laboratory in JSON output and sort its entries,
  matching the Python client's schema and order.
- Ask the server who is logged in for `whoami`, rather than trusting
  the cached name.
- Collapse repeated separators and refuse `..` in remote paths.
- Fall back to the API when a laboratory is missing from the cache, so
  a newly added one no longer needs a fresh login.
- Verify the login cache digest on read, as the Python client does.
- Accept `-e` as the short form of `--exclude`.
- Satisfy clippy and rustfmt across the crate.
2026-09-04 16:31:14 +09:00

118 lines
4.4 KiB
Rust

use crate::cache::{create_authenticated_conn, load_cache_with_token_refresh};
use crate::commands::shared::{
find_file_by_name, find_folder, find_laboratory, find_subfolder_by_name, nfc, parse_remote_path,
};
use crate::error::response_error;
use anyhow::bail;
pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), anyhow::Error> {
let (s_remote, s_lab, s_path) = parse_remote_path(src_path)?;
let dest_ends_with_slash = dest_path.ends_with('/');
let (d_remote, d_lab, d_path) = parse_remote_path(dest_path)?;
if s_remote != d_remote {
bail!("Remote host mismatched.");
}
if s_lab != d_lab {
bail!("Laboratory mismatched.");
}
let cache = load_cache_with_token_refresh(&s_remote).await?;
let conn = create_authenticated_conn(&s_remote, &cache)?;
let lab = find_laboratory(&conn, Some(&cache), &s_lab).await?;
let lab_id = lab.id;
// Split source path into parent directory and target name
let (s_dirname, s_basename_raw) = split_path(&s_path);
let s_basename = nfc(&s_basename_raw);
// If dest ends with '/', treat it as a directory and preserve src basename
let (d_dirname, d_basename_raw) = if dest_ends_with_slash {
(d_path.clone(), s_basename.clone())
} else {
split_path(&d_path)
};
let d_basename = nfc(&d_basename_raw);
let s_parent_folder = find_folder(&conn, lab_id, &s_dirname, None).await?;
let s_parent_files = conn.list_all_files(&s_parent_folder.id).await?;
let d_parent_folder = find_folder(&conn, lab_id, &d_dirname, None).await?;
let d_parent_files = conn.list_all_files(&d_parent_folder.id).await?;
// Try source as a file first
if let Some(src_file) = find_file_by_name(&s_parent_files, &s_basename) {
let src_file_id = src_file.id.clone();
if find_file_by_name(&d_parent_files, &d_basename).is_some() {
bail!("File `{}` already exists.", d_basename);
}
if find_subfolder_by_name(&d_parent_folder.sub_folders, &d_basename).is_some() {
bail!(
"Cannot overwrite non-folder `{}` with folder `{}`.",
d_basename,
d_path
);
}
// No-op if source and destination are identical
if s_parent_folder.id == d_parent_folder.id && d_basename == s_basename {
return Ok(());
}
let body = serde_json::json!({"folder": d_parent_folder.id, "name": d_basename});
let resp = conn
.post_json(&format!("v3/files/{}/move/", src_file_id), &body)
.await?;
if !resp.status().is_success() {
return Err(response_error("Move failed", resp).await);
}
return Ok(());
}
// Try source as a folder
let src_folder = match find_subfolder_by_name(&s_parent_folder.sub_folders, &s_basename) {
Some(f) => f,
None => bail!("File or folder `{}` not found.", s_basename),
};
let src_folder_id = src_folder.id.clone();
if find_file_by_name(&d_parent_files, &d_basename).is_some() {
bail!(
"Cannot overwrite non-folder `{}` with folder `{}`.",
d_basename,
s_path
);
}
if let Some(d_folder) = find_subfolder_by_name(&d_parent_folder.sub_folders, &d_basename) {
if d_folder.id == src_folder_id {
bail!("`{}` and `{}` are the same folder.", s_path, s_path);
}
bail!(
"Cannot move `{}` to `{}`: Folder not empty.",
s_path,
d_path
);
}
// No-op if source and destination are identical
if s_parent_folder.id == d_parent_folder.id && s_basename == d_basename {
return Ok(());
}
let body = serde_json::json!({"parent": d_parent_folder.id, "name": d_basename});
let resp = conn
.post_json(&format!("v3/folders/{}/move/", src_folder_id), &body)
.await?;
if !resp.status().is_success() {
return Err(response_error("Move failed", resp).await);
}
Ok(())
}
/// Split a path into (parent_dir, basename).
/// e.g. "/path/to/item" -> ("/path/to", "item")
fn split_path(path: &str) -> (String, String) {
let path = path.trim_end_matches('/');
if let Some(pos) = path.rfind('/') {
let d = if pos == 0 { "/" } else { &path[..pos] };
(d.to_string(), path[pos + 1..].to_string())
} else {
("/".to_string(), path.to_string())
}
}