use std::sync::Arc;
use serde::Serialize;
use serde_json::{json, Value};
use traits::{ArtifactId, Health, HolgerObject, RepositoryInfo};
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct ArchiveView {
pub repository: String,
pub files: Vec<String>,
pub file_count: u64,
pub total_uncompressed_bytes: u64,
pub archive_path: String,
pub error: Option<String>,
}
impl ArchiveView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct RepoRow {
pub name: String,
pub repo_type: String,
pub writable: bool,
pub has_archive: bool,
}
impl From<RepositoryInfo> for RepoRow {
fn from(r: RepositoryInfo) -> Self {
Self {
name: r.name,
repo_type: r.repo_type,
writable: r.writable,
has_archive: r.has_archive,
}
}
}
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct StatusView {
pub status: String,
pub version: String,
pub uptime_seconds: i64,
pub loaded: bool,
pub error: Option<String>,
}
impl StatusView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct ReposView {
pub repos: Vec<RepoRow>,
pub selected: Option<usize>,
pub error: Option<String>,
}
impl ReposView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
pub fn selected_repo(&self) -> Option<&RepoRow> {
self.selected.and_then(|i| self.repos.get(i))
}
pub fn upload_enabled(&self) -> bool {
self.selected_repo().map(|r| r.writable).unwrap_or(false)
}
}
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct ArtifactView {
pub repository: String,
pub namespace: Option<String>,
pub name: String,
pub version: String,
pub size_bytes: u64,
pub content_type: String,
pub found: bool,
pub error: Option<String>,
#[serde(skip)]
pub data: Vec<u8>,
}
impl ArtifactView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct BrowseRow {
pub namespace: Option<String>,
pub name: String,
pub version: String,
pub size_bytes: i64,
pub content_type: String,
}
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct BrowseView {
pub repository: String,
pub entries: Vec<BrowseRow>,
pub next_page_token: String,
pub error: Option<String>,
}
impl BrowseView {
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
pub struct UiData {
holger: Arc<dyn HolgerObject>,
rt: tokio::runtime::Runtime,
pub status: StatusView,
pub repos: ReposView,
pub artifact: ArtifactView,
pub browse: BrowseView,
pub archive: ArchiveView,
}
impl UiData {
pub fn new(holger: Arc<dyn HolgerObject>) -> anyhow::Result<Self> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
Ok(Self {
holger,
rt,
status: StatusView::default(),
repos: ReposView::default(),
artifact: ArtifactView::default(),
browse: BrowseView::default(),
archive: ArchiveView::default(),
})
}
pub fn with_runtime(holger: Arc<dyn HolgerObject>, rt: tokio::runtime::Runtime) -> Self {
Self {
holger,
rt,
status: StatusView::default(),
repos: ReposView::default(),
artifact: ArtifactView::default(),
browse: BrowseView::default(),
archive: ArchiveView::default(),
}
}
fn block<T>(&self, fut: impl std::future::Future<Output = T>) -> T {
self.rt.block_on(fut)
}
pub fn refresh_status(&mut self) {
match self.block(self.holger.health()) {
Ok(Health {
status,
version,
uptime_seconds,
}) => {
self.status = StatusView {
status,
version,
uptime_seconds,
loaded: true,
error: None,
};
}
Err(e) => {
self.status.loaded = false;
self.status.error = Some(e.to_string());
}
}
}
pub fn refresh_repos(&mut self) {
let prev = self.repos.selected_repo().map(|r| r.name.clone());
match self.block(self.holger.list_repositories()) {
Ok(list) => {
let repos: Vec<RepoRow> = list.into_iter().map(RepoRow::from).collect();
let selected = prev
.and_then(|name| repos.iter().position(|r| r.name == name))
.or(if repos.is_empty() { None } else { Some(0) });
self.repos = ReposView {
repos,
selected,
error: None,
};
}
Err(e) => self.repos.error = Some(e.to_string()),
}
}
pub fn select_repo(&mut self, idx: usize) {
if idx < self.repos.repos.len() {
self.repos.selected = Some(idx);
}
}
pub fn fetch_artifact(&mut self, repository: &str, id: ArtifactId) {
let res = self.block(self.holger.fetch(repository, &id));
let mut view = ArtifactView {
repository: repository.to_string(),
namespace: id.namespace,
name: id.name,
version: id.version,
..Default::default()
};
match res {
Ok(Some(bytes)) => {
view.size_bytes = bytes.len() as u64;
view.content_type = "application/octet-stream".into();
view.found = true;
view.data = bytes;
}
Ok(None) => view.found = false,
Err(e) => view.error = Some(e.to_string()),
}
self.artifact = view;
}
const BROWSE_PAGE_SIZE: u32 = 100;
pub fn refresh_browse(&mut self, repository: &str, name_filter: Option<String>) {
let res = self.block(self.holger.list_artifacts(
repository,
name_filter,
Self::BROWSE_PAGE_SIZE,
None,
));
let mut view = BrowseView {
repository: repository.to_string(),
..Default::default()
};
match res {
Ok((entries, next_page_token)) => {
view.entries = entries
.into_iter()
.map(|e| BrowseRow {
namespace: e.id.namespace,
name: e.id.name,
version: e.id.version,
size_bytes: e.size_bytes,
content_type: e.content_type,
})
.collect();
view.next_page_token = next_page_token;
}
Err(e) => view.error = Some(e.to_string()),
}
self.browse = view;
}
pub fn load_more_browse(&mut self) {
if self.browse.next_page_token.is_empty() {
return;
}
let repository = self.browse.repository.clone();
let token = self.browse.next_page_token.clone();
let res = self.block(self.holger.list_artifacts(
&repository,
None,
Self::BROWSE_PAGE_SIZE,
Some(token),
));
match res {
Ok((entries, next_page_token)) => {
self.browse
.entries
.extend(entries.into_iter().map(|e| BrowseRow {
namespace: e.id.namespace,
name: e.id.name,
version: e.id.version,
size_bytes: e.size_bytes,
content_type: e.content_type,
}));
self.browse.next_page_token = next_page_token;
self.browse.error = None;
}
Err(e) => self.browse.error = Some(e.to_string()),
}
}
pub fn refresh_archive(&mut self, repository: &str, prefix: Option<String>) {
let mut view = ArchiveView {
repository: repository.to_string(),
..Default::default()
};
match self.block(self.holger.archive_info(repository)) {
Ok(info) => {
view.file_count = info.file_count;
view.total_uncompressed_bytes = info.total_uncompressed_bytes;
view.archive_path = info.archive_path;
}
Err(e) => view.error = Some(e.to_string()),
}
if view.error.is_none() {
match self.block(self.holger.list_archive_files(repository, prefix)) {
Ok(files) => view.files = files,
Err(e) => view.error = Some(e.to_string()),
}
}
self.archive = view;
}
pub fn put_artifact(&self, repository: &str, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
self.block(self.holger.put(repository, id, data))
}
pub fn state_json(&self) -> Value {
json!({
"status": self.status.state_json(),
"repos": self.repos.state_json(),
"artifact": self.artifact.state_json(),
"browse": self.browse.state_json(),
"archive": self.archive.state_json(),
})
}
}
#[cfg(feature = "gui")]
impl UiData {
pub fn connect_remote(endpoint: &str) -> anyhow::Result<Self> {
Self::connect_remote_with_token(endpoint, None)
}
pub fn connect_remote_with_token(endpoint: &str, token: Option<&str>) -> anyhow::Result<Self> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let holger: Arc<dyn HolgerObject> = match token {
Some(t) => Arc::new(
rt.block_on(server_lib::RemoteHolger::connect_with_token(endpoint.to_string(), t))?,
),
None => {
Arc::new(rt.block_on(server_lib::RemoteHolger::connect(endpoint.to_string()))?)
}
};
Ok(Self::with_runtime(holger, rt))
}
pub fn connect_remote_with_tls(
endpoint: &str,
ca: Option<Vec<u8>>,
client_identity: Option<(Vec<u8>, Vec<u8>)>,
token: Option<&str>,
) -> anyhow::Result<Self> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let holger: Arc<dyn HolgerObject> = Arc::new(rt.block_on(
server_lib::RemoteHolger::connect_with_tls(
endpoint.to_string(),
ca,
client_identity,
token.map(|t| t.to_string()),
),
)?);
Ok(Self::with_runtime(holger, rt))
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Default)]
struct FakeHolger {
repos: Vec<RepositoryInfo>,
artifacts: HashMap<(String, String), Vec<u8>>, listings: HashMap<String, Vec<traits::ArtifactEntry>>,
puts: Mutex<Vec<(String, String, Vec<u8>)>>,
fail: bool,
}
impl FakeHolger {
fn key(repo: &str, id: &ArtifactId) -> (String, String) {
(repo.to_string(), id.name.clone())
}
}
#[async_trait]
impl HolgerObject for FakeHolger {
async fn fetch(&self, repository: &str, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
if self.fail {
anyhow::bail!("boom");
}
Ok(self.artifacts.get(&Self::key(repository, id)).cloned())
}
async fn put(&self, repository: &str, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
if self.fail {
anyhow::bail!("boom");
}
let writable = self
.repos
.iter()
.find(|r| r.name == repository)
.map(|r| r.writable)
.unwrap_or(false);
if !writable {
anyhow::bail!("Repository is read-only");
}
self.puts
.lock()
.unwrap()
.push((repository.to_string(), id.name.clone(), data.to_vec()));
Ok(())
}
async fn list_repositories(&self) -> anyhow::Result<Vec<RepositoryInfo>> {
if self.fail {
anyhow::bail!("boom");
}
Ok(self.repos.clone())
}
async fn list_artifacts(
&self,
repository: &str,
name_filter: Option<String>,
_limit: u32,
_page_token: Option<String>,
) -> anyhow::Result<(Vec<traits::ArtifactEntry>, String)> {
if self.fail {
anyhow::bail!("boom");
}
let entries = self
.listings
.get(repository)
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|e| match &name_filter {
Some(f) => e.id.name.contains(f.as_str()),
None => true,
})
.collect();
Ok((entries, String::new()))
}
async fn list_archive_files(
&self,
repository: &str,
prefix: Option<String>,
) -> anyhow::Result<Vec<String>> {
if self.fail {
anyhow::bail!("boom");
}
let files: Vec<String> = if repository == "rust-arch" {
vec![
"crates/serde-1.0.0.crate".to_string(),
"crates/tokio-1.2.0.crate".to_string(),
"index/config.json".to_string(),
]
} else {
Vec::new()
};
Ok(match prefix {
Some(p) => files.into_iter().filter(|f| f.starts_with(&p)).collect(),
None => files,
})
}
async fn archive_info(&self, repository: &str) -> anyhow::Result<traits::ArchiveInfo> {
if self.fail {
anyhow::bail!("boom");
}
if repository == "rust-arch" {
Ok(traits::ArchiveInfo {
file_count: 3,
total_uncompressed_bytes: 600,
archive_path: "rust-arch".into(),
})
} else {
Ok(traits::ArchiveInfo::default())
}
}
async fn health(&self) -> anyhow::Result<Health> {
if self.fail {
anyhow::bail!("boom");
}
Ok(Health {
status: "ok".into(),
version: "9.9.9".into(),
uptime_seconds: 42,
})
}
}
fn repo(name: &str, writable: bool) -> RepositoryInfo {
RepositoryInfo {
name: name.into(),
repo_type: "Rust".into(),
writable,
has_archive: true,
}
}
fn ui(fake: FakeHolger) -> UiData {
UiData::new(Arc::new(fake)).expect("runtime")
}
fn entry(name: &str, version: &str, size: i64) -> traits::ArtifactEntry {
traits::ArtifactEntry {
id: ArtifactId {
namespace: None,
name: name.into(),
version: version.into(),
},
size_bytes: size,
content_type: "application/octet-stream".into(),
}
}
#[test]
fn status_ok_populates_view_and_state_json() {
let mut d = ui(FakeHolger::default());
d.refresh_status();
assert!(d.status.loaded);
assert_eq!(d.status.version, "9.9.9");
assert_eq!(d.status.uptime_seconds, 42);
assert!(d.status.error.is_none());
let s = d.status.state_json();
assert_eq!(s["loaded"], json!(true));
assert_eq!(s["version"], json!("9.9.9"));
}
#[test]
fn status_error_is_surfaced_not_loaded() {
let mut d = ui(FakeHolger {
fail: true,
..Default::default()
});
d.refresh_status();
assert!(!d.status.loaded);
assert_eq!(d.status.error.as_deref(), Some("boom"));
}
#[test]
fn repos_load_selects_first_and_preserves_selection_by_name() {
let mut d = ui(FakeHolger {
repos: vec![repo("rust-prod", false), repo("rust-dev", true)],
..Default::default()
});
d.refresh_repos();
assert_eq!(d.repos.repos.len(), 2);
assert_eq!(d.repos.selected, Some(0));
assert_eq!(d.repos.selected_repo().unwrap().name, "rust-prod");
d.select_repo(1);
d.refresh_repos();
assert_eq!(d.repos.selected_repo().unwrap().name, "rust-dev");
}
#[test]
fn upload_enabled_tracks_selected_repo_writable() {
let mut d = ui(FakeHolger {
repos: vec![repo("rust-prod", false), repo("rust-dev", true)],
..Default::default()
});
d.refresh_repos();
assert!(!d.repos.upload_enabled());
d.select_repo(1); assert!(d.repos.upload_enabled());
}
#[test]
fn fetch_found_sets_size_and_does_not_leak_bytes_into_state_json() {
let mut arts = HashMap::new();
arts.insert(("rust-prod".to_string(), "serde".to_string()), vec![1u8; 100]);
let mut d = ui(FakeHolger {
artifacts: arts,
..Default::default()
});
let id = ArtifactId {
namespace: None,
name: "serde".into(),
version: "1.0.0".into(),
};
d.fetch_artifact("rust-prod", id);
assert!(d.artifact.found);
assert_eq!(d.artifact.size_bytes, 100);
assert_eq!(d.artifact.data.len(), 100);
let s = d.artifact.state_json();
assert_eq!(s["size_bytes"], json!(100));
assert_eq!(s["found"], json!(true));
assert!(s.get("data").is_none(), "raw bytes must not appear in state_json");
}
#[test]
fn fetch_missing_is_not_found_not_error() {
let mut d = ui(FakeHolger::default());
let id = ArtifactId {
namespace: None,
name: "nope".into(),
version: "0.0.0".into(),
};
d.fetch_artifact("rust-prod", id);
assert!(!d.artifact.found);
assert!(d.artifact.error.is_none());
}
#[test]
fn fetch_transport_error_is_surfaced() {
let mut d = ui(FakeHolger {
fail: true,
..Default::default()
});
let id = ArtifactId {
namespace: None,
name: "serde".into(),
version: "1.0.0".into(),
};
d.fetch_artifact("rust-prod", id);
assert!(!d.artifact.found);
assert_eq!(d.artifact.error.as_deref(), Some("boom"));
}
#[test]
fn put_rejected_on_read_only_repo() {
let d = ui(FakeHolger {
repos: vec![repo("rust-prod", false)],
..Default::default()
});
let id = ArtifactId {
namespace: None,
name: "mycrate".into(),
version: "0.1.0".into(),
};
let err = d.put_artifact("rust-prod", &id, b"bytes").unwrap_err();
assert!(err.to_string().contains("read-only"));
}
#[test]
fn put_succeeds_on_writable_repo() {
let d = ui(FakeHolger {
repos: vec![repo("rust-dev", true)],
..Default::default()
});
let id = ArtifactId {
namespace: None,
name: "mycrate".into(),
version: "0.1.0".into(),
};
d.put_artifact("rust-dev", &id, b"bytes").unwrap();
}
#[test]
fn combined_state_json_has_all_three_views() {
let d = ui(FakeHolger::default());
let s = d.state_json();
assert!(s.get("status").is_some());
assert!(s.get("repos").is_some());
assert!(s.get("artifact").is_some());
assert!(s.get("browse").is_some());
assert!(s.get("archive").is_some());
}
#[test]
fn archive_lists_files_and_stats() {
let mut d = ui(FakeHolger::default());
d.refresh_archive("rust-arch", None);
assert!(d.archive.error.is_none(), "archive error: {:?}", d.archive.error);
assert_eq!(d.archive.repository, "rust-arch");
assert_eq!(d.archive.file_count, 3);
assert_eq!(d.archive.total_uncompressed_bytes, 600);
assert_eq!(d.archive.archive_path, "rust-arch");
assert_eq!(d.archive.files.len(), 3);
assert_eq!(d.archive.files[0], "crates/serde-1.0.0.crate");
d.refresh_archive("rust-arch", Some("index/".into()));
assert_eq!(d.archive.files, vec!["index/config.json".to_string()]);
assert_eq!(d.archive.file_count, 3);
let s = d.archive.state_json();
assert_eq!(s["repository"], json!("rust-arch"));
assert_eq!(s["file_count"], json!(3));
d.refresh_archive("does-not-exist", None);
assert!(d.archive.error.is_none());
assert!(d.archive.files.is_empty());
assert_eq!(d.archive.file_count, 0);
}
#[test]
fn archive_transport_error_is_surfaced() {
let mut d = ui(FakeHolger {
fail: true,
..Default::default()
});
d.refresh_archive("rust-arch", None);
assert_eq!(d.archive.error.as_deref(), Some("boom"));
assert!(d.archive.files.is_empty());
}
#[test]
fn browse_lists_artifacts_for_repo() {
let mut listings = HashMap::new();
listings.insert(
"rust-prod".to_string(),
vec![entry("serde", "1.0.0", 100), entry("tokio", "1.2.0", 200)],
);
let mut d = ui(FakeHolger {
listings,
..Default::default()
});
d.refresh_browse("rust-prod", None);
assert!(d.browse.error.is_none());
assert_eq!(d.browse.repository, "rust-prod");
assert_eq!(d.browse.entries.len(), 2);
assert_eq!(d.browse.entries[0].name, "serde");
assert_eq!(d.browse.entries[0].size_bytes, 100);
assert_eq!(d.browse.entries[1].name, "tokio");
d.refresh_browse("rust-prod", Some("ser".into()));
assert_eq!(d.browse.entries.len(), 1);
assert_eq!(d.browse.entries[0].name, "serde");
let s = d.browse.state_json();
assert_eq!(s["repository"], json!("rust-prod"));
assert_eq!(s["entries"][0]["name"], json!("serde"));
}
#[test]
fn browse_unknown_repo_is_empty_not_error() {
let mut d = ui(FakeHolger::default());
d.refresh_browse("does-not-exist", None);
assert!(d.browse.error.is_none());
assert!(d.browse.entries.is_empty());
assert_eq!(d.browse.next_page_token, "");
}
}