diff --git a/Cargo.lock b/Cargo.lock index a921fd7..6c33344 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,36 @@ version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +[[package]] +name = "assert_cmd" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed72493ac66d5804837f480ab3766c72bdfab91a65e565fc54fa9e42db0073a8" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "assert_fs" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd762e110c8ed629b11b6cde59458cc1c71de78ebbcc30099fc8e0403a2a2ec" +dependencies = [ + "anstyle", + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -83,6 +113,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +[[package]] +name = "bstr" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "byte-unit" version = "4.0.19" @@ -229,6 +270,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "dirs" version = "5.0.1" @@ -250,6 +297,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dyn-clone" version = "1.0.16" @@ -291,6 +344,21 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -326,6 +394,30 @@ dependencies = [ "url", ] +[[package]] +name = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.4.1", + "ignore", + "walkdir", +] + [[package]] name = "hashbrown" version = "0.14.3" @@ -360,6 +452,22 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.1.0" @@ -499,9 +607,9 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" [[package]] name = "memoffset" @@ -533,6 +641,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "ntapi" version = "0.4.1" @@ -542,6 +656,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-traits" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.18.0" @@ -613,6 +736,36 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "predicates" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.70" @@ -719,6 +872,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -827,6 +989,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "tempfile" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "termcolor" version = "1.4.0" @@ -836,6 +1011,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "thiserror" version = "1.0.50" @@ -939,6 +1120,25 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1113,6 +1313,8 @@ name = "xdbm" version = "0.1.0" dependencies = [ "anyhow", + "assert_cmd", + "assert_fs", "byte-unit", "clap", "clap-verbosity-flag", @@ -1122,6 +1324,7 @@ dependencies = [ "inquire", "log", "pathdiff", + "predicates", "serde", "serde_yaml", "sysinfo", diff --git a/Cargo.toml b/Cargo.toml index 14e2632..79cb708 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,8 @@ byte-unit = "4.0.19" anyhow = "1.0" pathdiff = "0.2.1" unicode-width = "0.1.11" + +[dev-dependencies] +assert_cmd = "2.0.14" +assert_fs = "1.1.1" +predicates = "3.1.0" diff --git a/README.md b/README.md index 350efb7..ef0e81c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,13 @@ # TODO: -- [ ] split subcommands to functions +- [x] split subcommands to functions +- [ ] write test for init subcommand + - [ ] write test with existing repo + - [ ] with ssh credential + - [ ] ssh-agent + - [ ] specify key +- [ ] add sync subcommand +- [ ] add check subcommand - [ ] reorganize cmd option for storage - [ ] use subcommand + + diff --git a/src/cmd_args.rs b/src/cmd_args.rs index 7cd1270..cfc0659 100644 --- a/src/cmd_args.rs +++ b/src/cmd_args.rs @@ -6,7 +6,7 @@ use crate::PathBuf; use clap::{Parser, Subcommand}; use clap_verbosity_flag::Verbosity; -#[derive(Parser)] +#[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] pub(crate) struct Cli { #[command(subcommand)] @@ -20,13 +20,21 @@ pub(crate) struct Cli { pub(crate) verbose: Verbosity, } -#[derive(Subcommand)] +#[derive(Subcommand, Debug)] pub(crate) enum Commands { /// Initialize for this device. - /// Provide `repo_url` to use existing repository, otherwise this device will be configured as the - /// first device. Init { + /// Name for this device + device_name: String, + /// Url for existing repository. Empty if init for the first time. + #[arg(short, long)] repo_url: Option, // url? + /// Whether to use ssh-agent + #[arg(long)] + use_sshagent: bool, + /// Manually specify ssh key + #[arg(long)] + ssh_key: Option, }, /// Manage storages. @@ -42,14 +50,14 @@ pub(crate) enum Commands { Check {}, } -#[derive(clap::Args)] +#[derive(clap::Args, Debug)] #[command(args_conflicts_with_subcommands = true)] pub(crate) struct StorageArgs { #[command(subcommand)] pub(crate) command: StorageCommands, } -#[derive(Subcommand)] +#[derive(Subcommand, Debug)] pub(crate) enum StorageCommands { /// Add new storage. Add { diff --git a/src/cmd_init.rs b/src/cmd_init.rs index f68be03..f303d07 100644 --- a/src/cmd_init.rs +++ b/src/cmd_init.rs @@ -1,24 +1,91 @@ //! Init subcommand. //! Initialize xdbm for the device. -use crate::{ - add_and_commit, full_status, get_devices, set_device_name, write_devices, Device, DEVICESFILE, -}; -use anyhow::{anyhow, Context, Result}; -use git2::Repository; +use crate::{add_and_commit, full_status, get_devices, write_devices, Device, DEVICESFILE}; +use anyhow::{anyhow, Context, Ok, Result}; +use core::panic; +use git2::{Cred, RemoteCallbacks, Repository}; +use inquire::Password; +use std::env; use std::fs::File; use std::io::{BufWriter, Write}; -use std::path::{self, Path}; +use std::path::{self, Path, PathBuf}; -pub(crate) fn cmd_init(repo_url: Option, config_dir: &path::PathBuf) -> Result<()> { - let is_first_device: bool; +fn clone_repo( + repo_url: &String, + use_sshagent: bool, + ssh_key: Option, + config_dir: &path::PathBuf, +) -> Result { + // dont use credentials + if ssh_key.is_none() && !use_sshagent { + info!("No authentication will be used."); + info!("Use either ssh_key or ssh-agent to access private repository"); + return Ok(Repository::clone(&repo_url, &config_dir)?); + } + + // using credentials + let mut callbacks = RemoteCallbacks::new(); + callbacks.credentials(|_url, username_from_url, _allowed_types| { + if let Some(key) = &ssh_key { + info!("Using provided ssh key to access the repository"); + let passwd = match Password::new("SSH passphrase").prompt() { + std::result::Result::Ok(s) => Some(s), + Err(err) => { + error!("Failed to get ssh passphrase: {:?}", err); + None + } + }; + Cred::ssh_key( + username_from_url + .context("No username found from the url") + .unwrap(), + None, + &key as &Path, + passwd.as_deref(), + ) + } else if use_sshagent { + // use ssh agent + info!("Using ssh agent to access the repository"); + Cred::ssh_key_from_agent( + username_from_url + .context("No username found from the url") + .unwrap(), + ) + } else { + error!("no ssh_key and use_sshagent"); + panic!("This option must be unreachable.") + } + }); + + // fetch options + let mut fo = git2::FetchOptions::new(); + fo.remote_callbacks(callbacks); + + let mut builder = git2::build::RepoBuilder::new(); + builder.fetch_options(fo); + + Ok(builder.clone(&repo_url, config_dir)?) +} + +pub(crate) fn cmd_init( + device_name: String, + repo_url: Option, + use_sshagent: bool, + ssh_key: Option, + config_dir: &path::PathBuf, +) -> Result<()> { + // validate device name + if device_name.chars().count() == 0 { + log::error!("Device name cannnot by empty"); + return Err(anyhow!("Device name is empty")); + } // get repo or initialize it - let repo = match repo_url { + let (is_first_device, repo) = match repo_url { Some(repo_url) => { trace!("repo: {}", repo_url); - let repo = Repository::clone(&repo_url, &config_dir)?; - is_first_device = false; - repo + let repo = clone_repo(&repo_url, use_sshagent, ssh_key, config_dir)?; + (false, repo) } None => { trace!("No repo provided"); @@ -37,14 +104,15 @@ pub(crate) fn cmd_init(repo_url: Option, config_dir: &path::PathBuf) -> add_and_commit(&repo, Path::new(".gitignore"), "Add devname to gitignore.")?; full_status(&repo)?; } - is_first_device = true; - repo + (true, repo) } }; full_status(&repo)?; // set device name - let device = set_device_name()?; + // let device = set_device_name()?; + let device = Device::new(device_name); + trace!("Device information: {:?}", device); // save devname let devname_path = &config_dir.join("devname"); diff --git a/src/devices.rs b/src/devices.rs index 6c5861a..3a46315 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -87,7 +87,8 @@ pub fn get_device(config_dir: &Path) -> Result { /// Get `Vec` from yaml file in `config_dir`. pub fn get_devices(config_dir: &Path) -> Result> { trace!("get_devices"); - let f = File::open(config_dir.join(DEVICESFILE))?; + let f = + File::open(config_dir.join(DEVICESFILE)).context(format!("{} not found", DEVICESFILE))?; let reader = BufReader::new(f); let yaml: Vec = serde_yaml::from_reader(reader).context("Failed to parse devices.yml")?; diff --git a/src/main.rs b/src/main.rs index 95db3c4..e4d38d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,11 +45,13 @@ fn main() -> Result<()> { .filter_level(cli.verbose.log_level_filter()) .init(); trace!("Start logging..."); + trace!("args: {:?}", cli); let config_dir: std::path::PathBuf = match cli.config_dir { Some(path) => path, None => { - let mut config_dir = dirs::config_local_dir().context("Failed to get default config dir.")?; + let mut config_dir = + dirs::config_local_dir().context("Failed to get default config dir.")?; config_dir.push("xdbm"); config_dir } @@ -57,7 +59,12 @@ fn main() -> Result<()> { trace!("Config dir: {:?}", config_dir); match cli.command { - Commands::Init { repo_url } => cmd_init::cmd_init(repo_url, &config_dir)?, + Commands::Init { + device_name, + repo_url, + use_sshagent, + ssh_key, + } => cmd_init::cmd_init(device_name, repo_url, use_sshagent, ssh_key, &config_dir)?, Commands::Storage(storage) => { let repo = Repository::open(&config_dir).context( "Repository doesn't exist on the config path. Please run init to initialize the repository.", @@ -98,38 +105,6 @@ fn main() -> Result<()> { Ok(()) } -/// Set device name interactively. -fn set_device_name() -> Result { - let validator = |input: &str| { - if input.chars().count() == 0 { - Ok(Validation::Invalid("Need at least 1 character.".into())) - } else { - Ok(Validation::Valid) - } - }; - - let device_name = Text::new("Provide name for this device:") - .with_validator(validator) - .prompt(); - - let device_name = match device_name { - Ok(device_name) => { - println!("device name: {}", device_name); - device_name - } - Err(err) => { - println!("Error {}", err); - return Err(anyhow!(err)); - } - }; - - let device = Device::new(device_name); - trace!("Device information: {:?}", device); - trace!("Serialized: \n{}", serde_yaml::to_string(&device).unwrap()); - - return Ok(device); -} - fn ask_unique_name(storages: &HashMap, target: String) -> Result { let mut disk_name = String::new(); loop { diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..d0ba79d --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,59 @@ +use assert_cmd::prelude::*; +use assert_fs::prelude::*; + +mod cmd_init { + use anyhow::{Ok, Result}; + use assert_cmd::{cargo::CommandCargoExt, Command}; + use predicates::prelude::predicate; + + #[test] + fn init_with_tmpdir() -> Result<()> { + let config_dir = assert_fs::TempDir::new()?; + let mut cmd = Command::cargo_bin("xdbm")?; + cmd.arg("-c") + .arg(config_dir.path()) + .arg("init") + .arg("testdev"); + cmd.assert().success().stdout(predicate::str::contains("")); + assert_eq!( + std::fs::read_to_string(config_dir.path().join("devname"))?, + "testdev\n" + ); + Ok(()) + } + + #[test] + fn init_with_existing_repo() -> Result<()> { + // 1st device + let config_dir_1 = assert_fs::TempDir::new()?; + let mut cmd1 = Command::cargo_bin("xdbm")?; + cmd1.arg("-c") + .arg(config_dir_1.path()) + .arg("init") + .arg("first"); + cmd1.assert().success().stdout(predicate::str::contains("")); + + // 2nd device + let config_dir_2 = assert_fs::TempDir::new()?; + let mut cmd2 = Command::cargo_bin("xdbm")?; + cmd2.arg("-c") + .arg(config_dir_2.path()) + .arg("init") + .arg("second") + .arg("-r") + .arg(config_dir_1.path()); + cmd2.assert().success().stdout(predicate::str::contains("")); + + assert_eq!( + std::fs::read_to_string(config_dir_2.path().join("devname"))?, + "second\n" + ); + assert!( + std::fs::read_to_string(config_dir_2.path().join("devices.yml"))?.contains("first") + ); + assert!( + std::fs::read_to_string(config_dir_2.path().join("devices.yml"))?.contains("second") + ); + Ok(()) + } +}