diff --git a/src/api/alias.rs b/src/api/alias.rs index 0f850fb..f95d4db 100644 --- a/src/api/alias.rs +++ b/src/api/alias.rs @@ -14,6 +14,10 @@ pub struct Alias { } impl Provider for Alias { + fn info(&self) -> Result { + self.upstream.info() + } + fn list_repos(&self, owner: &str) -> Result> { let owner = self.alias_owner(owner); let names = self.upstream.list_repos(owner)?; diff --git a/src/api/cache.rs b/src/api/cache.rs index 048f45d..311c360 100644 --- a/src/api/cache.rs +++ b/src/api/cache.rs @@ -27,6 +27,10 @@ pub struct Cache { } impl Provider for Cache { + fn info(&self) -> Result { + self.upstream.info() + } + fn list_repos(&self, owner: &str) -> Result> { let path = self.list_repos_path(owner); if !self.force { diff --git a/src/api/github.rs b/src/api/github.rs index f64a594..56c3d50 100644 --- a/src/api/github.rs +++ b/src/api/github.rs @@ -240,6 +240,17 @@ pub struct Github { } impl Provider for Github { + fn info(&self) -> Result { + let auth = self.token.is_some(); + let ping = self.execute_get_resp("").is_ok(); + + Ok(ProviderInfo { + name: format!("GitHub {}", Self::API_VERSION), + auth, + ping, + }) + } + fn list_repos(&self, owner: &str) -> Result> { let path = format!("users/{owner}/repos?per_page={}", self.per_page); let github_repos = self.execute_get::>(&path)?; diff --git a/src/api/gitlab.rs b/src/api/gitlab.rs index 034c1f1..137f042 100644 --- a/src/api/gitlab.rs +++ b/src/api/gitlab.rs @@ -133,6 +133,17 @@ struct GitlabError { } impl Provider for Gitlab { + fn info(&self) -> Result { + let auth = self.token.is_some(); + let ping = self.execute_get_resp("projects").is_ok(); + + Ok(ProviderInfo { + name: format!("GitLab v{}", Gitlab::API_VERSION), + auth, + ping, + }) + } + fn list_repos(&self, owner: &str) -> Result> { let owner_encode = urlencoding::encode(owner); let path = format!("groups/{owner_encode}/projects?per_page={}", self.per_page); diff --git a/src/api/mod.rs b/src/api/mod.rs index c536334..17586d1 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -18,6 +18,25 @@ use crate::api::github::Github; use crate::api::gitlab::Gitlab; use crate::config::{Config, ProviderType, RemoteConfig}; +#[derive(Debug, Serialize)] +pub struct ProviderInfo { + pub name: String, + pub auth: bool, + pub ping: bool, +} + +impl Display for ProviderInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let auth = if self.auth { "with auth" } else { "no auth" }; + let ping = if self.ping { + format!("ping {}", style("ok").green()) + } else { + format!("ping {}", style("failed").red()) + }; + write!(f, "{}, {auth}, {ping}", self.name) + } +} + /// Represents repository information obtained from a [`Provider`]. #[derive(Debug, PartialEq, Deserialize, Serialize)] pub struct ApiRepo { @@ -208,6 +227,8 @@ impl ActionJobStatus { /// cache layer. External components do not need to be concerned with the internal /// implementation of the provider. pub trait Provider { + fn info(&self) -> Result; + /// Retrieve all repositories under a given owner. fn list_repos(&self, owner: &str) -> Result>; @@ -336,6 +357,10 @@ pub mod api_tests { } impl Provider for StaticProvider { + fn info(&self) -> Result { + todo!() + } + fn list_repos(&self, owner: &str) -> Result> { match self.repos.get(owner) { Some(repos) => Ok(repos.clone()), diff --git a/src/cmd/info.rs b/src/cmd/info.rs index d11b0a7..a62bfb0 100644 --- a/src/cmd/info.rs +++ b/src/cmd/info.rs @@ -1,161 +1,204 @@ -use std::collections::BTreeMap; -use std::fs; +use std::borrow::Cow; +use std::fmt::Display; use std::path::PathBuf; +use std::{fs, io}; -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use clap::Args; +use console::style; use serde::Serialize; +use crate::api::{self, ProviderInfo}; use crate::cmd::Run; -use crate::config::Config; +use crate::config::{Config, RemoteConfig}; use crate::repo::database::Database; use crate::term::{self, Cmd}; use crate::utils; /// Show some global info #[derive(Args)] -pub struct InfoArgs {} +pub struct InfoArgs { + /// Show the output in json format. + #[clap(short, long)] + pub json: bool, +} impl Run for InfoArgs { fn run(&self, cfg: &Config) -> Result<()> { let db = Database::load(cfg)?; + let info = Info::build(cfg, &db)?; - let git = Self::convert_component(Self::git_info()); - let fzf = Self::convert_component(Self::fzf_info()); + if self.json { + return term::show_json(info); + } - let config = Self::config(cfg)?; + eprint!("{info}"); - let repos = db.list_all(&None); + Ok(()) + } +} - let mut total_size: u64 = 0; - let mut workspace_count: u32 = 0; - let mut workspace_size: u64 = 0; - let mut standalone_count: u32 = 0; - let mut standalone_size: u64 = 0; - let mut remotes: BTreeMap = BTreeMap::new(); - - let mut scan_file: u64 = 0; - - for repo in repos { - let path = repo.get_path(cfg); - let mut size: u64 = 0; - utils::walk_dir(path, |_file, meta| { - if meta.is_file() { - scan_file += 1; - size += meta.len(); - } - Ok(true) - })?; - - total_size += size; - match repo.path.as_ref() { - Some(_) => { - standalone_count += 1; - standalone_size += size; - } - None => { - workspace_count += 1; - workspace_size += size; - } - }; +#[derive(Debug, Serialize)] +struct Info { + config: ConfigInfo, + commands: Vec, + standalone: Vec, + + meta_disk_usage: MetaDiskUsage, + repo_disk_usage: Vec, + + remote_api: Vec, +} + +impl Info { + fn build(cfg: &Config, db: &Database) -> Result { + let config = ConfigInfo::build(cfg)?; + let commands = vec![CommandInfo::git()?, CommandInfo::fzf()?]; + let standalone = StandaloneInfo::list(db); + + let meta_disk_usage = MetaDiskUsage::build(cfg)?; + + let mut remote_api = Vec::with_capacity(cfg.remotes.len()); + for remote_cfg in cfg.remotes.values() { + let api_info = RemoteApiInfo::build(remote_cfg)?; + remote_api.push(api_info); + } + remote_api.sort_unstable_by(|a, b| a.name.cmp(&b.name)); + + let repo_disk_usage = RemoteDiskUsage::scan(cfg, db)?; + + Ok(Info { + config, + commands, + standalone, + meta_disk_usage, + repo_disk_usage, + remote_api, + }) + } - let (remote, mut remote_info) = remotes - .remove_entry(repo.remote.as_ref()) - .unwrap_or_else(|| { - let clone = repo.remote_cfg.clone.is_some(); - ( - repo.remote.to_string(), - RemoteInfo { - clone, - size: None, - repo_count: 0, - owner_count: 0, - owners: BTreeMap::new(), - size_u64: 0, - }, - ) - }); - - remote_info.size_u64 += size; - remote_info.repo_count += 1; - - let (owner, mut owner_info) = remote_info - .owners - .remove_entry(repo.owner.as_ref()) - .unwrap_or_else(|| { - ( - repo.owner.to_string(), - OwnerInfo { - repo_count: 0, - size: None, - size_u64: 0, - }, - ) - }); - - owner_info.repo_count += 1; - owner_info.size_u64 += size; - - remote_info.owners.insert(owner, owner_info); - remotes.insert(remote, remote_info); + fn show_title(f: &mut std::fmt::Formatter<'_>, title: &str, name: &str) -> std::fmt::Result { + let start = style(format!("[{title} ")).blue().bold(); + let name = style(name).magenta().bold(); + let end = style("]").blue().bold(); + writeln!(f, "{start}{name}{end}") + } +} + +impl Display for Info { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{}", style("[Config]").blue().bold())?; + writeln!(f, "\tpath: {}", self.config.path)?; + writeln!(f, "\tmeta: {}", self.config.meta_path)?; + writeln!(f)?; + + if !self.commands.is_empty() { + writeln!(f, "{}", style("[Commands]").blue().bold())?; + for cmd in self.commands.iter() { + writeln!(f, "\t{cmd}")?; + } + writeln!(f)?; } - for (_, remote) in remotes.iter_mut() { - remote.size = Some(utils::human_bytes(remote.size_u64)); - remote.owner_count = remote.owners.len() as u32; - for (_, owner) in remote.owners.iter_mut() { - owner.size = Some(utils::human_bytes(owner.size_u64)); + if !self.standalone.is_empty() { + writeln!(f, "{}", style("[Standalone]").blue().bold())?; + for standalone in self.standalone.iter() { + writeln!(f, "\t{standalone}")?; } + writeln!(f)?; } - let info = Info { - git, - fzf, - total_repo_size: utils::human_bytes(total_size), - files_count: scan_file, - workspace: RepoInfo { - count: workspace_count, - size: utils::human_bytes(workspace_size), - }, - standalone: RepoInfo { - count: standalone_count, - size: utils::human_bytes(standalone_size), - }, - config, - remote_count: remotes.len() as u32, - remotes, - }; + writeln!(f, "{}", style("[Meta Disk Usage]").blue().bold())?; + let meta_database = utils::human_bytes(self.meta_disk_usage.database); + writeln!(f, "\tTotal: {}", self.meta_disk_usage.total)?; + writeln!(f, "\tDatabase: {meta_database}")?; + writeln!(f)?; + + if !self.repo_disk_usage.is_empty() { + for remote_usage in self.repo_disk_usage.iter() { + Self::show_title(f, "Disk Usage", &remote_usage.name)?; + writeln!(f, "\tTotal: {}", remote_usage.usage)?; + writeln!(f)?; + + for owner_usage in remote_usage.owners.iter() { + let owner_name = format!("{}/{}", remote_usage.name, owner_usage.name); + Self::show_title(f, "Disk Usage", &owner_name)?; + writeln!(f, "\tTotal: {}", owner_usage.usage)?; + for repo_usage in owner_usage.repos.iter() { + writeln!(f, "\t{}: {}", repo_usage.name, repo_usage.usage)?; + } + writeln!(f)?; + } + } + } + + if !self.remote_api.is_empty() { + writeln!(f, "{}", style("[Remote API]").blue().bold())?; + for api in self.remote_api.iter() { + let provider = api + .provider + .as_ref() + .map(|p| Cow::Owned(format!("{p}"))) + .unwrap_or(Cow::Borrowed("")); + writeln!(f, "\t{}: {provider}", api.name)?; + } + writeln!(f)?; + } - term::show_json(info) + Ok(()) } } -impl InfoArgs { - fn git_info() -> Result { - let path = Self::get_component_path("git")?; +#[derive(Debug, Serialize)] +struct ConfigInfo { + path: String, + meta_path: String, +} + +impl ConfigInfo { + fn build(cfg: &Config) -> Result { + let path = Config::get_path()? + .map(|path| format!("{}", path.display())) + .unwrap_or(String::from("N/A")); + let meta_path = format!("{}", cfg.get_meta_dir().display()); + Ok(ConfigInfo { path, meta_path }) + } +} + +#[derive(Debug, Serialize)] +struct CommandInfo { + name: &'static str, + path: String, + version: String, +} + +impl CommandInfo { + fn git() -> Result { + let path = Self::get_command_path("git")?; let version = Cmd::git(&["version"]).read()?; - let version = match version.trim().strip_prefix("git version") { - Some(v) => v.trim(), - None => version.as_str(), - }; - Ok(ComponentInfo { - enable: true, + let version = version + .trim() + .strip_prefix("git version") + .map(|s| s.trim().to_string()) + .unwrap_or(version); + Ok(CommandInfo { + name: "git", path, - version: String::from(version), + version, }) } - fn fzf_info() -> Result { - let path = Self::get_component_path("fzf")?; + fn fzf() -> Result { + let path = Self::get_command_path("fzf")?; let version = Cmd::with_args("fzf", &["--version"]).read()?; - Ok(ComponentInfo { - enable: true, + Ok(CommandInfo { + name: "fzf", path, version, }) } - fn get_component_path(cmd: &str) -> Result { + fn get_command_path(cmd: &str) -> Result { let path = Cmd::with_args("which", &[cmd]).read()?; let path = path.trim(); if path.is_empty() { @@ -163,87 +206,200 @@ impl InfoArgs { } Ok(String::from(path)) } +} - fn convert_component(r: Result) -> ComponentInfo { - r.unwrap_or_else(|_| ComponentInfo { - enable: false, - path: String::new(), - version: String::new(), - }) +impl Display for CommandInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}, {}", self.name, self.path, self.version) } +} - fn config(cfg: &Config) -> Result { - let path = match Config::get_path()? { - Some(path) => format!("{}", path.display()), - None => "N/A".to_string(), - }; - let meta_dir = format!("{}", cfg.get_meta_dir().display()); - let meta_size = utils::dir_size(PathBuf::from(&meta_dir))?; +#[derive(Debug, Serialize)] +struct StandaloneInfo { + name: String, + path: String, +} - let db_path = cfg.get_meta_dir().join("database"); - let db_meta = fs::metadata(db_path)?; - let db_size = utils::human_bytes(db_meta.len()); +impl StandaloneInfo { + fn list(db: &Database) -> Vec { + let repos = db.list_all(&None); + repos + .into_iter() + .filter(|repo| repo.path.is_some()) + .map(|repo| StandaloneInfo { + name: repo.name_with_remote(), + path: repo.path.unwrap().to_string(), + }) + .collect() + } +} - Ok(ConfigInfo { - path, - meta_path: meta_dir, - meta_size: utils::human_bytes(meta_size), - database_size: db_size, - }) +impl Display for StandaloneInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} -> {}", self.name, self.path) } } #[derive(Debug, Serialize)] -struct Info { - pub git: ComponentInfo, - pub fzf: ComponentInfo, - pub total_repo_size: String, - pub files_count: u64, - pub workspace: RepoInfo, - pub standalone: RepoInfo, - pub config: ConfigInfo, - pub remote_count: u32, - pub remotes: BTreeMap, +struct DirectoryUsage { + files: u64, + dirs: u64, + size: u64, +} + +impl DirectoryUsage { + fn scan(dir: PathBuf) -> Result { + let mut files: u64 = 0; + let mut dirs: u64 = 0; + let mut size: u64 = 0; + utils::walk_dir(dir, |_, meta| { + if meta.is_dir() { + dirs += 1; + } + if meta.is_file() { + files += 1; + } + size += meta.len(); + + Ok(true) + })?; + + Ok(DirectoryUsage { files, dirs, size }) + } +} + +impl Display for DirectoryUsage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let files_word = if self.files > 1 { "files" } else { "file" }; + let dirs_word = if self.dirs > 1 { "dirs" } else { "dir" }; + let size = utils::human_bytes(self.size); + write!( + f, + "{} {files_word}, {} {dirs_word}, {size}", + self.files, self.dirs + ) + } } #[derive(Debug, Serialize)] -struct ComponentInfo { - pub enable: bool, - pub path: String, - pub version: String, +struct MetaDiskUsage { + total: DirectoryUsage, + database: u64, +} + +impl MetaDiskUsage { + fn build(cfg: &Config) -> Result { + let meta_dir = cfg.get_meta_dir(); + let database_path = meta_dir.join("database"); + + let total = DirectoryUsage::scan(meta_dir.clone())?; + let database_size = match fs::metadata(database_path) { + Ok(meta) => meta.len(), + Err(err) if err.kind() == io::ErrorKind::NotFound => 0, + Err(err) => return Err(err).context("get metadata for database"), + }; + + Ok(MetaDiskUsage { + total, + database: database_size, + }) + } } #[derive(Debug, Serialize)] -struct RepoInfo { - pub count: u32, - pub size: String, +struct RemoteDiskUsage { + name: String, + usage: DirectoryUsage, + owners: Vec, } #[derive(Debug, Serialize)] -struct ConfigInfo { - pub path: String, - pub meta_path: String, - pub meta_size: String, - pub database_size: String, +struct OwnerDiskUsage { + name: String, + usage: DirectoryUsage, + repos: Vec, } #[derive(Debug, Serialize)] -struct RemoteInfo { - pub clone: bool, - pub size: Option, - pub repo_count: u32, - pub owner_count: u32, - pub owners: BTreeMap, +struct RepoDiskUsage { + name: String, + usage: DirectoryUsage, +} + +impl RemoteDiskUsage { + fn scan(cfg: &Config, db: &Database) -> Result> { + let mut remotes: Vec = Vec::with_capacity(cfg.remotes.len()); + for remote_name in cfg.remotes.keys() { + let owner_names = db.list_owners(remote_name); + + let mut remote = RemoteDiskUsage { + name: remote_name.to_string(), + usage: DirectoryUsage { + files: 0, + dirs: 0, + size: 0, + }, + owners: Vec::with_capacity(owner_names.len()), + }; - #[serde(skip)] - size_u64: u64, + for owner_name in owner_names { + let repos = db.list_by_owner(remote_name, &owner_name, &None); + let mut owner = OwnerDiskUsage { + name: owner_name.clone(), + usage: DirectoryUsage { + files: 0, + dirs: 0, + size: 0, + }, + repos: Vec::with_capacity(repos.len()), + }; + + for repo in repos { + let path = repo.get_path(cfg); + let usage = DirectoryUsage::scan(path).with_context(|| { + format!("scan disk usage for {}", repo.name_with_remote()) + })?; + + owner.usage.files += usage.files; + owner.usage.dirs += usage.dirs; + owner.usage.size += usage.size; + owner.repos.push(RepoDiskUsage { + name: repo.name.into_owned(), + usage, + }); + } + + remote.usage.files += owner.usage.files; + remote.usage.dirs += owner.usage.dirs; + remote.usage.size += owner.usage.size; + remote.owners.push(owner); + } + + remotes.push(remote); + } + + remotes.sort_unstable_by(|a, b| a.name.cmp(&b.name)); + Ok(remotes) + } } #[derive(Debug, Serialize)] -struct OwnerInfo { - pub repo_count: u32, - pub size: Option, +struct RemoteApiInfo { + name: String, + provider: Option, +} - #[serde(skip)] - size_u64: u64, +impl RemoteApiInfo { + fn build(remote_cfg: &RemoteConfig) -> Result { + let name = remote_cfg.get_name().to_string(); + let mut provider_info: Option = None; + if remote_cfg.provider.is_some() { + let provider = api::build_raw_provider(remote_cfg); + provider_info = Some(provider.info()?); + } + Ok(RemoteApiInfo { + name, + provider: provider_info, + }) + } } diff --git a/src/term.rs b/src/term.rs index bdfa04f..af8ab30 100644 --- a/src/term.rs +++ b/src/term.rs @@ -103,7 +103,7 @@ pub fn show_info(msg: impl AsRef) { /// Output the object in pretty JSON format in the terminal. pub fn show_json(value: T) -> Result<()> { - let formatter = PrettyFormatter::with_indent(b" "); + let formatter = PrettyFormatter::with_indent(b"\t"); let mut buf = Vec::new(); let mut ser = Serializer::with_formatter(&mut buf, formatter); value.serialize(&mut ser).context("serialize object")?;