diff --git a/TODOLIST.md b/TODOLIST.md index 110d561..ce19914 100644 --- a/TODOLIST.md +++ b/TODOLIST.md @@ -1,5 +1,11 @@ # TODOList +## v0.10.1 + +- [x] In `url`, expand the alias name. +- [ ] Support completion for `keyword` and `repo name`. +- [ ] Support `@[n]`, to select the `nth` visited repo. Usage: `rox home @3`, `rox home github @5`. The `n` can be defaulted to `5`. + ## v0.10.0 - [x] Use `Cow`, to further reduce memory `clone` overhead. diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 898767d..3826ab0 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -34,6 +34,7 @@ use strum::EnumVariantNames; use crate::config::Config; use crate::repo::database::{self, Database}; +use crate::repo::keywords::Keywords; use crate::term::{GitBranch, GitRemote}; use crate::{api, hashmap, term}; @@ -188,8 +189,9 @@ impl Completion { pub fn repo_args(cfg: &Config, args: &[&str]) -> Result { match args.len() { 0 | 1 => { + let to_complete = args.get(0).map(|s| *s).unwrap_or(""); let remotes = cfg.list_remotes(); - Ok(CompletionResult::from(remotes)) + Self::wrap_with_keywords(cfg, "", to_complete, remotes, false) } 2 => { let db = Database::load(cfg)?; @@ -203,7 +205,7 @@ impl Completion { .into_iter() .map(|owner| format!("{}/", owner)) .collect(); - return Ok(CompletionResult::from(items).no_space()); + return Self::wrap_with_keywords(cfg, remote, query, items, true); } let (owner, _) = database::parse_owner(query); @@ -219,6 +221,72 @@ impl Completion { } } + fn wrap_with_keywords( + cfg: &Config, + remote: &str, + to_complete: &str, + items: Vec, + no_space: bool, + ) -> Result { + if to_complete == "" { + if no_space { + return Ok(CompletionResult::from(items).no_space()); + } + return Ok(CompletionResult::from(items)); + } + + let mut completion = vec![]; + let mut found = false; + for item in items { + if item.starts_with(to_complete) { + found = true; + completion.push(item); + } + } + if found { + // The highest priority is given to the `items` - if `to_complete` matches any + // element in `items`, directly return the matching item, disregarding keywords + // and repository names. + if no_space { + return Ok(CompletionResult::from(completion).no_space()); + } + return Ok(CompletionResult::from(completion)); + } + + // Return the matched keywords and repository names as the completion items. + let keywords = Keywords::load(cfg)?; + let mut keywords = keywords.complete(remote); + let db = Database::load(cfg)?; + let names: Vec<_> = if remote != "" { + db.list_by_remote(remote, &None) + } else { + db.list_all(&None) + } + .into_iter() + .map(|repo| repo.name.to_string()) + .collect(); + keywords.extend(names); + for kw in keywords { + if kw.starts_with(to_complete) { + completion.push(kw); + } + } + + let mut set: HashSet = HashSet::with_capacity(completion.len()); + let completion: Vec<_> = completion + .into_iter() + .filter(|item| { + if set.contains(item.as_str()) { + return false; + } + set.insert(item.clone()); + true + }) + .collect(); + + Ok(CompletionResult::from(completion)) + } + pub fn owner_args(cfg: &Config, args: &[&str]) -> Result { match args.len() { 0 | 1 => { diff --git a/src/config/defaults.rs b/src/config/defaults.rs index 722745c..9ee9cd0 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use crate::config::Docker; use crate::config::RemoteConfig; +use crate::utils; pub fn workspace() -> String { String::from("~/dev") @@ -50,6 +51,10 @@ pub fn docker_shell() -> String { String::from("sh") } +pub fn keyword_expire() -> u64 { + utils::DAY +} + pub fn empty_map() -> HashMap { HashMap::new() } diff --git a/src/config/mod.rs b/src/config/mod.rs index 61ac731..41910fb 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -30,6 +30,9 @@ pub struct Config { #[serde(default = "defaults::docker")] pub docker: Docker, + #[serde(default = "defaults::keyword_expire")] + pub keyword_expire: u64, + /// The remotes config. #[serde(default = "defaults::empty_map")] pub remotes: HashMap, @@ -419,6 +422,7 @@ impl Config { workspace: defaults::workspace(), metadir: defaults::metadir(), docker: defaults::docker(), + keyword_expire: defaults::keyword_expire(), cmd: defaults::cmd(), remotes: HashMap::new(), release: defaults::release(), @@ -533,6 +537,11 @@ impl Config { pub fn now(&self) -> u64 { self.now.unwrap() } + + #[cfg(test)] + pub fn set_now(&mut self, now: u64) { + self.now = Some(now); + } } #[cfg(test)] diff --git a/src/repo/database.rs b/src/repo/database.rs index 498da97..2504b10 100644 --- a/src/repo/database.rs +++ b/src/repo/database.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::api::Provider; use crate::config::{Config, RemoteConfig}; +use crate::repo::keywords::Keywords; use crate::repo::{NameLevel, Repo}; use crate::utils::{self, FileLock}; use crate::{info, term}; @@ -1201,10 +1202,7 @@ impl<'a, T: TerminalHelper, P: ProviderBuilder> Selector<'_, T, P> { }?; Ok((repo, true)) } - None => { - let repo = db.must_get_fuzzy("", self.head)?; - Ok((repo, true)) - } + None => self.fuzzy_get_repo(db, "", self.head), } } @@ -1265,10 +1263,7 @@ impl<'a, T: TerminalHelper, P: ProviderBuilder> Selector<'_, T, P> { if owner.is_empty() { return match self.opts.mode { SelectMode::Search => self.one_from_provider(db, &remote_cfg), - SelectMode::Fuzzy => { - let repo = db.must_get_fuzzy(remote, self.query)?; - Ok((repo, true)) - } + SelectMode::Fuzzy => self.fuzzy_get_repo(db, remote, self.query), }; } @@ -1346,6 +1341,33 @@ impl<'a, T: TerminalHelper, P: ProviderBuilder> Selector<'_, T, P> { Ok((repo, false)) } + /// Wrap fuzzy get, add keywords addon. + fn fuzzy_get_repo<'b, R, K>( + &self, + db: &'b Database, + remote: R, + keyword: K, + ) -> Result<(Repo<'b>, bool)> + where + R: AsRef, + K: AsRef, + { + let repo = db.must_get_fuzzy(remote.as_ref(), keyword.as_ref())?; + + if repo.name != keyword.as_ref() { + // If a fuzzy match hits a repository, record the fuzzy matching keywords in a + // file for automatic keyword completion. If the fuzzy match word exactly matches + // the repository name, no additional recording is needed, as the completion + // logic will automatically include the repository name in the completion + // candidates. + let mut keywords = Keywords::load(db.cfg)?; + keywords.upsert(remote.as_ref(), keyword.as_ref()); + keywords.save()?; + } + + Ok((repo, true)) + } + /// Selecting multiple repositories from the local database. /// /// # Returns @@ -1389,7 +1411,7 @@ impl<'a, T: TerminalHelper, P: ProviderBuilder> Selector<'_, T, P> { Ok((repos, NameLevel::Owner)) } None => { - let repo = db.must_get_fuzzy("", self.head)?; + let (repo, _) = self.fuzzy_get_repo(db, "", self.head)?; Ok((vec![repo], NameLevel::Owner)) } }; @@ -1412,7 +1434,8 @@ impl<'a, T: TerminalHelper, P: ProviderBuilder> Selector<'_, T, P> { let (owner, name) = parse_owner(self.query); let repo = if owner.is_empty() { - db.must_get_fuzzy(remote, &name)? + let (repo, _) = self.fuzzy_get_repo(db, remote, &name)?; + repo } else { db.must_get(remote, &owner, &name)? }; diff --git a/src/repo/keywords.rs b/src/repo/keywords.rs new file mode 100644 index 0000000..51bd98f --- /dev/null +++ b/src/repo/keywords.rs @@ -0,0 +1,214 @@ +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use crate::config::Config; +use crate::utils; + +pub struct Keywords { + data: HashMap>, + + path: PathBuf, + + disable: bool, + + now: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Record { + pub last_accessed: u64, + pub accessed: u64, +} + +impl Keywords { + const COMPLETE_ACCESSED: u64 = 3; + + pub fn load(cfg: &Config) -> Result { + let path = cfg.get_meta_dir().join("keywords"); + + let mut not_found = false; + let data: HashMap> = match fs::read(&path) { + Ok(data) => bincode::deserialize(&data).unwrap_or(HashMap::new()), + Err(err) if err.kind() == io::ErrorKind::NotFound => { + not_found = true; + HashMap::new() + } + Err(err) => { + return Err(err).with_context(|| format!("read keywords file '{}'", path.display())) + } + }; + + if cfg.keyword_expire == 0 { + // The user disable keyword, delete record file if it is exists + if !not_found { + fs::remove_file(&path) + .with_context(|| format!("delete keywords file '{}'", path.display()))?; + } + return Ok(Keywords { + data, + path, + disable: true, + now: 0, + }); + } + + let now = cfg.now(); + let mut filter_data: HashMap> = + HashMap::with_capacity(data.len()); + for (remote, records) in data { + let filter_records: HashMap = records + .into_iter() + .filter_map(|(kw, record)| { + let expire_time = record.last_accessed + cfg.keyword_expire; + if expire_time < now { + return None; + } + Some((kw, record)) + }) + .collect(); + if filter_records.is_empty() { + continue; + } + filter_data.insert(remote, filter_records); + } + + Ok(Keywords { + data: filter_data, + path, + disable: false, + now, + }) + } + + pub fn upsert(&mut self, remote: R, keyword: K) + where + R: AsRef, + K: AsRef, + { + if self.disable { + return; + } + let (remote, mut records) = self + .data + .remove_entry(remote.as_ref()) + .unwrap_or((remote.as_ref().to_string(), HashMap::with_capacity(1))); + + let (keyword, mut record) = records.remove_entry(keyword.as_ref()).unwrap_or(( + keyword.as_ref().to_string(), + Record { + last_accessed: 0, + accessed: 0, + }, + )); + + record.last_accessed = self.now; + record.accessed += 1; + + records.insert(keyword, record); + self.data.insert(remote, records); + } + + pub fn complete(mut self, remote: impl AsRef) -> Vec { + let records = match self.data.remove(remote.as_ref()) { + Some(records) => records, + None => return vec![], + }; + + let mut records: Vec<_> = records.into_iter().collect(); + records.sort_unstable_by(|(kw0, record0), (kw1, record1)| { + if record0.last_accessed != record1.last_accessed { + return record1.last_accessed.cmp(&record0.last_accessed); + } + if record0.accessed != record1.accessed { + return record1.accessed.cmp(&record0.accessed); + } + kw0.cmp(kw1) + }); + + records + .into_iter() + .filter_map(|(kw, record)| { + if record.accessed < Self::COMPLETE_ACCESSED { + return None; + } + Some(kw) + }) + .collect() + } + + pub fn save(self) -> Result<()> { + let data = bincode::serialize(&self.data).context("encode keywords data")?; + utils::write_file(&self.path, &data)?; + Ok(()) + } +} + +#[cfg(test)] +mod keywords_tests { + use crate::config::config_tests; + use crate::repo::keywords::*; + + #[test] + fn test_complete() { + let cfg = config_tests::load_test_config("keywords/completion"); + + let mut disable_cfg = cfg.clone(); + disable_cfg.keyword_expire = 0; + let _ = Keywords::load(&disable_cfg).unwrap(); // remove old file + + let mut keywords = Keywords::load(&cfg).unwrap(); + let cases = vec![ + ("", "go"), + ("", "go"), + ("", "vim"), + ("", "vim"), + ("", "vim"), + ("", "vim"), + ("", "vim"), + ("", "vim"), + ("test", "hello"), + ("test", "hello"), + ("", "rox"), + ("", "rox"), + ("", "rox"), + ("test", "rust"), + ("test", "rust"), + ("test", "rust"), + ]; + for (remote, keyword) in cases { + keywords.upsert(remote, keyword); + } + keywords.save().unwrap(); + + let expects = vec![("", vec!["vim", "rox"]), ("test", vec!["rust"])]; + for (remote, expect) in expects { + let keywords = Keywords::load(&cfg).unwrap(); + let keywords = keywords.complete(remote); + let result: Vec<_> = keywords.iter().map(|s| s.as_str()).collect(); + assert_eq!(result, expect); + } + } + + #[test] + fn test_expire() { + let mut cfg = config_tests::load_test_config("keywords/expire"); + cfg.keyword_expire = 3; + + let mut keywords = Keywords::load(&cfg).unwrap(); + keywords.upsert("", "rox"); + keywords.upsert("", "some"); + + keywords.save().unwrap(); + + cfg.set_now(cfg.now() + 5); + let keywords = Keywords::load(&cfg).unwrap(); + // All keywords should be expired + assert!(keywords.complete("").is_empty()); + } +} diff --git a/src/repo/mod.rs b/src/repo/mod.rs index 6f49b93..a4ea769 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -1,4 +1,5 @@ pub mod database; +pub mod keywords; pub mod snapshot; use std::collections::HashSet; diff --git a/src/utils.rs b/src/utils.rs index e49c78d..2df122a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -85,10 +85,10 @@ pub fn ensure_dir(path: &PathBuf) -> Result<()> { Ok(_) => Ok(()), Err(err) if err.kind() == io::ErrorKind::NotFound => { fs::create_dir_all(dir) - .with_context(|| format!("create directory {}", dir.display()))?; + .with_context(|| format!("create directory '{}'", dir.display()))?; Ok(()) } - Err(err) => Err(err).with_context(|| format!("read directory {}", dir.display())), + Err(err) => Err(err).with_context(|| format!("read directory '{}'", dir.display())), } } else { Ok(()) @@ -103,9 +103,9 @@ pub fn write_file(path: &PathBuf, data: &[u8]) -> Result<()> { opts.create(true).truncate(true).write(true); let mut file = opts .open(path) - .with_context(|| format!("open file {}", path.display()))?; + .with_context(|| format!("open file '{}'", path.display()))?; file.write_all(data) - .with_context(|| format!("write file {}", path.display()))?; + .with_context(|| format!("write file '{}'", path.display()))?; Ok(()) }