fix: align all command outputs with Python reference implementation
- fix(connection): fix create_folder API body (name, parent_id, description, template_id)
- feat(shared): add Unicode NFC normalization helper and find_subfolder_by_name()
- feat(Cargo): add unicode-normalization dependency
- fix(shared,mkdir,rm,cp,mv,upload): apply NFC normalization to path and name comparisons
- fix(labs): rewrite output as aligned table (Name/PI/Laboratory), remove cache fallback
- fix(mkdir): silent on success; align error message with Python
- fix(rm): silent on success; use find_subfolder_by_name for NFC-aware lookup
- fix(cp): silent on success; align all error messages; add no-op when src==dest
- fix(mv): silent on success; align all error messages; add no-op when src==dest
- fix(login): change output to 'Login Successful'
- fix(logout): remove all output (silent like Python)
- fix(chacl): remove success message (silent like Python)
- fix(metadata): use compact JSON output (to_string instead of to_string_pretty)
- fix(file_metadata): use compact JSON output
- fix(ls): use compact JSON output; add blank line after entries in recursive plain mode
- fix(config): silent on create/update/delete; add colon in list short format;
remove empty-state messages; align error messages ('is already exists.' / 'is not exists.')
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Generated
+1
@@ -1039,6 +1039,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"unicode-normalization",
|
||||
"validators",
|
||||
]
|
||||
|
||||
|
||||
@@ -28,3 +28,4 @@ fs2 = "0.4"
|
||||
ctrlc = "3"
|
||||
os_info = "3"
|
||||
dotenvy = "0.15"
|
||||
unicode-normalization = "0.1"
|
||||
|
||||
@@ -45,6 +45,5 @@ pub async fn chacl(
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("ACL change failed: {}", resp.status()).into());
|
||||
}
|
||||
println!("ACL changed successfully for: {}", remote_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+4
-14
@@ -64,7 +64,7 @@ pub fn config_create(remote: &str, url: &str) -> Result<(), Box<dyn std::error::
|
||||
.map(|m| m.contains_key(remote))
|
||||
.unwrap_or(false);
|
||||
if section_exists {
|
||||
return Err(format!("Remote host `{}` already exists.", remote).into());
|
||||
return Err(format!("Remote host `{}` is already exists.", remote).into());
|
||||
}
|
||||
// set url
|
||||
conf.set(remote, "url", Some(url.to_string()));
|
||||
@@ -73,7 +73,6 @@ pub fn config_create(remote: &str, url: &str) -> Result<(), Box<dyn std::error::
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
write_ini_atomic(&path, &conf)?;
|
||||
println!("Created remote host `{}`.", remote);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -94,18 +93,16 @@ pub fn config_update(remote: &str, url: &str) -> Result<(), Box<dyn std::error::
|
||||
.map(|m| m.contains_key(remote))
|
||||
.unwrap_or(false);
|
||||
if !section_exists {
|
||||
return Err(format!("Remote host `{}` does not exist.", remote).into());
|
||||
return Err(format!("Remote host `{}` is not exists.", remote).into());
|
||||
}
|
||||
conf.set(remote, "url", Some(url.to_string()));
|
||||
write_ini_atomic(&path, &conf)?;
|
||||
println!("Updated remote host `{}`.", remote);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn config_list(long: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let path = config_path();
|
||||
if !path.exists() {
|
||||
println!("No config file found at {}", path.to_string_lossy());
|
||||
return Ok(());
|
||||
}
|
||||
sanitize_config_file(&path)?;
|
||||
@@ -113,23 +110,17 @@ pub fn config_list(long: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut conf = Ini::new();
|
||||
let _ = conf.load(&path_str)?;
|
||||
let map = conf.get_map().unwrap_or_default();
|
||||
let mut printed = false;
|
||||
for (sec, props) in map.iter() {
|
||||
if sec == "default" {
|
||||
continue;
|
||||
}
|
||||
if !long {
|
||||
println!("{}", sec);
|
||||
printed = true;
|
||||
println!("{}:", sec);
|
||||
} else {
|
||||
let url = props.get("url").and_then(|v| v.clone()).unwrap_or_default();
|
||||
println!("{}:\t{}", sec, url);
|
||||
printed = true;
|
||||
}
|
||||
}
|
||||
if !printed {
|
||||
println!("No remotes configured");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -144,7 +135,7 @@ pub fn config_delete(remote: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// fallback: reconstruct by removing the section in memory map and writing file
|
||||
let mut map = conf.get_map().unwrap_or_default();
|
||||
if map.remove(remote).is_none() {
|
||||
return Err(format!("Remote host `{}` does not exist.", remote).into());
|
||||
return Err(format!("Remote host `{}` is not exists.", remote).into());
|
||||
}
|
||||
// build new Ini from map
|
||||
let mut new_ini = Ini::new();
|
||||
@@ -154,7 +145,6 @@ pub fn config_delete(remote: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
write_ini_atomic(&path, &new_ini)?;
|
||||
println!("Deleted remote host `{}`.", remote);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+52
-44
@@ -1,6 +1,6 @@
|
||||
use crate::commands::shared::{
|
||||
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
|
||||
parse_remote_path,
|
||||
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache,
|
||||
find_subfolder_by_name, load_cache_with_token_refresh, nfc, parse_remote_path,
|
||||
};
|
||||
|
||||
pub async fn cp(
|
||||
@@ -13,10 +13,10 @@ pub async fn cp(
|
||||
let (d_remote, d_lab, d_path) = parse_remote_path(dest_path)?;
|
||||
|
||||
if s_remote != d_remote {
|
||||
return Err("Source and destination must use the same remote.".into());
|
||||
return Err("Remote host mismatched.".into());
|
||||
}
|
||||
if s_lab != d_lab {
|
||||
return Err("Source and destination must be in the same laboratory.".into());
|
||||
return Err("Laboratory mismatched.".into());
|
||||
}
|
||||
|
||||
let cache = load_cache_with_token_refresh(&s_remote).await?;
|
||||
@@ -28,11 +28,12 @@ pub async fn cp(
|
||||
let (s_dirname, s_basename) = split_path(&s_path);
|
||||
|
||||
// If dest ends with '/', treat it as a directory and preserve src basename
|
||||
let (d_dirname, d_basename) = if dest_ends_with_slash {
|
||||
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?;
|
||||
@@ -46,12 +47,16 @@ pub async fn cp(
|
||||
if find_file_by_name(&d_parent_files, &d_basename).is_some() {
|
||||
return Err(format!("File `{}` already exists.", d_basename).into());
|
||||
}
|
||||
if d_parent_folder
|
||||
.sub_folders
|
||||
.iter()
|
||||
.any(|f| f.name.to_lowercase() == d_basename.to_lowercase())
|
||||
{
|
||||
return Err("Cannot overwrite non-folder with folder.".into());
|
||||
if find_subfolder_by_name(&d_parent_folder.sub_folders, &d_basename).is_some() {
|
||||
return Err(format!(
|
||||
"Cannot overwrite non-folder `{}` with folder `{}`.",
|
||||
d_basename, d_path
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// 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
|
||||
@@ -64,48 +69,51 @@ pub async fn cp(
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Copy failed: {}", resp.status()).into());
|
||||
}
|
||||
println!("Copied: {} -> {}", src_path, dest_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try source as a folder
|
||||
if let Some(src_folder) = s_parent_folder
|
||||
.sub_folders
|
||||
.iter()
|
||||
.find(|f| f.name.to_lowercase() == s_basename.to_lowercase())
|
||||
{
|
||||
if !recursive {
|
||||
let src_folder = match find_subfolder_by_name(&s_parent_folder.sub_folders, &s_basename) {
|
||||
Some(f) => f,
|
||||
None => return Err(format!("File or folder `{}` not found.", s_basename).into()),
|
||||
};
|
||||
if !recursive {
|
||||
return Err(format!("Cannot copy `{}`: Is a folder.", s_path).into());
|
||||
}
|
||||
let src_folder_id = src_folder.id.clone();
|
||||
if find_file_by_name(&d_parent_files, &d_basename).is_some() {
|
||||
return Err(format!(
|
||||
"Cannot overwrite non-folder `{}` with folder `{}`.",
|
||||
d_basename, s_path
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if let Some(d_folder) = find_subfolder_by_name(&d_parent_folder.sub_folders, &d_basename) {
|
||||
if d_folder.id == src_folder_id {
|
||||
return Err(
|
||||
format!("{}: is a folder (use -r to copy folders)", src_path).into(),
|
||||
format!("`{}` and `{}` are the same folder.", s_path, s_path).into(),
|
||||
);
|
||||
}
|
||||
let src_folder_id = src_folder.id.clone();
|
||||
if find_file_by_name(&d_parent_files, &d_basename).is_some() {
|
||||
return Err(format!("File `{}` already exists.", d_basename).into());
|
||||
}
|
||||
if d_parent_folder
|
||||
.sub_folders
|
||||
.iter()
|
||||
.any(|f| f.name.to_lowercase() == d_basename.to_lowercase())
|
||||
{
|
||||
return Err("Folder not empty.".into());
|
||||
}
|
||||
let body = serde_json::json!({"parent": d_parent_folder.id, "name": d_basename});
|
||||
let resp = conn
|
||||
.client
|
||||
.post(conn.build_url(&format!("v3/folders/{}/copy/", src_folder_id)))
|
||||
.headers(conn.prepare_headers())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Copy failed: {}", resp.status()).into());
|
||||
}
|
||||
println!("Copied: {} -> {}", src_path, dest_path);
|
||||
return Err(
|
||||
format!("Cannot move `{}` to `{}`: Folder not empty.", s_path, d_path).into(),
|
||||
);
|
||||
}
|
||||
// No-op if source and destination are identical
|
||||
if s_parent_folder.id == d_parent_folder.id && s_basename == d_basename {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!("Source `{}` not found.", src_path).into())
|
||||
let body = serde_json::json!({"parent": d_parent_folder.id, "name": d_basename});
|
||||
let resp = conn
|
||||
.client
|
||||
.post(conn.build_url(&format!("v3/folders/{}/copy/", src_folder_id)))
|
||||
.headers(conn.prepare_headers())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Copy failed: {}", resp.status()).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split a path into (parent_dir, basename).
|
||||
|
||||
@@ -28,6 +28,6 @@ pub async fn file_metadata(remote_path: &str, password: Option<&str>) -> Result<
|
||||
|
||||
let resp = conn.get(&format!("v3/files/{}/metadata/", file.id)).await?;
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
println!("{}", serde_json::to_string_pretty(&json)?);
|
||||
println!("{}", serde_json::to_string(&json)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+29
-48
@@ -1,56 +1,37 @@
|
||||
use crate::connection::MDRSConnection;
|
||||
use crate::commands::shared::{create_authenticated_conn, load_cache_with_token_refresh};
|
||||
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
pub async fn labs(remote: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cache = load_cache_with_token_refresh(remote).await?;
|
||||
let conn = create_authenticated_conn(remote, &cache)?;
|
||||
let labs = conn.list_laboratories().await?;
|
||||
|
||||
pub async fn labs(
|
||||
conn: Arc<MDRSConnection>,
|
||||
remote_label: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Try API first
|
||||
match conn.list_laboratories().await {
|
||||
Ok(labs) => {
|
||||
println!("Laboratories:");
|
||||
for lab in labs.items {
|
||||
println!(
|
||||
" {} (PI: {}, Full: {})",
|
||||
lab.name, lab.pi_name, lab.full_name
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
// fallback to cache
|
||||
}
|
||||
let header = ("Name", "PI", "Laboratory");
|
||||
let mut w_name = header.0.len();
|
||||
let mut w_pi = header.1.len();
|
||||
let mut w_full = header.2.len();
|
||||
|
||||
for lab in &labs.items {
|
||||
w_name = w_name.max(lab.name.len());
|
||||
w_pi = w_pi.max(lab.pi_name.len());
|
||||
w_full = w_full.max(lab.full_name.len());
|
||||
}
|
||||
|
||||
// fallback: read cache file using remote_label
|
||||
let cache_path = crate::settings::SETTINGS
|
||||
.config_dirname
|
||||
.join("cache")
|
||||
.join(format!("{}.json", remote_label));
|
||||
if !cache_path.exists() {
|
||||
println!("No laboratories available (API failed and no cache)");
|
||||
return Ok(());
|
||||
}
|
||||
let text = fs::read_to_string(&cache_path)?;
|
||||
let v: serde_json::Value = serde_json::from_str(&text)?;
|
||||
// Cache stores laboratories as `{"items": [...]}` (Python-compatible format)
|
||||
let labs_arr = v
|
||||
.get("laboratories")
|
||||
.and_then(|l| l.get("items"))
|
||||
.and_then(|a| a.as_array());
|
||||
if let Some(arr) = labs_arr {
|
||||
println!("Laboratories (from cache):");
|
||||
for lab in arr {
|
||||
let name = lab.get("name").and_then(|s| s.as_str()).unwrap_or("");
|
||||
let pi = lab.get("pi_name").and_then(|s| s.as_str()).unwrap_or("");
|
||||
let full = lab.get("full_name").and_then(|s| s.as_str()).unwrap_or("");
|
||||
println!(" {} (PI: {}, Full: {})", name, pi, full);
|
||||
}
|
||||
} else {
|
||||
println!("No laboratories found in cache");
|
||||
println!(
|
||||
"{:<w_name$} {:<w_pi$} {:<w_full$}",
|
||||
header.0, header.1, header.2,
|
||||
w_name = w_name, w_pi = w_pi, w_full = w_full,
|
||||
);
|
||||
let sep_len = w_name + 2 + w_pi + 2 + w_full;
|
||||
println!("{}", "-".repeat(sep_len));
|
||||
|
||||
for lab in &labs.items {
|
||||
println!(
|
||||
"{:<w_name$} {:<w_pi$} {:<w_full$}",
|
||||
lab.name, lab.pi_name, lab.full_name,
|
||||
w_name = w_name, w_pi = w_pi, w_full = w_full,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ pub async fn login(
|
||||
}
|
||||
fs::rename(&tmp, &cache_file)?;
|
||||
|
||||
println!("Login successful and cached for {}.", remote);
|
||||
println!("Login Successful");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
use std::fs;
|
||||
|
||||
pub fn logout(remote: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cache_path = crate::settings::SETTINGS
|
||||
.config_dirname
|
||||
.join("cache")
|
||||
.join(format!("{}.json", remote));
|
||||
if cache_path.exists() {
|
||||
fs::remove_file(&cache_path)?;
|
||||
println!("Logged out from {}", remote);
|
||||
} else {
|
||||
println!("No login cache found for {}", remote);
|
||||
std::fs::remove_file(&cache_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+3
-1
@@ -29,7 +29,7 @@ pub async fn ls(
|
||||
} else {
|
||||
build_folder_json_flat(&conn, &folder, &labname).await?
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&output)?);
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
} else if is_recursive {
|
||||
let prefix = format!("{}:/{}", remote, labname);
|
||||
ls_plain_recursive(&conn, folder, &labname, &prefix, password).await?;
|
||||
@@ -167,6 +167,8 @@ fn ls_plain_recursive<'a>(
|
||||
|
||||
print_folder_plain(&sub_folders, &files_sorted, access, labname, false);
|
||||
|
||||
println!();
|
||||
|
||||
for sf in sub_folders {
|
||||
if sf.lock {
|
||||
match password {
|
||||
|
||||
@@ -13,6 +13,6 @@ pub async fn metadata(remote_path: &str, password: Option<&str>) -> Result<(), B
|
||||
.get(&format!("v3/folders/{}/metadata/", folder.id))
|
||||
.await?;
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
println!("{}", serde_json::to_string_pretty(&json)?);
|
||||
println!("{}", serde_json::to_string(&json)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+8
-15
@@ -1,6 +1,6 @@
|
||||
use crate::commands::shared::{
|
||||
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
|
||||
parse_remote_path,
|
||||
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache,
|
||||
find_subfolder_by_name, load_cache_with_token_refresh, nfc, parse_remote_path,
|
||||
};
|
||||
|
||||
pub async fn mkdir(remote_path: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -26,26 +26,19 @@ pub async fn mkdir(remote_path: &str) -> Result<(), Box<dyn std::error::Error>>
|
||||
let lab = find_lab_in_cache(&cache, &labname)?;
|
||||
let parent_folder = find_folder(&conn, lab.id, parent_path, None).await?;
|
||||
|
||||
// Check for name conflict in sub-folders
|
||||
if parent_folder
|
||||
.sub_folders
|
||||
.iter()
|
||||
.any(|f| f.name == new_folder_name)
|
||||
{
|
||||
return Err(format!("'{}' already exists as a folder", new_folder_name).into());
|
||||
}
|
||||
// Check for name conflict in files
|
||||
// Check for name conflict in sub-folders or files
|
||||
let files = conn.list_all_files(&parent_folder.id).await?;
|
||||
if find_file_by_name(&files, new_folder_name).is_some() {
|
||||
return Err(format!("'{}' already exists as a file", new_folder_name).into());
|
||||
if find_subfolder_by_name(&parent_folder.sub_folders, new_folder_name).is_some()
|
||||
|| find_file_by_name(&files, new_folder_name).is_some()
|
||||
{
|
||||
return Err(format!("Cannot create folder `{}`: File exists.", path).into());
|
||||
}
|
||||
|
||||
let resp = conn
|
||||
.create_folder(&parent_folder.id, new_folder_name)
|
||||
.create_folder(&parent_folder.id, &nfc(new_folder_name))
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Failed to create folder: {}", resp.status()).into());
|
||||
}
|
||||
println!("Created folder: {}", new_folder_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+51
-41
@@ -1,6 +1,6 @@
|
||||
use crate::commands::shared::{
|
||||
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
|
||||
parse_remote_path,
|
||||
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache,
|
||||
find_subfolder_by_name, load_cache_with_token_refresh, nfc, parse_remote_path,
|
||||
};
|
||||
|
||||
pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -9,10 +9,10 @@ pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), Box<dyn std::erro
|
||||
let (d_remote, d_lab, d_path) = parse_remote_path(dest_path)?;
|
||||
|
||||
if s_remote != d_remote {
|
||||
return Err("Source and destination must use the same remote.".into());
|
||||
return Err("Remote host mismatched.".into());
|
||||
}
|
||||
if s_lab != d_lab {
|
||||
return Err("Source and destination must be in the same laboratory.".into());
|
||||
return Err("Laboratory mismatched.".into());
|
||||
}
|
||||
|
||||
let cache = load_cache_with_token_refresh(&s_remote).await?;
|
||||
@@ -24,11 +24,12 @@ pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), Box<dyn std::erro
|
||||
let (s_dirname, s_basename) = split_path(&s_path);
|
||||
|
||||
// If dest ends with '/', treat it as a directory and preserve src basename
|
||||
let (d_dirname, d_basename) = if dest_ends_with_slash {
|
||||
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?;
|
||||
@@ -42,12 +43,16 @@ pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), Box<dyn std::erro
|
||||
if find_file_by_name(&d_parent_files, &d_basename).is_some() {
|
||||
return Err(format!("File `{}` already exists.", d_basename).into());
|
||||
}
|
||||
if d_parent_folder
|
||||
.sub_folders
|
||||
.iter()
|
||||
.any(|f| f.name.to_lowercase() == d_basename.to_lowercase())
|
||||
{
|
||||
return Err("Cannot overwrite non-folder with folder.".into());
|
||||
if find_subfolder_by_name(&d_parent_folder.sub_folders, &d_basename).is_some() {
|
||||
return Err(format!(
|
||||
"Cannot overwrite non-folder `{}` with folder `{}`.",
|
||||
d_basename, d_path
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// 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
|
||||
@@ -60,43 +65,48 @@ pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), Box<dyn std::erro
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Move failed: {}", resp.status()).into());
|
||||
}
|
||||
println!("Moved: {} -> {}", src_path, dest_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try source as a folder
|
||||
if let Some(src_folder) = s_parent_folder
|
||||
.sub_folders
|
||||
.iter()
|
||||
.find(|f| f.name.to_lowercase() == s_basename.to_lowercase())
|
||||
{
|
||||
let src_folder_id = src_folder.id.clone();
|
||||
if find_file_by_name(&d_parent_files, &d_basename).is_some() {
|
||||
return Err(format!("File `{}` already exists.", d_basename).into());
|
||||
let src_folder = match find_subfolder_by_name(&s_parent_folder.sub_folders, &s_basename) {
|
||||
Some(f) => f,
|
||||
None => return Err(format!("File or folder `{}` not found.", s_basename).into()),
|
||||
};
|
||||
let src_folder_id = src_folder.id.clone();
|
||||
if find_file_by_name(&d_parent_files, &d_basename).is_some() {
|
||||
return Err(format!(
|
||||
"Cannot overwrite non-folder `{}` with folder `{}`.",
|
||||
d_basename, s_path
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if let Some(d_folder) = find_subfolder_by_name(&d_parent_folder.sub_folders, &d_basename) {
|
||||
if d_folder.id == src_folder_id {
|
||||
return Err(
|
||||
format!("`{}` and `{}` are the same folder.", s_path, s_path).into(),
|
||||
);
|
||||
}
|
||||
if d_parent_folder
|
||||
.sub_folders
|
||||
.iter()
|
||||
.any(|f| f.name.to_lowercase() == d_basename.to_lowercase())
|
||||
{
|
||||
return Err("Folder not empty.".into());
|
||||
}
|
||||
let body = serde_json::json!({"parent": d_parent_folder.id, "name": d_basename});
|
||||
let resp = conn
|
||||
.client
|
||||
.post(conn.build_url(&format!("v3/folders/{}/move/", src_folder_id)))
|
||||
.headers(conn.prepare_headers())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Move failed: {}", resp.status()).into());
|
||||
}
|
||||
println!("Moved: {} -> {}", src_path, dest_path);
|
||||
return Err(
|
||||
format!("Cannot move `{}` to `{}`: Folder not empty.", s_path, d_path).into(),
|
||||
);
|
||||
}
|
||||
// No-op if source and destination are identical
|
||||
if s_parent_folder.id == d_parent_folder.id && s_basename == d_basename {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!("Source `{}` not found.", src_path).into())
|
||||
let body = serde_json::json!({"parent": d_parent_folder.id, "name": d_basename});
|
||||
let resp = conn
|
||||
.client
|
||||
.post(conn.build_url(&format!("v3/folders/{}/move/", src_folder_id)))
|
||||
.headers(conn.prepare_headers())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Move failed: {}", resp.status()).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split a path into (parent_dir, basename).
|
||||
|
||||
+5
-11
@@ -1,6 +1,6 @@
|
||||
use crate::commands::shared::{
|
||||
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
|
||||
parse_remote_path,
|
||||
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache,
|
||||
find_subfolder_by_name, load_cache_with_token_refresh, parse_remote_path,
|
||||
};
|
||||
|
||||
pub async fn rm(remote_path: &str, recursive: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -36,18 +36,13 @@ pub async fn rm(remote_path: &str, recursive: bool) -> Result<(), Box<dyn std::e
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Failed to delete file: {}", resp.status()).into());
|
||||
}
|
||||
println!("Deleted file: {}", target_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if target is a sub-folder
|
||||
if let Some(subfolder) = parent_folder
|
||||
.sub_folders
|
||||
.iter()
|
||||
.find(|f| f.name == target_name)
|
||||
{
|
||||
if let Some(subfolder) = find_subfolder_by_name(&parent_folder.sub_folders, target_name) {
|
||||
if !recursive {
|
||||
return Err(format!("'{}': Is a folder", target_name).into());
|
||||
return Err(format!("Cannot remove `{}`: Is a folder.", path).into());
|
||||
}
|
||||
let resp = conn
|
||||
.client
|
||||
@@ -59,9 +54,8 @@ pub async fn rm(remote_path: &str, recursive: bool) -> Result<(), Box<dyn std::e
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Failed to delete folder: {}", resp.status()).into());
|
||||
}
|
||||
println!("Deleted folder: {}", target_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!("'{}': No such file or directory", target_name).into())
|
||||
Err(format!("Cannot remove `{}`: No such file or folder.", path).into())
|
||||
}
|
||||
|
||||
+18
-5
@@ -1,11 +1,12 @@
|
||||
use crate::models::file::File;
|
||||
use crate::models::folder::FolderDetail;
|
||||
use crate::models::folder::{FolderDetail, FolderSimple};
|
||||
use crate::connection::MDRSConnection;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache structs — matching Python's cache format exactly
|
||||
@@ -376,6 +377,11 @@ pub fn find_lab_in_cache<'a>(
|
||||
.ok_or_else(|| format!("Laboratory `{}` not found.", labname).into())
|
||||
}
|
||||
|
||||
/// Apply Unicode NFC normalization to a string.
|
||||
pub fn nfc(s: &str) -> String {
|
||||
s.chars().nfc().collect()
|
||||
}
|
||||
|
||||
/// Resolve a folder by path using the API (GET v3/folders/?path=...&laboratory_id=...)
|
||||
pub async fn find_folder(
|
||||
conn: &MDRSConnection,
|
||||
@@ -383,7 +389,8 @@ pub async fn find_folder(
|
||||
path: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<FolderDetail, Box<dyn std::error::Error>> {
|
||||
let folders = conn.list_folders_by_path(lab_id, path).await?;
|
||||
let normalized_path = nfc(path);
|
||||
let folders = conn.list_folders_by_path(lab_id, &normalized_path).await?;
|
||||
if folders.is_empty() {
|
||||
return Err(format!("Folder `{}` not found.", path).into());
|
||||
}
|
||||
@@ -409,10 +416,16 @@ pub async fn find_folder(
|
||||
Ok(folder)
|
||||
}
|
||||
|
||||
/// Find a file by name (case-insensitive) in a file list
|
||||
/// Find a file by name (NFC-normalized, case-insensitive) in a file list.
|
||||
pub fn find_file_by_name<'a>(files: &'a [File], name: &str) -> Option<&'a File> {
|
||||
let name_lower = name.to_lowercase();
|
||||
files.iter().find(|f| f.name.to_lowercase() == name_lower)
|
||||
let name_lower = nfc(name).to_lowercase();
|
||||
files.iter().find(|f| nfc(&f.name).to_lowercase() == name_lower)
|
||||
}
|
||||
|
||||
/// Find a sub-folder by name (NFC-normalized, case-insensitive).
|
||||
pub fn find_subfolder_by_name<'a>(subfolders: &'a [FolderSimple], name: &str) -> Option<&'a FolderSimple> {
|
||||
let name_lower = nfc(name).to_lowercase();
|
||||
subfolders.iter().find(|f| nfc(&f.name).to_lowercase() == name_lower)
|
||||
}
|
||||
|
||||
/// Format an ISO 8601 timestamp as "YYYY/MM/DD HH:MM:SS"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::models::folder::FolderSimple;
|
||||
use crate::commands::shared::{
|
||||
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh,
|
||||
parse_remote_path,
|
||||
create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache,
|
||||
load_cache_with_token_refresh, nfc, parse_remote_path,
|
||||
};
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use std::path::PathBuf;
|
||||
@@ -128,10 +128,10 @@ async fn find_or_create_folder(
|
||||
existing: &[FolderSimple],
|
||||
name: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
if let Some(sf) = existing.iter().find(|f| f.name == name) {
|
||||
if let Some(sf) = existing.iter().find(|f| nfc(&f.name).to_lowercase() == nfc(name).to_lowercase()) {
|
||||
return Ok(sf.id.clone());
|
||||
}
|
||||
let resp = conn.create_folder(parent_id, name).await?;
|
||||
let resp = conn.create_folder(parent_id, &nfc(name)).await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Failed to create remote folder: {}", name).into());
|
||||
}
|
||||
|
||||
+6
-1
@@ -103,7 +103,12 @@ impl MDRSConnection {
|
||||
parent_id: &str,
|
||||
folder_name: &str,
|
||||
) -> reqwest::Result<reqwest::Response> {
|
||||
let body = serde_json::json!({"parent": parent_id, "name": folder_name});
|
||||
let body = serde_json::json!({
|
||||
"name": folder_name,
|
||||
"parent_id": parent_id,
|
||||
"description": "",
|
||||
"template_id": -1,
|
||||
});
|
||||
self.client
|
||||
.post(self.build_url("v3/folders/"))
|
||||
.headers(self.prepare_headers())
|
||||
|
||||
+3
-15
@@ -275,21 +275,9 @@ fn main() {
|
||||
}
|
||||
Commands::Labs { remote } => {
|
||||
let remote = remote.trim_end_matches(':');
|
||||
match crate::commands::config::get_remote_url(remote) {
|
||||
Ok(Some(url)) => {
|
||||
let conn = std::sync::Arc::new(crate::connection::MDRSConnection::new(&url));
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
if let Err(e) = rt.block_on(crate::commands::labs::labs(conn.clone(), remote)) {
|
||||
handle_error(e);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
eprintln!("Error: Remote host `{}` is not configured", remote);
|
||||
std::process::exit(2);
|
||||
}
|
||||
Err(e) => {
|
||||
handle_error(e);
|
||||
}
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
if let Err(e) = rt.block_on(commands::labs::labs(remote)) {
|
||||
handle_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user