use quilt_rs::lineage::UpstreamState;
use quilt_uri::Namespace;
use tabled::settings::Modify;
use tabled::settings::Span;
use crate::cli::Error;
use crate::cli::model::Commands;
use crate::cli::output::Std;
const NO_BUCKET: &str = "∅";
pub struct PackageEntry {
pub bucket: Option<String>,
pub namespace: Namespace,
pub status: UpstreamState,
}
pub struct Output {
installed_packages_list: Vec<PackageEntry>,
}
impl Output {
fn new(mut installed_packages_list: Vec<PackageEntry>) -> Self {
installed_packages_list.sort_by(|a, b| {
(a.bucket.is_none(), &a.bucket, &a.namespace).cmp(&(
b.bucket.is_none(),
&b.bucket,
&b.namespace,
))
});
Self {
installed_packages_list,
}
}
fn to_json(&self) -> String {
let packages: Vec<_> = self
.installed_packages_list
.iter()
.map(|package| {
serde_json::json!({
"bucket": package.bucket.as_deref(),
"namespace": package.namespace.to_string(),
"status": package.status,
})
})
.collect();
serde_json::json!({ "packages": packages }).to_string()
}
}
#[derive(tabled::Tabled)]
struct PackageRow {
bucket: String,
namespace: String,
status: String,
}
impl From<&PackageEntry> for PackageRow {
fn from(entry: &PackageEntry) -> Self {
Self {
bucket: entry.bucket.as_deref().unwrap_or(NO_BUCKET).to_string(),
namespace: entry.namespace.to_string(),
status: entry.status.to_string(),
}
}
}
impl std::fmt::Display for Output {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.installed_packages_list.is_empty() {
return write!(f, "No installed packages");
}
let mut table =
tabled::Table::new(self.installed_packages_list.iter().map(PackageRow::from));
let mut row = 1; for group in self
.installed_packages_list
.chunk_by(|a, b| a.bucket == b.bucket)
{
if group.len() > 1 {
table.with(Modify::new((row, 0)).with(Span::row(group.len().cast_signed())));
}
row += group.len();
}
write!(f, "{table}")
}
}
pub async fn command(m: impl Commands, json: bool) -> Std {
match m.list().await {
Ok(output) => Std::Out(if json {
output.to_json()
} else {
output.to_string()
}),
Err(error) => Std::Err(error),
}
}
pub async fn model(local_domain: &quilt_rs::LocalDomain) -> Result<Output, Error> {
let domain_lineage = local_domain.get_lineage().await?;
let mut installed_packages_list = Vec::with_capacity(domain_lineage.packages.len());
for (namespace, lineage) in domain_lineage.packages {
let bucket = lineage
.remote_uri
.as_ref()
.map(|remote| remote.bucket.clone())
.filter(|bucket| !bucket.is_empty());
installed_packages_list.push(PackageEntry {
status: UpstreamState::from(lineage),
namespace,
bucket,
});
}
Ok(Output::new(installed_packages_list))
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
use crate::cli::fixtures::packages::default as pkg;
use crate::cli::model::Commands;
use crate::cli::model::create_model_in_temp_dir;
use crate::cli::model::install_package_into_temp_dir;
fn entry(bucket: Option<&str>, name: &str, status: UpstreamState) -> PackageEntry {
PackageEntry {
bucket: bucket.map(str::to_owned),
namespace: ("example", name).into(),
status,
}
}
#[test(tokio::test)]
async fn test_empty_list() -> Result<(), Error> {
let (m, _temp_dir) = create_model_in_temp_dir().await?;
{
let local_domain = m.get_local_domain();
let empty_output = model(local_domain).await?;
assert!(empty_output.installed_packages_list.is_empty());
assert_eq!(format!("{empty_output}"), "No installed packages");
}
Ok(())
}
#[test]
fn test_json_empty_list() {
let output = Output::new(Vec::new());
assert_eq!(output.to_json(), r#"{"packages":[]}"#);
}
#[test]
fn test_json_includes_bucket_namespace_and_status() {
let output = Output::new(vec![
entry(Some("acme-research"), "one", UpstreamState::UpToDate),
entry(None, "scratch", UpstreamState::Local),
]);
assert_eq!(
output.to_json(),
r#"{"packages":[{"bucket":"acme-research","namespace":"example/one","status":"up_to_date"},{"bucket":null,"namespace":"example/scratch","status":"local"}]}"#
);
}
#[test]
fn test_display_groups_interleaved_buckets() {
let output = Output::new(vec![
entry(Some("zulu-bucket"), "late", UpstreamState::Behind),
entry(Some("acme-research"), "two", UpstreamState::UpToDate),
entry(None, "scratch", UpstreamState::Local),
entry(Some("acme-research"), "one", UpstreamState::UpToDate),
entry(Some("zulu-bucket"), "early", UpstreamState::Ahead),
]);
let rendered = format!("{output}");
assert_eq!(rendered.matches("acme-research").count(), 1, "one span");
assert_eq!(rendered.matches("zulu-bucket").count(), 1, "one span");
let positions = |name: &str| rendered.find(name).expect("{name} is listed");
assert!(positions("acme-research") < positions("zulu-bucket"));
assert!(positions("example/one") < positions("example/two"));
assert!(positions("example/early") < positions("example/late"));
assert!(positions("zulu-bucket") < positions("example/scratch"));
}
#[test]
fn test_display_spans_repeated_buckets() {
let output = Output::new(vec![
entry(Some("acme-research"), "one", UpstreamState::UpToDate),
entry(Some("acme-research"), "two", UpstreamState::UpToDate),
entry(Some("zulu-bucket"), "late", UpstreamState::Behind),
entry(None, "scratch", UpstreamState::Local),
]);
let rendered = format!("{output}");
assert_eq!(rendered.matches("acme-research").count(), 1, "named once");
assert_eq!(rendered.matches("zulu-bucket").count(), 1);
assert_eq!(rendered.matches(NO_BUCKET).count(), 1);
for name in [
"example/one",
"example/two",
"example/late",
"example/scratch",
] {
assert!(rendered.contains(name), "{name} is listed");
}
assert_eq!(rendered.matches("status").count(), 1);
assert_eq!(rendered.matches("up_to_date").count(), 2);
}
#[test(tokio::test)]
async fn test_model_with_local_package() -> Result<(), Error> {
let (m, _temp_dir) = create_model_in_temp_dir().await?;
m.create(crate::cli::create::Input {
namespace: ("example", "local").into(),
source: None,
message: None,
})
.await?;
let output = model(m.get_local_domain()).await?;
let entry = &output.installed_packages_list[0];
assert_eq!(entry.bucket, None);
assert_eq!(entry.namespace, ("example", "local").into());
assert_eq!(entry.status, UpstreamState::Local);
let output = format!("{output}");
assert!(output.contains("example/local"));
assert!(output.contains(NO_BUCKET));
Ok(())
}
#[test(tokio::test)]
async fn live_model() -> Result<(), Error> {
let uri = format!("{}&path={}", pkg::URI_LATEST, pkg::README_LK_ESCAPED);
let (m, _, _temp_dir) = install_package_into_temp_dir(&uri).await?;
{
let local_domain = m.get_local_domain();
let output = model(local_domain).await?;
let entry = &output.installed_packages_list[0];
assert_eq!(entry.bucket.as_deref(), Some(pkg::BUCKET));
assert_eq!(entry.namespace, pkg::NAMESPACE.into());
assert_eq!(entry.status, UpstreamState::UpToDate);
let output = format!("{output}");
assert!(output.contains("bucket"));
assert!(output.contains("namespace"));
assert!(output.contains("status"));
assert!(output.contains(pkg::BUCKET));
assert!(output.contains("up_to_date"));
}
Ok(())
}
#[test(tokio::test)]
async fn live_command_with_package() -> Result<(), Error> {
let uri = format!("{}&path={}", pkg::URI_LATEST, pkg::README_LK_ESCAPED);
let (m, _, _temp_dir) = install_package_into_temp_dir(&uri).await?;
if let Std::Out(output) = command(m, false).await {
assert!(output.contains(pkg::BUCKET));
assert!(output.contains(pkg::NAMESPACE_STR));
assert!(output.contains("up_to_date"));
} else {
return Err(Error::Test("Failed to list packages".to_string()));
}
Ok(())
}
}