use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::Serialize;
use crate::error::{Result, RototoError};
use crate::lint::{ModelEntityRef, ModelReferenceVia, PackageSemanticModel};
use super::github::package_repo_path;
use super::store::PackageRecord;
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PackageInventory {
pub variables: Vec<VariableInventoryItem>,
pub qualifiers: Vec<QualifierInventoryItem>,
pub catalogs: Vec<CatalogInventoryItem>,
pub catalog_entries: Vec<CatalogEntryInventoryItem>,
pub linters: Vec<LinterInventoryItem>,
pub context: ContextInventory,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VariableInventoryItem {
pub id: String,
pub path: String,
pub description: Option<String>,
pub declaration: String,
pub default_value: Option<String>,
pub rule_count: usize,
pub qualifier_references: Vec<String>,
pub rule_values: Vec<String>,
pub catalog_reference: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QualifierInventoryItem {
pub id: String,
pub path: String,
pub description: Option<String>,
pub qualifier_references: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogInventoryItem {
pub id: String,
pub path: String,
pub description: Option<String>,
pub schema: Option<String>,
pub entry_count: usize,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogEntryInventoryItem {
pub catalog_id: String,
pub key: String,
pub id: String,
pub path: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LinterInventoryItem {
pub id: String,
pub title: Option<String>,
pub path: Option<String>,
pub kind: &'static str,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ContextInventory {
pub evaluation_contexts: Vec<EvaluationContextInventoryItem>,
pub samples: Vec<EvaluationContextSampleInventoryItem>,
pub example_count: usize,
pub examples: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EvaluationContextInventoryItem {
pub id: String,
pub path: String,
pub title: Option<String>,
pub description: Option<String>,
pub entry_count: usize,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EvaluationContextSampleInventoryItem {
pub evaluation_context_id: String,
pub key: String,
pub id: String,
pub path: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PackageDefinition {
pub path: String,
pub text: String,
pub language: &'static str,
}
pub async fn inspect_package_inventory(
package: &PackageRecord,
model: &PackageSemanticModel,
_staged_root: &Path,
) -> Result<PackageInventory> {
let context = inspect_context(package, model);
Ok(inventory_from_model(package, model, context))
}
pub async fn read_package_definition(
package: &PackageRecord,
staged_root: &Path,
path: &str,
) -> Result<PackageDefinition> {
let local_path = package_local_path(package, path)?;
let text = tokio::fs::read_to_string(staged_root.join(&local_path))
.await
.map_err(|err| RototoError::new(format!("failed to read {path}: {err}")))?;
Ok(PackageDefinition {
path: path.to_owned(),
text,
language: language_for_path(path),
})
}
fn inventory_from_model(
package: &PackageRecord,
model: &PackageSemanticModel,
context: ContextInventory,
) -> PackageInventory {
let repo_path = |path: &str| package_repo_path(&package.path, path);
let variables = model
.variables
.iter()
.map(|variable| {
let rules: &[crate::lint::RuleModel] = variable
.resolve
.as_ref()
.map(|resolve| resolve.rules.as_slice())
.unwrap_or_default();
let qualifier_references =
distinct_sorted(model.references.iter().filter_map(|reference| {
match (&reference.from, &reference.to, &reference.via) {
(
ModelEntityRef::Variable { id: variable_id },
ModelEntityRef::Qualifier { id: qualifier_id },
ModelReferenceVia::RuleCondition { .. },
) if variable_id == &variable.id => Some(qualifier_id.clone()),
_ => None,
}
}));
VariableInventoryItem {
id: variable.id.clone(),
path: repo_path(&variable.location.path),
description: variable.description.clone(),
declaration: declaration_label(&variable.declaration),
default_value: (variable.declaration.kind == "catalog")
.then(|| {
variable
.resolve
.as_ref()
.and_then(|resolve| resolve.default.as_ref())
.and_then(|default| default.value.as_ref())
.and_then(|value| value.as_str())
.map(str::to_owned)
})
.flatten(),
rule_count: rules.len(),
qualifier_references,
rule_values: if variable.declaration.kind == "catalog" {
distinct_sorted(
rules
.iter()
.filter_map(|rule| rule.value.as_ref())
.filter_map(|value| value.value.as_ref())
.filter_map(|value| value.as_str())
.map(str::to_owned),
)
} else {
Vec::new()
},
catalog_reference: (variable.declaration.kind == "catalog")
.then(|| variable.declaration.value.clone())
.flatten(),
}
})
.collect();
let mut qualifier_edges: BTreeMap<&str, Vec<String>> = BTreeMap::new();
for reference in &model.references {
if let (
crate::lint::ModelEntityRef::Qualifier { id: from },
crate::lint::ModelEntityRef::Qualifier { id: to },
) = (&reference.from, &reference.to)
{
qualifier_edges.entry(from).or_default().push(to.clone());
}
}
let qualifiers = model
.qualifiers
.iter()
.map(|qualifier| QualifierInventoryItem {
id: qualifier.id.clone(),
path: repo_path(&qualifier.location.path),
description: qualifier.description.clone(),
qualifier_references: distinct_sorted(
qualifier_edges
.get(qualifier.id.as_str())
.cloned()
.unwrap_or_default()
.into_iter(),
),
})
.collect();
let mut entry_counts: BTreeMap<&str, usize> = BTreeMap::new();
for entry in &model.catalog_entries {
*entry_counts.entry(entry.catalog.as_str()).or_default() += 1;
}
let catalogs = model
.catalogs
.iter()
.map(|catalog| CatalogInventoryItem {
id: catalog.id.clone(),
path: repo_path(&catalog.location.path),
description: catalog.description.clone(),
schema: Some(catalog.path.clone()),
entry_count: entry_counts
.get(catalog.id.as_str())
.copied()
.unwrap_or_default(),
})
.collect();
let catalog_entries = model
.catalog_entries
.iter()
.map(|entry| CatalogEntryInventoryItem {
catalog_id: entry.catalog.clone(),
key: entry.key.clone(),
id: format!("{}/{}", entry.catalog, entry.key),
path: repo_path(&entry.location.path),
})
.collect();
let linters = model
.linters
.iter()
.map(|linter| {
let file_name = linter.path.rsplit('/').next().unwrap_or(&linter.path);
let titles: BTreeSet<&str> = linter
.rules
.iter()
.map(|rule| rule.title.as_str())
.collect();
LinterInventoryItem {
id: file_name
.rsplit_once('.')
.map(|(stem, _)| stem.to_owned())
.unwrap_or_else(|| file_name.to_owned()),
title: (!titles.is_empty())
.then(|| titles.into_iter().collect::<Vec<_>>().join(" · ")),
path: Some(repo_path(&linter.path)),
kind: "script",
}
})
.collect();
PackageInventory {
variables,
qualifiers,
catalogs,
catalog_entries,
linters,
context,
}
}
fn declaration_label(declaration: &crate::lint::DeclarationModel) -> String {
match declaration.kind.as_str() {
"primitive" => declaration
.value
.clone()
.unwrap_or_else(|| "undeclared".to_owned()),
"catalog" => format!("catalog:{}", declaration.value.as_deref().unwrap_or("?")),
"schema" => format!("schema:{}", declaration.value.as_deref().unwrap_or("?")),
"missing" => "undeclared".to_owned(),
other => other.to_owned(),
}
}
fn inspect_context(package: &PackageRecord, model: &PackageSemanticModel) -> ContextInventory {
let repo_path = |path: &str| package_repo_path(&package.path, path);
let mut entry_counts: BTreeMap<&str, usize> = BTreeMap::new();
for entry in &model.evaluation_context_samples {
*entry_counts
.entry(entry.evaluation_context.as_str())
.or_default() += 1;
}
let evaluation_contexts = model
.evaluation_contexts
.iter()
.map(|context| EvaluationContextInventoryItem {
id: context.id.clone(),
path: repo_path(&context.path),
title: context.title.clone(),
description: context.description.clone(),
entry_count: entry_counts
.get(context.id.as_str())
.copied()
.unwrap_or_default(),
})
.collect();
let samples = model
.evaluation_context_samples
.iter()
.map(|entry| EvaluationContextSampleInventoryItem {
evaluation_context_id: entry.evaluation_context.clone(),
key: entry.key.clone(),
id: format!("{}/{}", entry.evaluation_context, entry.key),
path: repo_path(&entry.path),
})
.collect::<Vec<_>>();
let mut examples = samples
.iter()
.map(|entry| entry.path.clone())
.collect::<Vec<_>>();
examples.sort();
ContextInventory {
evaluation_contexts,
example_count: samples.len(),
examples,
samples,
}
}
pub fn package_local_path(package: &PackageRecord, path: &str) -> Result<String> {
if path.starts_with('/') || path.split('/').any(|segment| segment == "..") {
return Err(RototoError::new(
"package definition path must stay inside the package",
));
}
if package.path == "." {
return Ok(path.to_owned());
}
let prefix = format!("{}/", package.path);
path.strip_prefix(&prefix)
.map(str::to_owned)
.ok_or_else(|| RototoError::new("package definition path does not belong to this package"))
}
pub fn language_for_path(path: &str) -> &'static str {
if path.ends_with(".toml") {
"toml"
} else if path.ends_with(".json") {
"json"
} else if path.ends_with(".lua") {
"lua"
} else {
"text"
}
}
fn distinct_sorted(values: impl Iterator<Item = String>) -> Vec<String> {
let set: BTreeSet<String> = values.collect();
set.into_iter().collect()
}