diff --git a/src/main.rs b/src/main.rs index bc75fb3..9ab4dba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use structopt::StructOpt; mod repo; -mod tags; +mod repository; mod ui; mod widget; diff --git a/src/repository/dockerhub.rs b/src/repository/dockerhub.rs new file mode 100644 index 0000000..373b874 --- /dev/null +++ b/src/repository/dockerhub.rs @@ -0,0 +1,101 @@ +use serde::Deserialize; + +use crate::repo; +use crate::repository::Error; + +#[derive(Deserialize, Debug, Clone)] +struct ImageDetails { + architecture: String, + size: usize, +} + +#[derive(Deserialize, Clone)] +pub struct Images { + images: Vec, + #[serde(rename(deserialize = "name"))] + tag_name: String, + last_updated: String, +} + +impl Images { + pub fn convert(&self) -> super::Tag { + super::Tag { + name: self.tag_name.clone(), + last_updated: Some(self.last_updated.clone()), + details: self + .images + .iter() + .map(|d| super::TagDetails { + arch: Some(d.architecture.clone()), + size: Some(d.size.clone()), + }) + .collect(), + } + } +} + +#[derive(Deserialize)] +pub struct DockerHub { + #[serde(rename(deserialize = "next"))] + next_page: Option, + results: Vec, +} + +impl DockerHub { + /// fetches tag information with a repository name in the form of organization/repository or library/repository in the case of official images from docker + pub fn new(repo: &str) -> Result { + let request = format!("https://hub.docker.com/v2/repositories/{}/tags", repo); + Self::with_url(&request) + } + + /// fetches tag information from a url + pub fn with_url(url: &str) -> Result { + let response = match reqwest::blocking::get(url) { + Ok(result) => result, + Err(e) => return Err(Error::Fetching(format!("reqwest error: {}", e))), + }; + + //convert it to json + let tags = match response.json::() { + Ok(result) => result, + Err(e) => return Err(Error::Converting(format!("invalid json: {}", e))), + }; + + if tags.results.is_empty() { + return Err(Error::NoTagsFound); + } + + Ok(super::Repo { + tags: tags.results.iter().map(|t| t.convert()).collect(), + next_page: tags.next_page, + }) + } + + /// checks the repo name and may add a prefix for official images + pub fn check_repo(name: &str) -> Result { + let repo = match repo::split_tag_from_repo(name) { + Err(e) => return Err(Error::Converting(format!("{}", e))), + Ok((name, _)) => name, + }; + + match repo::split_repo_without_tag(name) { + Ok(repo::Repo::Project(s)) => Ok(format!("library/{}", s)), + Ok(_) => Ok(repo.to_string()), + Err(e) => Err(Error::Converting(format!("{}", e))), + } + } +} + +#[cfg(test)] +mod tests { + use crate::repository::dockerhub::Repo; + #[test] + fn test_check_repo() { + assert_eq!(Repo::check_repo("nginx").unwrap(), "library/nginx"); + assert_eq!(Repo::check_repo("library/nginx").unwrap(), "library/nginx"); + assert_eq!( + Repo::check_repo("rocketchat/rocket.chat").unwrap(), + "rocketchat/rocket.chat" + ); + } +} diff --git a/src/repository/mod.rs b/src/repository/mod.rs new file mode 100644 index 0000000..91c182f --- /dev/null +++ b/src/repository/mod.rs @@ -0,0 +1,149 @@ +pub mod dockerhub; + +use std::fmt; + +use chrono::DateTime; + +#[derive(Debug, PartialEq)] +pub enum Error { + /// couldn't fetch json with reqwest + Fetching(String), + /// a serde error + Converting(String), + /// invalid repos show a valid json with 0 tags + NoTagsFound, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Fetching(s) => write!(f, "Fetching error: {}", s), + Error::Converting(s) => write!(f, "Converting error: {}", s), + Error::NoTagsFound => write!(f, "Given Repo has 0 tags. Is it valid?"), + } + } +} + +#[derive(Clone)] +pub struct TagDetails { + arch: Option, + size: Option, +} + +impl fmt::Display for TagDetails { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let size = match self.size { + None => "".to_string(), + Some(s) => (s / 1024 / 1024).to_string(), + }; + write!( + f, + "{}|{}MB", + self.arch.as_ref().unwrap_or(&"".to_string()), + size + ) + } +} + +#[derive(Clone)] +pub struct Tag { + name: String, + details: Vec, + last_updated: Option, +} + +impl Tag { + pub fn get_name(&self) -> &str { + &self.name + } + + pub fn get_name_with_details(&self) -> String { + //architecture infos + let mut arch = String::new(); + for image in self.details.iter().take(1) { + arch.push_str(&format!("{}", image)); + } + for image in self.details.iter().skip(1) { + arch.push_str(&format!(", {}", image)); + } + + let dif = match &self.last_updated { + None => "".to_string(), + Some(last_updated) => { + let now = chrono::Utc::now(); + let rfc3339 = DateTime::parse_from_rfc3339(last_updated).unwrap(); + let dif = now - rfc3339.with_timezone(&chrono::Utc); + format!("{}", format_time_nice(dif)) + } + }; + format!("{} vor {} [{}]", self.name, dif, arch) + } +} + +pub struct Repo { + // name: String, + tags: Vec, + next_page: Option, +} + +impl Repo { + pub fn new(repo: &str) -> Result { + use crate::repo::Repo; + let (registry, repo) = match crate::repo::split_repo_without_tag(repo) { + Ok(Repo::WithServer(reg, org, pro)) => (Some(reg), format!("{}/{}", org, pro)), + Ok(Repo::WithOrga(org, pro)) => (None, format!("{}/{}", org, pro)), + Ok(Repo::Project(pro)) => (None, format!("library/{}", pro)), + Err(e) => return Err(Error::Converting(format!("{}", e))), + }; + + // if ®istry == "ghcr.io" { + // // + // } else { + // dockerhub::DockerHub::new(repo) + // } + + dockerhub::DockerHub::new(&repo) + } + + pub fn with_url(url: &str) -> Result { + //TODO fix for other registries + dockerhub::DockerHub::with_url(url) + } + + pub fn get_tags(&self) -> &Vec { + &self.tags + } + + pub fn next_page(&self) -> Option { + match &self.next_page { + Some(url) => match Self::with_url(url) { + Ok(tags) => Some(tags), + Err(_) => None, + }, + None => None, + } + } +} + +/// converts a given duration to a readable string +fn format_time_nice(time: chrono::Duration) -> String { + if time.num_weeks() == 52 { + format!("{} Jahr", (time.num_weeks() / 52) as i32) + } else if time.num_weeks() > 103 { + format!("{} Jahren", (time.num_weeks() / 52) as i32) + } else if time.num_days() == 1 { + format!("{} Tag", time.num_days()) + } else if time.num_days() > 1 { + format!("{} Tagen", time.num_days()) + } else if time.num_hours() == 1 { + format!("{} Stunde", time.num_hours()) + } else if time.num_hours() > 1 { + format!("{} Stunden", time.num_hours()) + } else if time.num_minutes() == 1 { + format!("{} Minute", time.num_minutes()) + } else if time.num_minutes() > 1 { + format!("{} Minuten", time.num_minutes()) + } else { + format!("{} Sekunden", time.num_seconds()) + } +} diff --git a/src/tags.rs b/src/tags.rs deleted file mode 100644 index fb5a40f..0000000 --- a/src/tags.rs +++ /dev/null @@ -1,168 +0,0 @@ -use std::fmt; - -use crate::repo; -use chrono::DateTime; -use serde::Deserialize; - -#[derive(Deserialize, Debug, Clone)] -struct ImageDetails { - architecture: String, - size: usize, -} - -impl fmt::Display for ImageDetails { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}|{}MB", self.architecture, self.size / 1024 / 1024) - } -} - -#[derive(Deserialize, Clone)] -pub struct Images { - images: Vec, - #[serde(rename(deserialize = "name"))] - pub tag_name: String, - last_updated: String, -} - -impl fmt::Display for Images { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - //architecture infos - let mut arch = String::new(); - for image in self.images.iter().take(1) { - arch.push_str(&format!("{}", image)); - } - for image in self.images.iter().skip(1) { - arch.push_str(&format!(", {}", image)); - } - - let now = chrono::Utc::now(); - let rfc3339 = DateTime::parse_from_rfc3339(&self.last_updated).unwrap(); - let dif = now - rfc3339.with_timezone(&chrono::Utc); - write!( - f, - "{} vor {} [{}]", - self.tag_name, - format_time_nice(dif), - arch - ) - } -} - -#[derive(Deserialize)] -pub struct Tags { - count: usize, - #[serde(rename(deserialize = "next"))] - pub next_page: Option, - pub results: Vec, -} - -#[derive(Debug, PartialEq)] -pub enum Error { - /// couldn't fetch json with reqwest - Fetching(String), - /// a serde error - Converting(String), - /// invalid repos show a valid json with 0 tags - NoTagsFound, -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::Fetching(s) => write!(f, "Fetching error: {}", s), - Error::Converting(s) => write!(f, "Converting error: {}", s), - Error::NoTagsFound => write!(f, "Given Repo has 0 tags. Is it valid?"), - } - } -} - -impl Tags { - /// fetches tag information with a repository name in the form of organization/repository or library/repository in the case of official images from docker - pub fn new(repo: String) -> Result { - let request = format!("https://hub.docker.com/v2/repositories/{}/tags", repo); - Self::with_url(&request) - } - - /// fetches tag information from a url - fn with_url(url: &str) -> Result { - let res = match reqwest::blocking::get(url) { - Ok(result) => result, - Err(e) => return Err(Error::Fetching(format!("reqwest error: {}", e))), - }; - - //convert it to json - let raw = res.text().unwrap(); - let tags: Self = match serde_json::from_str(&raw) { - Ok(result) => result, - Err(e) => return Err(Error::Converting(format!("invalid json: {}", e))), - }; - - if tags.count == 0 { - return Err(Error::NoTagsFound); - } - - Ok(tags) - } - - /// checks the repo name and may add a prefix for official images - pub fn check_repo(name: &str) -> Result { - let repo = match repo::split_tag_from_repo(name) { - Err(e) => return Err(Error::Converting(format!("{}", e))), - Ok((name, _)) => name, - }; - - match repo::split_repo_without_tag(name) { - Ok(repo::Repo::Project(s)) => Ok(format!("library/{}", s)), - Ok(_) => Ok(repo.to_string()), - Err(e) => Err(Error::Converting(format!("{}", e))), - } - } - - /// returns tags of next page - pub fn next_page(&self) -> Option { - match &self.next_page { - Some(url) => match Self::with_url(url) { - Ok(tags) => Some(tags), - Err(_) => None, - }, - None => None, - } - } -} - -/// converts a given duration to a readable string -fn format_time_nice(time: chrono::Duration) -> String { - if time.num_weeks() == 52 { - format!("{} Jahr", (time.num_weeks() / 52) as i32) - } else if time.num_weeks() > 103 { - format!("{} Jahren", (time.num_weeks() / 52) as i32) - } else if time.num_days() == 1 { - format!("{} Tag", time.num_days()) - } else if time.num_days() > 1 { - format!("{} Tagen", time.num_days()) - } else if time.num_hours() == 1 { - format!("{} Stunde", time.num_hours()) - } else if time.num_hours() > 1 { - format!("{} Stunden", time.num_hours()) - } else if time.num_minutes() == 1 { - format!("{} Minute", time.num_minutes()) - } else if time.num_minutes() > 1 { - format!("{} Minuten", time.num_minutes()) - } else { - format!("{} Sekunden", time.num_seconds()) - } -} - -#[cfg(test)] -mod tests { - use crate::tags::Tags; - #[test] - fn test_check_repo() { - assert_eq!(Tags::check_repo("nginx").unwrap(), "library/nginx"); - assert_eq!(Tags::check_repo("library/nginx").unwrap(), "library/nginx"); - assert_eq!( - Tags::check_repo("rocketchat/rocket.chat").unwrap(), - "rocketchat/rocket.chat" - ); - } -} diff --git a/src/ui/default.rs b/src/ui/default.rs index e143e4a..7a19cde 100644 --- a/src/ui/default.rs +++ b/src/ui/default.rs @@ -7,6 +7,7 @@ use tui::backend::TermionBackend; use tui::layout::{Constraint, Direction, Layout}; use tui::Terminal; +use crate::repository::dockerhub; use crate::widget::info; use crate::widget::repo_entry; use crate::widget::service_switcher; @@ -156,7 +157,7 @@ impl Ui { match ui.services.extract_repo() { Err(e) => ui.info.set_info(&format!("{}", e)), Ok(s) => { - let repo = match crate::tags::Tags::check_repo(&s) { + let repo = match dockerhub::DockerHub::check_repo(&s) { Err(e) => { ui.info.set_info(&format!("{}", e)); continue; @@ -177,7 +178,7 @@ impl Ui { match ui.services.extract_repo() { Err(e) => ui.info.set_info(&format!("{}", e)), Ok(s) => { - let repo = match crate::tags::Tags::check_repo(&s) { + let repo = match dockerhub::DockerHub::check_repo(&s) { Err(e) => { ui.info.set_info(&format!("{}", e)); continue; diff --git a/src/widget/tag_list.rs b/src/widget/tag_list.rs index 656fd04..5f86b4d 100644 --- a/src/widget/tag_list.rs +++ b/src/widget/tag_list.rs @@ -4,7 +4,7 @@ use termion::event::Key; use tui::style::{Color, Style}; use tui::widgets::{Block, Borders, List, ListState}; -use crate::tags; +use crate::repository; pub enum Error { NoneSelected, @@ -24,7 +24,7 @@ impl fmt::Display for Error { enum Line { Status(String), - Image(tags::Images), + Image(repository::Tag), NextPage(String), } @@ -32,7 +32,7 @@ impl fmt::Display for Line { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Line::Status(s) => write!(f, "{}", s), - Line::Image(i) => write!(f, "{}", i), + Line::Image(i) => write!(f, "{}", i.get_name_with_details()), Line::NextPage(s) => write!(f, "{}", s), } } @@ -41,7 +41,7 @@ impl fmt::Display for Line { pub struct TagList { lines: Vec, state: ListState, - tags: Option, + tags: Option, } impl TagList { @@ -54,15 +54,15 @@ impl TagList { } pub fn with_repo_name(repo: String) -> Self { - match tags::Tags::new(repo) { + match repository::Repo::new(&repo) { Ok(tags) => Self::with_tags(tags), Err(_) => Self::with_status("input repo was not found"), } } - pub fn with_tags(mut tags: tags::Tags) -> Self { + pub fn with_tags(mut tags: repository::Repo) -> Self { let mut lines: Vec = tags - .results + .get_tags() .iter() .map(|r| Line::Image(r.clone())) .collect(); @@ -136,7 +136,7 @@ impl TagList { None => Err(Error::NoneSelected), Some(i) => match &self.lines[i] { Line::Status(_) => Err(Error::SelectedStatus), - Line::Image(i) => Ok(i.tag_name.clone()), + Line::Image(i) => Ok(i.get_name().to_string()), Line::NextPage(_) => { self.load_next_page(); Err(Error::NextPageSelected) @@ -158,12 +158,17 @@ impl TagList { let next_page = self.lines.pop(); //add tags - for image in &self.tags.as_ref().unwrap().results { - self.lines.push(Line::Image(image.clone())); + match &self.tags { + None => (), + Some(tags) => { + for image in tags.get_tags().iter() { + self.lines.push(Line::Image(image.clone())); + } + } } //readd next page - match self.tags.as_ref().unwrap().next_page { + match self.tags.as_ref().unwrap().next_page() { None => (), Some(_) => self.lines.push(next_page.unwrap()), }