diff --git a/src/lib/data.rs b/src/lib/data.rs index c23651c..684655e 100644 --- a/src/lib/data.rs +++ b/src/lib/data.rs @@ -85,10 +85,10 @@ impl StatusEntry { } } } - pub fn columns(&self, abbrev: Option) -> Result> { + pub fn columns(&self, abbrev: Option) -> Vec { let local_status = &self.local_status; - let md5_string = self.local_md5_column(abbrev)?; + let md5_string = self.local_md5_column(abbrev).expect("Internal Error: StatusEntry::local_md5_column()."); let mod_time_pretty = self.local_mod_time.map(format_mod_time).unwrap_or_default(); @@ -105,7 +105,7 @@ impl StatusEntry { (false, _) => "".to_string(), (true, Some(true)) => ", tracked".to_string(), (true, Some(false)) => ", untracked".to_string(), - (_, _) => return Err(anyhow!("Invalid tracking state")) + (true, None) => ", not in manifest".to_string() }; let mut columns = vec![ self.name.clone(), @@ -119,7 +119,8 @@ impl StatusEntry { Some(RemoteStatusCode::Current) => "identical remote".to_string(), Some(RemoteStatusCode::MessyLocal) => "messy local".to_string(), Some(RemoteStatusCode::Different) => { - format!("different remote version ({:})", self.remote_md5_column(abbrev)?) + let remote_md5 = self.remote_md5_column(abbrev).expect("Internal Error: StatusEntry::remote_md5_column()."); + format!("different remote version ({:})", remote_md5) }, Some(RemoteStatusCode::NotExists) => "not on remote".to_string(), Some(RemoteStatusCode::NoLocal) => "unknown (messy remote)".to_string(), @@ -129,8 +130,7 @@ impl StatusEntry { }; columns.push(remote_status_msg.to_string()); } - - Ok(columns) + columns } } @@ -140,6 +140,7 @@ pub struct DataFile { pub tracked: bool, pub md5: String, pub size: u64, + pub url: Option //modified: Option>, } @@ -316,7 +317,7 @@ impl MergedFile { impl DataFile { - pub fn new(path: String, path_context: &Path) -> Result { + pub fn new(path: String, url: Option<&str>, path_context: &Path) -> Result { let full_path = path_context.join(&path); if !full_path.exists() { return Err(anyhow!("File '{}' does not exist.", path)) @@ -328,11 +329,13 @@ impl DataFile { let size = metadata(full_path) .map_err(|err| anyhow!("Failed to get metadata for file {:?}: {}", path, err))? .len(); + let maybe_url: Option = url.map(|s| s.to_string()); Ok(DataFile { path, tracked: false, md5, size, + url: maybe_url, }) } @@ -623,7 +626,7 @@ impl DataCollection { None => Err(anyhow!("No such remote")), } } - pub fn track_file(&mut self, filepath: &String) -> Result<()> { + pub fn track_file(&mut self, filepath: &String, path_context: &Path) -> Result<()> { trace!("complete files: {:?}", self.files); let data_file = self.files.get_mut(filepath); @@ -640,8 +643,9 @@ impl DataCollection { None => Err(anyhow!("Data file '{}' is not in the data manifest. Add it first using:\n \ $ sdf track {}\n", filepath, filepath)), Some(data_file) => { - let path = Path::new(filepath); - let file_size = data_file.get_size(path)?; + // check that the file isn't empty + // (this is why a path_context is needed) + let file_size = data_file.get_size(&path_context)?; if file_size == 0 { return Err(anyhow!("Cannot track an empty file, and '{}' has a file size of 0.", filepath)); } diff --git a/src/lib/project.rs b/src/lib/project.rs index 789fba2..a673199 100644 --- a/src/lib/project.rs +++ b/src/lib/project.rs @@ -12,7 +12,7 @@ use dirs; #[allow(unused_imports)] use crate::{print_warn,print_info}; use crate::lib::data::{DataFile,DataCollection}; -use crate::lib::utils::{load_file,print_status}; +use crate::lib::utils::{load_file,print_status, pluralize}; use crate::lib::remote::{AuthKeys,authenticate_remote}; use crate::lib::remote::Remote; use crate::lib::api::figshare::FigShareAPI; @@ -171,6 +171,17 @@ impl Project { Ok(()) } + // TODO could add support for other metadata here + pub fn set_metadata(&mut self, title: &Option, description: &Option) -> Result<()> { + if let Some(new_title) = title { + self.data.metadata.title = Some(new_title.to_string()); + } + if let Some(new_description) = description { + self.data.metadata.description = Some(new_description.to_string()); + } + self.save() + } + pub fn set_config(name: &Option, email: &Option, affiliation: &Option) -> Result<()> { let mut config = Project::load_config().unwrap_or_else(|_| Config { user: User { @@ -254,13 +265,13 @@ impl Project { Ok(self.relative_path(path)?.to_string_lossy().to_string()) } - pub async fn status(&mut self, include_remotes: bool) -> Result<()> { + pub async fn status(&mut self, include_remotes: bool, all: bool) -> Result<()> { // if include_remotes (e.g. --remotes) is set, we need to merge // in the remotes, so we authenticate first and then get them. let path_context = &canonicalize(self.path_context())?; let status_rows = self.data.status(path_context, include_remotes).await?; //let remotes: Option<_> = include_remotes.then(|| &self.data.remotes); - print_status(status_rows, Some(&self.data.remotes)); + print_status(status_rows, Some(&self.data.remotes), all); Ok(()) } @@ -298,12 +309,12 @@ impl Project { let mut num_added = 0; for filepath in files { let filename = self.relative_path_string(Path::new(&filepath.clone()))?; - let data_file = DataFile::new(filename.clone(), &self.path_context())?; + let data_file = DataFile::new(filename.clone(), None, &self.path_context())?; info!("Adding file '{}'.", filename); self.data.register(data_file)?; num_added += 1; } - println!("Added {} files.", num_added); + println!("Added {}.", pluralize(num_added as u64, "file")); self.save() } @@ -371,6 +382,18 @@ impl Project { Ok(()) } + pub async fn get(&mut self, url: &str, filename: &str) -> Result<()> { + let data_file = DataFile::new(filename.to_string(), Some(url), &self.path_context())?; + info!("Adding file '{}'.", filename); + self.data.register(data_file)?; + Ok(()) + } + + pub async fn get_from_file(&mut self, filename: &str, column: u64) -> Result<()> { + // TODO + Ok(()) + } + pub fn untrack(&mut self, filepath: &String) -> Result<()> { let filepath = self.relative_path_string(Path::new(filepath))?; self.data.untrack_file(&filepath)?; @@ -379,7 +402,7 @@ impl Project { pub fn track(&mut self, filepath: &String) -> Result<()> { let filepath = self.relative_path_string(Path::new(filepath))?; - self.data.track_file(&filepath)?; + self.data.track_file(&filepath, &self.path_context())?; self.save() } diff --git a/src/lib/utils.rs b/src/lib/utils.rs index cf48d90..7ffc115 100644 --- a/src/lib/utils.rs +++ b/src/lib/utils.rs @@ -121,7 +121,9 @@ pub fn print_fixed_width(rows: HashMap>, nspaces: Optio */ // More specialized version of print_fixed_width() for statuses. // Handles coloring, manual annotation, etc -pub fn print_fixed_width_status(rows: BTreeMap>, nspaces: Option, indent: Option, color: bool) { +pub fn print_fixed_width_status(rows: BTreeMap>, nspaces: Option, + indent: Option, color: bool, all: bool) { + //debug!("rows: {:?}", rows); let indent = indent.unwrap_or(0); let nspaces = nspaces.unwrap_or(6); @@ -130,7 +132,7 @@ pub fn print_fixed_width_status(rows: BTreeMap>, nspace // get the max number of columns (in case ragged) let max_cols = rows.values() .flat_map(|v| v.iter()) - .filter_map(|entry| entry.columns(abbrev).ok().map(|cols| cols.len())) + .map(|entry| entry.columns(abbrev).len()) .max() .unwrap_or(0); @@ -138,35 +140,38 @@ pub fn print_fixed_width_status(rows: BTreeMap>, nspace // compute max lengths across all rows for status in rows.values().flat_map(|v| v.iter()) { - if let Ok(cols) = status.columns(abbrev) { // Assuming columns returns Result> - for (i, col) in cols.iter().enumerate() { - max_lengths[i] = max_lengths[i].max(col.len()); // Assuming col is a string - } + let cols = status.columns(abbrev); + for (i, col) in cols.iter().enumerate() { + max_lengths[i] = max_lengths[i].max(col.len()); // Assuming col is a string } } // print status table - let mut keys: Vec<&String> = rows.keys().collect(); - keys.sort(); - for (key, value) in &rows { + let mut dir_keys: Vec<&String> = rows.keys().collect(); + dir_keys.sort(); + for key in dir_keys { + let statuses = &rows[key]; let pretty_key = if color { key.bold().to_string() } else { key.clone() }; println!("[{}]", pretty_key); // Print the rows with the correct widths - for status in value { - if let Ok(cols) = status.columns(abbrev) { - let mut fixed_row = Vec::new(); - for (i, col) in cols.iter().enumerate() { - // push a fixed-width column to vector - let spacer = if i == 0 { " " } else { "" }; - let fixed_col = format!("{}{:width$}", spacer, col, width = max_lengths[i]); - fixed_row.push(fixed_col); - } - let spacer = " ".repeat(nspaces); - let line = fixed_row.join(&spacer); - let status_line = if color { status.color(line) } else { line.to_string() }; - println!("{}{}", " ".repeat(indent), status_line); + for status in statuses { + if status.local_status.is_none() && !all { + // ignore things that aren't in the manifest, unless --all + continue; + } + let cols = status.columns(abbrev); + let mut fixed_row = Vec::new(); + for (i, col) in cols.iter().enumerate() { + // push a fixed-width column to vector + let spacer = if i == 0 { " " } else { "" }; + let fixed_col = format!("{}{:width$}", spacer, col, width = max_lengths[i]); + fixed_row.push(fixed_col); } + let spacer = " ".repeat(nspaces); + let line = fixed_row.join(&spacer); + let status_line = if color { status.color(line) } else { line.to_string() }; + println!("{}{}", " ".repeat(indent), status_line); } println!(); } @@ -199,10 +204,49 @@ pub fn pluralize>(count: T, noun: &str) -> String { } } -pub fn print_status(rows: BTreeMap>, remote: Option<&HashMap>) { +struct FileCounts { + local: u64, + remote: u64, + both: u64, + total: u64 +} + +fn get_counts(rows: &BTreeMap>) -> Result { + let mut local = 0; + let mut remote = 0; + let mut both = 0; + let mut total = 0; + for files in rows.values() { + for file in files { + total += 1; + match (&file.local_status, &file.remote_status) { + (None, None) => { + return Err(anyhow!("Internal Error: get_counts found a file with both local/remote set to None.")); + }, + (Some(_), None) => { + local += 1; + }, + (None, Some(_)) => { + remote += 1; + }, + (Some(_), Some(_)) => { + both += 1; + } + } + } + } + Ok(FileCounts { local, remote, both, total }) +} + +pub fn print_status(rows: BTreeMap>, remote: Option<&HashMap>, + all: bool) { println!("{}", "Project data status:".bold()); - let total: usize = rows.values().map(|v| v.len()).sum(); - println!("{} registered.\n", pluralize(total as u64, "data file")); + let counts = get_counts(&rows).expect("Internal Error: get_counts() panicked."); + println!("{} on local and remotes ({} only local, {} only remote), {} total.\n", + pluralize(counts.both as u64, "file"), + pluralize(counts.local as u64, "file"), + pluralize(counts.remote as u64, "file"), + pluralize(counts.total as u64, "file")); // this brings the remote name (if there is a corresponding remote) into // the key, so the linked remote can be displayed in the status @@ -222,7 +266,7 @@ pub fn print_status(rows: BTreeMap>, remote: Option<&Has None => rows, }; - print_fixed_width_status(rows_by_dir, None, None, true); + print_fixed_width_status(rows_by_dir, None, None, true, all); } pub fn format_bytes(size: u64) -> String { diff --git a/src/main.rs b/src/main.rs index 3f57f13..b6f1c60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,21 +54,23 @@ enum Commands { filenames: Vec, }, #[structopt(name = "config")] - /// Set local configuration settings (e.g. name), which + /// Set local system-wide metadata (e.g. your name, email, etc.), which /// can be propagated to some APIs. Config { - /// Your name (required if not previously set) + /// Your name. #[structopt(long)] name: Option, + // Your email. #[structopt(long)] email: Option, + // Your affiliation. #[structopt(long)] affiliation: Option, }, #[structopt(name = "init")] /// Initialize a new project. Init { - /// project name (default: the name of the directory) + /// Project name (default: the name of the directory). #[structopt(long)] name: Option }, @@ -76,13 +78,18 @@ enum Commands { #[structopt(name = "status")] /// Show status of data. Status { - /// Show remotes status + /// Show remotes status (requires network). #[structopt(long)] - remotes: bool + remotes: bool, + + /// Show statuses of all files, including those on remote(s) but not in the manifest. + #[structopt(long)] + all: bool + }, #[structopt(name = "stats")] - /// Show status of data. + /// Show file size statistics. Stats { }, @@ -96,45 +103,43 @@ enum Commands { #[structopt(name = "link")] /// Link a directory to a remote storage solution. Link { - /// directory to link to remote storage. + /// Directory to link to remote storage. dir: String, - /// the service to use (currently only FigShare). + /// The data repository service to use (either 'figshare' or 'zenodo'). service: String, - /// the authentication token. + /// The authentication token. key: String, - /// project name for remote (default: directory name) + /// Project name for remote (default: the metadata title in the data + /// manifest, or if that's not set, the directory name). #[structopt(long)] name: Option, - /// don't initialize remote, only add to manifest + /// Don't initialize remote, only add to manifest. This will retrieve + /// the remote information (i.e. the FigShare Article ID or Zenodo + /// Depository ID) to add to the manifest. Requires network. #[structopt(long)] link_only: bool }, - #[structopt(name = "ls")] - /// List remotes. - Ls { - }, - #[structopt(name = "untrack")] /// No longer keep track of this file on the remote. Untrack { - /// the file to untrack with remote. + /// The file to untrack with remote. filename: String }, #[structopt(name = "track")] /// Keep track of this file on the remote. Track { - /// the file to track with remote. + /// The file to track with remote. filename: String }, #[structopt(name = "push")] /// Push all tracked files to remote. Push { - // Overwrite local files? + // Overwrite remote files if they exit. #[structopt(long)] overwrite: bool, }, @@ -142,7 +147,7 @@ enum Commands { #[structopt(name = "pull")] /// Pull in all tracked files from the remote. Pull { - // Overwrite local files? + // Overwrite local files if they exit. #[structopt(long)] overwrite: bool, @@ -150,7 +155,17 @@ enum Commands { //directories: Vec, }, - + #[structopt(name = "metadata")] + /// Update the project metadata. + Metadata { + /// The project name. + #[structopt(long)] + title: Option, + // A description of the project. + #[structopt(long)] + description: Option, + }, + } pub fn print_errors(response: Result<()>) { @@ -185,9 +200,9 @@ async fn run() -> Result<()> { Some(Commands::Init { name }) => { Project::init(name.clone()) } - Some(Commands::Status { remotes }) => { + Some(Commands::Status { remotes, all }) => { let mut proj = Project::new()?; - proj.status(*remotes).await + proj.status(*remotes, *all).await } Some(Commands::Stats { }) => { //let proj = Project::new()?; @@ -202,10 +217,6 @@ async fn run() -> Result<()> { let mut proj = Project::new()?; proj.link(dir, service, key, name, link_only).await } - Some(Commands::Ls {}) => { - let mut proj = Project::new()?; - proj.ls().await - }, Some(Commands::Track { filename }) => { let mut proj = Project::new()?; proj.track(filename) @@ -222,6 +233,10 @@ async fn run() -> Result<()> { let mut proj = Project::new()?; proj.pull(*overwrite).await }, + Some(Commands::Metadata { title, description }) => { + let mut proj = Project::new()?; + proj.set_metadata(title, description) + }, None => { println!("{}\n", INFO); std::process::exit(1);