diff --git a/README.md b/README.md index a1581e3..909bc26 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ mdrs labs neurodata: ### ls -List the contents of a remote folder. +List the contents of a remote folder. You can also specify a DOI path in the form `remote:10.xxxx/yyy.ID[/optional/subpath]`. ```shell mdrs ls neurodata:/NIU/Repository/ @@ -111,6 +111,10 @@ mdrs ls -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/ mdrs ls -r neurodata:/NIU/Repository/Dataset1/ mdrs ls -J -r neurodata:/NIU/Repository/Dataset1/ mdrs ls -q neurodata:/NIU/Repository/ + +# DOI access examples: +mdrs ls neurodata:10.60178/cbs.20260429-001 +mdrs ls "neurodata:10.60178/cbs.20260429-001/Figure 1" ``` ### mkdir @@ -133,7 +137,7 @@ mdrs upload -r --skip-if-exists ./dataset neurodata:/NIU/Repository/TEST/ ### download -Download a file or folder from a remote path. +Download a file or folder from a remote path. You can also specify a DOI path. ```shell mdrs download neurodata:/NIU/Repository/TEST/sample.dat ./ @@ -141,6 +145,9 @@ mdrs download -r neurodata:/NIU/Repository/TEST/dataset/ ./ mdrs download -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.dat ./ mdrs download -r --exclude /NIU/Repository/TEST/dataset/skip neurodata:/NIU/Repository/TEST/dataset/ ./ mdrs download -r --skip-if-exists neurodata:/NIU/Repository/TEST/dataset/ ./ + +# DOI access examples: +mdrs download -r neurodata:10.60178/cbs.20260429-001 ./ ``` ### mv @@ -184,20 +191,26 @@ Available access levels: `private`, `public`, `pw_open`, `cbs_open`, `5kikan_ope ### metadata -Show metadata for a remote folder. +Show metadata for a remote folder. You can also specify a DOI path. ```shell -mdrs metadata neurodata:/NIU/Repository/TEST/ +mdrs metadata neurodata:/NIU/Repository/Private/ mdrs metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/ + +# DOI access examples: +mdrs metadata neurodata:10.60178/cbs.20260429-001 ``` ### file-metadata -Show metadata for a remote file. +Show metadata for a remote file. You can also specify a DOI path. ```shell mdrs file-metadata neurodata:/NIU/Repository/TEST/dataset/sample.dat mdrs file-metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.txt + +# DOI access examples: +mdrs file-metadata "neurodata:10.60178/cbs.20260429-001/Figure 1/Figure1v3.pdf" ``` ### version diff --git a/src/api/doi.rs b/src/api/doi.rs new file mode 100644 index 0000000..6bbbd1c --- /dev/null +++ b/src/api/doi.rs @@ -0,0 +1,17 @@ +use crate::connection::MDRSConnection; +use crate::models::doi::DoiResponse; +use anyhow::bail; + +impl MDRSConnection { + /// Retrieve the folder associated with a DOI suffix ID (GET v3/doi/{id}/). + /// + /// The MDRS DOI format is `10.xxxx/prefix.{id}` where the suffix after the + /// last `.` is the internal system ID passed to this endpoint. + pub async fn retrieve_doi(&self, id: &str) -> Result { + let resp = self.get(&format!("v3/doi/{}/", id)).await?; + if !resp.status().is_success() { + bail!("DOI lookup failed: {}", resp.status()); + } + Ok(resp.json::().await?) + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index a1fa623..3a0ec47 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,6 @@ // API module (add users, files, folders, laboratories, etc. here) +pub mod doi; pub mod files; pub mod folders; pub mod laboratories; diff --git a/src/commands/download.rs b/src/commands/download.rs index c674770..382c78d 100644 --- a/src/commands/download.rs +++ b/src/commands/download.rs @@ -1,7 +1,7 @@ use crate::cache::create_readonly_conn; use crate::commands::shared::{ - find_file_by_name, find_folder_limited, find_laboratory, find_subfolder_by_name, - parse_remote_path, + find_file_by_name, find_folder_by_doi, find_folder_limited, find_laboratory, + find_subfolder_by_name, is_doi, parse_doi_remote_path, parse_remote_path, }; use crate::connection::{ApiRequestLimiter, MDRSConnection}; use anyhow::{anyhow, bail}; @@ -17,11 +17,7 @@ pub async fn download( password: Option<&str>, excludes: Vec, ) -> Result<(), anyhow::Error> { - let (remote, labname, r_path) = parse_remote_path(remote_path)?; - let (conn, cache) = create_readonly_conn(&remote).await?; - let conn = Arc::new(conn); let limiter = ApiRequestLimiter::new(crate::settings::SETTINGS.concurrent); - let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?; // Validate that local_path is an existing directory (matching Python's behaviour). let local_real = std::fs::canonicalize(local_path) @@ -30,6 +26,197 @@ pub async fn download( bail!("Local directory `{}` not found.", local_path); } + // Detect DOI path: "remote:10.xxxx/prefix.ID[/optional/sub/path]" + if is_doi(remote_path.splitn(2, ':').nth(1).unwrap_or("")) { + let (remote, doi, subpath) = parse_doi_remote_path(remote_path)?; + let (raw_conn, _cache) = create_readonly_conn(&remote).await?; + let (doi_folder, lab) = find_folder_by_doi(&raw_conn, &doi, password).await?; + let abs_path = format!("{}{}", doi_folder.path.trim_end_matches('/'), subpath); + let abs_path_clean = abs_path.trim_end_matches('/'); + + // Check if the target is a folder. + // If it is, we download the directory. If it is not, we download the file or subfolder. + let (folder, is_folder) = match find_folder_limited( + &raw_conn, + &limiter, + lab.id, + abs_path_clean, + password, + ) + .await + { + Ok(f) => (f, true), + Err(_) => { + let (parent_path, _) = match abs_path_clean.rfind('/') { + Some(0) => ("/".to_string(), abs_path_clean[1..].to_string()), + Some(pos) => ( + abs_path_clean[..pos].to_string(), + abs_path_clean[pos + 1..].to_string(), + ), + None => ("/".to_string(), abs_path_clean.to_string()), + }; + let parent = + find_folder_limited(&raw_conn, &limiter, lab.id, &parent_path, password) + .await?; + (parent, false) + } + }; + + let conn = Arc::new(raw_conn); + let excludes = Arc::new(excludes); + + if is_folder { + let path = folder.path.clone(); + let lab_name = Arc::new(lab.name); + let top_local = local_real.join(&folder.name); + let password_owned = password.map(str::to_string); + + let mut folder_tasks: JoinSet> = + JoinSet::new(); + let mut download_tasks: JoinSet> = JoinSet::new(); + let mut errors = Vec::new(); + + if !recursive { + // Single-folder, non-recursive: download the files inside the DOI folder. + let files = conn.list_all_files_limited(&folder.id, &limiter).await?; + for file in &files { + if is_excluded(&excludes, lab_name.as_str(), &path, Some(&file.name)) { + continue; + } + let dest = local_real.join(&file.name); + if skip_if_exists { + if dest.exists() { + if let Ok(meta) = std::fs::metadata(&dest) { + if meta.len() == file.size { + println!("{}", dest.display()); + continue; + } + } + } + } + let url = make_absolute_url(&conn, &file.download_url); + conn.download_file_limited(&url, &dest.to_string_lossy(), &limiter) + .await?; + println!("{}", dest.display()); + } + return Ok(()); + } + + spawn_download_folder_task( + &mut folder_tasks, + conn.clone(), + limiter.clone(), + lab_name.clone(), + excludes.clone(), + folder.id.clone(), + top_local, + password_owned.clone(), + skip_if_exists, + ); + + drive_download_tasks( + &mut folder_tasks, + &mut download_tasks, + &mut errors, + conn.clone(), + limiter, + lab_name, + excludes, + password_owned, + skip_if_exists, + ) + .await; + + if !errors.is_empty() { + bail!(errors.join("\n")); + } + return Ok(()); + } + + // Case: target is a file or subfolder inside the resolved folder. + let basename = match abs_path_clean.rfind('/') { + Some(pos) => abs_path_clean[pos + 1..].to_string(), + None => abs_path_clean.to_string(), + }; + let files = conn.list_all_files_limited(&folder.id, &limiter).await?; + + // Case 1: basename matches a file in the folder. + if let Some(file) = find_file_by_name(&files, &basename) { + if is_excluded(&excludes, &lab.name, &folder.path, Some(&file.name)) { + return Ok(()); + } + let dest = local_real.join(&file.name); + if skip_if_exists { + if dest.exists() { + if let Ok(meta) = std::fs::metadata(&dest) { + if meta.len() == file.size { + println!("{}", dest.display()); + return Ok(()); + } + } + } + } + let url = make_absolute_url(&conn, &file.download_url); + conn.download_file_limited(&url, &dest.to_string_lossy(), &limiter) + .await?; + println!("{}", dest.display()); + return Ok(()); + } + + // Case 2: basename matches a sub-folder. + let subfolder = find_subfolder_by_name(&folder.sub_folders, &basename); + if let Some(sub) = subfolder { + if !recursive { + bail!("Cannot download `{}`: Is a folder.", abs_path_clean); + } + let top_local = local_real.join(&sub.name); + let mut folder_tasks: JoinSet> = + JoinSet::new(); + let mut download_tasks: JoinSet> = JoinSet::new(); + let mut errors = Vec::new(); + let lab_name = Arc::new(lab.name.clone()); + let password_owned = password.map(str::to_string); + + spawn_download_folder_task( + &mut folder_tasks, + conn.clone(), + limiter.clone(), + lab_name.clone(), + excludes.clone(), + sub.id.clone(), + top_local, + password_owned.clone(), + skip_if_exists, + ); + + drive_download_tasks( + &mut folder_tasks, + &mut download_tasks, + &mut errors, + conn.clone(), + limiter, + lab_name, + excludes, + password_owned, + skip_if_exists, + ) + .await; + + if !errors.is_empty() { + bail!(errors.join("\n")); + } + return Ok(()); + } + + bail!("File or folder `{}` not found.", abs_path_clean); + } + + // Normal path: "remote:/labname/path/..." + let (remote, labname, r_path) = parse_remote_path(remote_path)?; + let (raw_conn, cache) = create_readonly_conn(&remote).await?; + let conn = Arc::new(raw_conn); + let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?; + // Split r_path into the parent directory path and the target basename. // Trailing slashes are already stripped by parse_remote_path, so this is safe. let r_path_clean = r_path.trim_end_matches('/'); diff --git a/src/commands/file_metadata.rs b/src/commands/file_metadata.rs index 638ffa4..fe9b1a9 100644 --- a/src/commands/file_metadata.rs +++ b/src/commands/file_metadata.rs @@ -1,24 +1,16 @@ use crate::cache::create_readonly_conn; -use crate::commands::shared::{find_file_by_name, find_folder, find_laboratory, parse_remote_path}; +use crate::commands::shared::{find_file_by_name, resolve_remote_file}; use anyhow::anyhow; pub async fn file_metadata(remote_path: &str, password: Option<&str>) -> Result<(), anyhow::Error> { - let (remote, labname, r_path) = parse_remote_path(remote_path)?; + let remote = remote_path + .splitn(2, ':') + .next() + .ok_or_else(|| anyhow!("Invalid remote path"))?; + let (conn, cache) = create_readonly_conn(remote).await?; + let (parent_folder, basename) = + resolve_remote_file(&conn, cache.as_ref(), remote_path, password).await?; - let (conn, cache) = create_readonly_conn(&remote).await?; - let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?; - let lab_id = lab.id; - - // Split the file path into parent directory and filename - let path = r_path.trim_end_matches('/'); - let (dirname, basename) = 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()) - }; - - let parent_folder = find_folder(&conn, lab_id, &dirname, password).await?; let files = conn.list_all_files(&parent_folder.id).await?; let file = find_file_by_name(&files, &basename) diff --git a/src/commands/ls.rs b/src/commands/ls.rs index 44758ac..a9859d7 100644 --- a/src/commands/ls.rs +++ b/src/commands/ls.rs @@ -1,5 +1,5 @@ use crate::cache::create_readonly_conn; -use crate::commands::shared::{find_folder, find_laboratory, fmt_datetime, parse_remote_path}; +use crate::commands::shared::{fmt_datetime, resolve_remote_folder}; use crate::connection::MDRSConnection; use crate::models::file::File; use crate::models::folder::{FolderDetail, FolderSimple}; @@ -14,11 +14,14 @@ pub async fn ls( is_recursive: bool, is_quiet: bool, ) -> Result<(), anyhow::Error> { - let (remote, labname, path) = parse_remote_path(remote_path)?; - let (conn, cache) = create_readonly_conn(&remote).await?; - let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?; - - let folder = find_folder(&conn, lab.id, &path, password).await?; + let remote = remote_path + .splitn(2, ':') + .next() + .ok_or_else(|| anyhow::anyhow!("Invalid remote path"))?; + let (conn, cache) = create_readonly_conn(remote).await?; + let (folder, lab) = + resolve_remote_folder(&conn, cache.as_ref(), None, remote_path, password).await?; + let labname = lab.name; if is_json { let output = if is_recursive { @@ -28,7 +31,7 @@ pub async fn ls( }; println!("{}", serde_json::to_string(&output)?); } else if is_recursive { - let prefix = format!("{}:/{}", remote, labname); + let prefix = format!("{}:/{}", conn.remote.as_deref().unwrap_or(""), labname); ls_plain_recursive(&conn, folder, &labname, &prefix, password).await?; } else { let files = conn.list_all_files(&folder.id).await?; diff --git a/src/commands/metadata.rs b/src/commands/metadata.rs index 12a0bad..b4191e5 100644 --- a/src/commands/metadata.rs +++ b/src/commands/metadata.rs @@ -1,11 +1,14 @@ use crate::cache::create_readonly_conn; -use crate::commands::shared::{find_folder, find_laboratory, parse_remote_path}; +use crate::commands::shared::resolve_remote_folder; pub async fn metadata(remote_path: &str, password: Option<&str>) -> Result<(), anyhow::Error> { - let (remote, labname, folder_path) = parse_remote_path(remote_path)?; - let (conn, cache) = create_readonly_conn(&remote).await?; - let lab = find_laboratory(&conn, cache.as_ref(), &labname).await?; - let folder = find_folder(&conn, lab.id, &folder_path, password).await?; + let remote = remote_path + .splitn(2, ':') + .next() + .ok_or_else(|| anyhow::anyhow!("Invalid remote path"))?; + let (conn, cache) = create_readonly_conn(remote).await?; + let (folder, _) = + resolve_remote_folder(&conn, cache.as_ref(), None, remote_path, password).await?; let resp = conn .get(&format!("v3/folders/{}/metadata/", folder.id)) diff --git a/src/commands/shared.rs b/src/commands/shared.rs index e880e62..59717f0 100644 --- a/src/commands/shared.rs +++ b/src/commands/shared.rs @@ -7,6 +7,152 @@ use crate::models::laboratory::Laboratory; use anyhow::{anyhow, bail}; use unicode_normalization::UnicodeNormalization; +// --------------------------------------------------------------------------- +// DOI helpers +// --------------------------------------------------------------------------- + +/// Return true if the path component (after the remote: prefix) looks like a +/// DOI string, i.e. starts with "10." and contains a "/". +pub fn is_doi(path: &str) -> bool { + path.starts_with("10.") && path.contains('/') +} + +/// Extract the DOI suffix ID from a full DOI string. +/// +/// MDRS uses the segment after the last `.` in the DOI as its internal +/// identifier, e.g. `10.xxxx/prefix.20230511-001` → `20230511-001`. +/// If there is no `.` after the `/`, the entire suffix after `/` is used. +pub fn doi_suffix_id(doi: &str) -> &str { + // Find the slash that separates DOI prefix from suffix. + if let Some(slash_pos) = doi.find('/') { + let suffix = &doi[slash_pos + 1..]; + // Use the part after the last `.` within the suffix. + if let Some(dot_pos) = suffix.rfind('.') { + &suffix[dot_pos + 1..] + } else { + suffix + } + } else { + doi + } +} + +/// Split a DOI-with-optional-path string into `(doi, subpath)`. +/// +/// A DOI has the form `10.REGISTRANT/SUFFIX` where SUFFIX contains no `/`. +/// Anything after the first `/` following SUFFIX is treated as a subfolder path. +/// +/// Examples: +/// - `"10.1234/prefix.ID"` → `("10.1234/prefix.ID", "")` +/// - `"10.1234/prefix.ID/"` → `("10.1234/prefix.ID", "")` +/// - `"10.1234/prefix.ID/sub"` → `("10.1234/prefix.ID", "/sub")` +/// - `"10.1234/prefix.ID/sub/deep"` → `("10.1234/prefix.ID", "/sub/deep")` +pub fn split_doi_and_subpath(doi_with_path: &str) -> (&str, &str) { + // Find the first `/` that separates registrant from suffix. + if let Some(first_slash) = doi_with_path.find('/') { + let after_suffix_start = first_slash + 1; + let after_first = &doi_with_path[after_suffix_start..]; + // Find the next `/` inside the suffix portion — this starts the subpath. + if let Some(second_slash) = after_first.find('/') { + let doi_end = after_suffix_start + second_slash; + let doi = &doi_with_path[..doi_end]; + let subpath = &doi_with_path[doi_end..]; // begins with "/" + // Treat a bare trailing slash as no subpath (root of DOI folder). + if subpath == "/" { + (doi, "") + } else { + (doi, subpath) + } + } else { + // No second slash — the whole string is the DOI, no subpath. + (doi_with_path, "") + } + } else { + (doi_with_path, "") + } +} + +/// Parse `"remote:10.xxxx/prefix.ID[/optional/sub/path]"` into +/// `(remote, doi, subpath)`. +/// +/// `subpath` is empty when the remote path points directly at the DOI root +/// folder. Otherwise it is an absolute path string like `"/subfolder/deep"`. +pub fn parse_doi_remote_path(remote_path: &str) -> Result<(String, String, String), anyhow::Error> { + let parts: Vec<&str> = remote_path.splitn(2, ':').collect(); + if parts.len() != 2 { + bail!("remote_path must be in the form 'remote:10.xxxx/prefix.ID'"); + } + let remote = parts[0].to_string(); + let doi_with_path = parts[1]; + if !is_doi(doi_with_path) { + bail!( + "Path `{}` does not look like a DOI (must start with '10.' and contain '/').", + doi_with_path + ); + } + let (doi, subpath) = split_doi_and_subpath(doi_with_path); + Ok((remote, doi.to_string(), subpath.to_string())) +} + +/// Resolve a DOI string to a (FolderDetail, Laboratory) pair. +/// +/// Calls GET v3/doi/{id}/ to look up the folder ID, then fetches the full +/// folder detail (which carries `laboratory_id`) and resolves the laboratory. +pub async fn find_folder_by_doi( + conn: &MDRSConnection, + doi: &str, + password: Option<&str>, +) -> Result<(FolderDetail, Laboratory), anyhow::Error> { + // Strip any trailing slash from the DOI before extracting the suffix ID. + let doi_clean = doi.trim_end_matches('/'); + let id = doi_suffix_id(doi_clean); + let doi_resp = conn.retrieve_doi(id).await?; + + // Verify that the returned DOI matches the one supplied by the caller + // (case-insensitive, trimming trailing slashes). + let returned = doi_resp.doi.trim_end_matches('/'); + if !returned.eq_ignore_ascii_case(doi_clean) { + bail!( + "DOI mismatch: requested `{}` but server returned `{}`.", + doi_clean, + returned + ); + } + + let folder_id = &doi_resp.folder.id; + + // Fetch the full folder detail; laboratory_id is available here. + let folder = conn + .retrieve_folder(folder_id) + .await + .map_err(|e| anyhow!("Failed to retrieve folder for DOI `{}`: {}", doi_clean, e))?; + + // Handle password-locked folder. + if folder.lock { + match password { + None => { + bail!( + "Folder for DOI `{}` is locked. Use -p/--password to provide a password.", + doi_clean + ); + } + Some(pw) => conn.folder_auth(folder_id, pw).await?, + } + } + + // Resolve laboratory using the laboratory_id from the folder detail. + let lab_id = folder.laboratory_id; + let lab = conn + .list_laboratories() + .await? + .items + .into_iter() + .find(|l| l.id == lab_id) + .ok_or_else(|| anyhow!("Laboratory with id {} not found.", lab_id))?; + + Ok((folder, lab)) +} + // --------------------------------------------------------------------------- // Path helpers // --------------------------------------------------------------------------- @@ -206,6 +352,89 @@ pub fn fmt_datetime(iso: &str) -> String { } } +/// Resolve any remote path (normal or DOI-based) into a FolderDetail and Laboratory. +/// Takes an optional API limiter for download command compatibility. +pub async fn resolve_remote_folder( + conn: &MDRSConnection, + cache: Option<&Cache>, + limiter: Option<&ApiRequestLimiter>, + remote_path: &str, + password: Option<&str>, +) -> Result<(FolderDetail, Laboratory), anyhow::Error> { + let path_component = remote_path.splitn(2, ':').nth(1).unwrap_or(""); + if is_doi(path_component) { + let (_, doi, subpath) = parse_doi_remote_path(remote_path)?; + let (doi_folder, lab) = find_folder_by_doi(conn, &doi, password).await?; + if subpath.is_empty() { + Ok((doi_folder, lab)) + } else { + let abs_path = format!("{}{}", doi_folder.path.trim_end_matches('/'), subpath); + let folder = if let Some(l) = limiter { + find_folder_limited(conn, l, lab.id, &abs_path, password).await? + } else { + find_folder(conn, lab.id, &abs_path, password).await? + }; + Ok((folder, lab)) + } + } else { + let (_, labname, folder_path) = parse_remote_path(remote_path)?; + let lab = find_laboratory(conn, cache, &labname).await?; + let folder = if let Some(l) = limiter { + find_folder_limited(conn, l, lab.id, &folder_path, password).await? + } else { + find_folder(conn, lab.id, &folder_path, password).await? + }; + Ok((folder, lab)) + } +} + +/// Resolves a remote path pointing to a file into the parent FolderDetail and the file's basename. +pub async fn resolve_remote_file( + conn: &MDRSConnection, + cache: Option<&Cache>, + remote_path: &str, + password: Option<&str>, +) -> Result<(FolderDetail, String), anyhow::Error> { + let path_component = remote_path.splitn(2, ':').nth(1).unwrap_or(""); + if is_doi(path_component) { + let (_, doi, subpath) = parse_doi_remote_path(remote_path)?; + let (doi_folder, lab) = find_folder_by_doi(conn, &doi, password).await?; + let subpath_clean = subpath.trim_end_matches('/'); + if subpath_clean.is_empty() { + bail!("DOI path must point to a file, not a folder."); + } + let (sub_dir, basename) = if let Some(pos) = subpath_clean.rfind('/') { + let d = if pos == 0 { "/" } else { &subpath_clean[..pos] }; + (d.to_string(), subpath_clean[pos + 1..].to_string()) + } else { + ("/".to_string(), subpath_clean.to_string()) + }; + let abs_path = format!( + "{}{}", + doi_folder.path.trim_end_matches('/'), + if sub_dir.starts_with('/') { + sub_dir + } else { + format!("/{}", sub_dir) + } + ); + let parent = find_folder(conn, lab.id, &abs_path, password).await?; + Ok((parent, basename)) + } else { + let (_, labname, r_path) = parse_remote_path(remote_path)?; + let lab = find_laboratory(conn, cache, &labname).await?; + let path = r_path.trim_end_matches('/'); + let (dirname, basename) = 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()) + }; + let parent = find_folder(conn, lab.id, &dirname, password).await?; + Ok((parent, basename)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -243,4 +472,104 @@ mod tests { assert_eq!(lab.name, "public-lab"); server.await.unwrap(); } + + // ------------------------------------------------------------------ + // DOI helper unit tests + // ------------------------------------------------------------------ + + #[test] + fn is_doi_returns_true_for_valid_doi() { + assert!(is_doi("10.12345/prefix.20230511-001")); + assert!(is_doi("10.1234/abc")); + } + + #[test] + fn is_doi_returns_false_for_normal_paths() { + assert!(!is_doi("/labname/path/to/folder")); + assert!(!is_doi("labname/path")); + assert!(!is_doi("10.1234")); // no slash + } + + #[test] + fn doi_suffix_id_extracts_last_dot_segment() { + assert_eq!( + doi_suffix_id("10.12345/prefix.20230511-001"), + "20230511-001" + ); + } + + #[test] + fn doi_suffix_id_no_dot_in_suffix_returns_whole_suffix() { + assert_eq!(doi_suffix_id("10.1234/nodot"), "nodot"); + } + + #[test] + fn doi_suffix_id_no_slash_returns_whole_input() { + assert_eq!(doi_suffix_id("10.1234"), "10.1234"); + } + + #[test] + fn parse_doi_remote_path_valid_no_subpath() { + let (remote, doi, subpath) = + parse_doi_remote_path("neurodata:10.12345/prefix.20230511-001").unwrap(); + assert_eq!(remote, "neurodata"); + assert_eq!(doi, "10.12345/prefix.20230511-001"); + assert_eq!(subpath, ""); + } + + #[test] + fn parse_doi_remote_path_valid_trailing_slash() { + let (remote, doi, subpath) = + parse_doi_remote_path("neurodata:10.12345/prefix.20230511-001/").unwrap(); + assert_eq!(remote, "neurodata"); + assert_eq!(doi, "10.12345/prefix.20230511-001"); + assert_eq!(subpath, ""); // trailing slash treated as no subpath + } + + #[test] + fn parse_doi_remote_path_valid_with_subpath() { + let (remote, doi, subpath) = + parse_doi_remote_path("neurodata:10.12345/prefix.20230511-001/sub/folder").unwrap(); + assert_eq!(remote, "neurodata"); + assert_eq!(doi, "10.12345/prefix.20230511-001"); + assert_eq!(subpath, "/sub/folder"); + } + + #[test] + fn parse_doi_remote_path_rejects_normal_path() { + let err = parse_doi_remote_path("neurodata:/lab/path").unwrap_err(); + assert!(err.to_string().contains("does not look like a DOI")); + } + + #[test] + fn split_doi_and_subpath_no_subpath() { + assert_eq!( + split_doi_and_subpath("10.1234/prefix.ID"), + ("10.1234/prefix.ID", "") + ); + } + + #[test] + fn split_doi_and_subpath_trailing_slash_only() { + assert_eq!( + split_doi_and_subpath("10.1234/prefix.ID/"), + ("10.1234/prefix.ID", "") + ); + } + + #[test] + fn split_doi_and_subpath_single_level() { + assert_eq!( + split_doi_and_subpath("10.1234/prefix.ID/sub"), + ("10.1234/prefix.ID", "/sub") + ); + } + + #[test] + fn split_doi_and_subpath_multi_level() { + assert_eq!( + split_doi_and_subpath("10.1234/prefix.ID/sub/deep/path"), + ("10.1234/prefix.ID", "/sub/deep/path") + ); + } } diff --git a/src/models/doi.rs b/src/models/doi.rs new file mode 100644 index 0000000..8203f36 --- /dev/null +++ b/src/models/doi.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +/// Nested folder information returned inside a DOI response. +/// The DOI endpoint only returns the folder `id`; `laboratory_id` must be +/// obtained by subsequently calling the folder retrieve endpoint. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DoiFolderRef { + pub id: String, +} + +/// Response from GET v3/doi/{id}/ +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DoiResponse { + /// The internal DOI suffix ID (e.g. "20260429-001") returned as a string. + pub id: String, + pub doi: String, + pub folder: DoiFolderRef, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 4bb5258..5a051f4 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,5 +1,6 @@ // Model definitions (User, Laboratory, File, Folder, etc.) +pub mod doi; pub mod file; pub mod folder; pub mod laboratory;