use std::collections::{BTreeMap, BTreeSet};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use zip::ZipArchive;
use crate::error::AppError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveKind {
ProjectExport,
GatewayBackup,
Unknown,
}
impl ArchiveKind {
pub fn as_str(self) -> &'static str {
match self {
Self::ProjectExport => "project_export",
Self::GatewayBackup => "gateway_backup",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectSelection {
Single { root: String },
Multiple { roots: Vec<String> },
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveInspection {
pub archive_kind: ArchiveKind,
pub project_selection: ProjectSelection,
pub detected_project_roots: Vec<String>,
pub selected_project_roots: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ProjectMetadata {
pub project_root: String,
pub title: String,
pub description: Option<String>,
pub parent: Option<String>,
pub enabled: bool,
pub inheritable: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Resource {
pub section: String,
pub type_key: String,
pub path: String,
pub resource_json_path: String,
pub binary_only: bool,
pub attributes: BTreeMap<String, Value>,
pub files: Vec<ResourceFile>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceFile {
pub file_kind: String,
pub file_zip_path: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProjectResourceInventory {
pub project_root: String,
pub resources: Vec<Resource>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ProjectCounts {
pub resources_total: usize,
pub files_total: usize,
pub binary_only_resources: usize,
pub resources_by_section: BTreeMap<String, usize>,
pub resources_by_type: BTreeMap<String, usize>,
pub files_by_kind: BTreeMap<String, usize>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct CoverageMetrics {
pub unknown_resources: usize,
pub unknown_ratio: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Classification {
section: &'static str,
type_key: &'static str,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct AnalyticsBundle {
pub schema_version: String,
pub generated_at: String,
pub input: AnalyticsInput,
pub summary: AnalyticsSummary,
pub projects: Vec<ProjectAnalytics>,
pub issues: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gateway_meta: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AnalyticsInput {
pub archive_path: String,
pub archive_kind: String,
pub detected_project_roots: Vec<String>,
pub selected_project_roots: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ProjectAnalytics {
pub project_root: String,
pub project: ProjectMetadata,
pub counts: ProjectCounts,
pub coverage: CoverageMetrics,
pub issues: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct AnalyticsSummary {
pub projects_total: usize,
pub resources_total: usize,
pub files_total: usize,
pub binary_only_resources: usize,
pub resources_by_section: BTreeMap<String, usize>,
pub resources_by_type: BTreeMap<String, usize>,
pub files_by_kind: BTreeMap<String, usize>,
pub unknown_resources: usize,
pub unknown_ratio: f64,
}
#[derive(Debug, Deserialize)]
struct RawProjectFile {
title: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
parent: Option<String>,
#[serde(default = "default_enabled")]
enabled: bool,
#[serde(default)]
inheritable: bool,
}
const fn default_enabled() -> bool {
true
}
const SECTION_PERSPECTIVE: &str = "Perspective";
const SECTION_SCRIPTING: &str = "Scripting";
const SECTION_NAMED_QUERIES: &str = "Named Queries";
const SECTION_SFC: &str = "Sequential Function Charts (SFC)";
const SECTION_EVENT_STREAMS: &str = "Event Streams";
const SECTION_REPORTS: &str = "Reports";
const SECTION_ALARM_PIPELINES: &str = "Alarm Notification Pipelines";
const SECTION_PROPERTIES: &str = "Properties";
const SECTION_OTHER: &str = "Other";
const TYPE_PERSPECTIVE_VIEW: &str = "perspective.view";
const TYPE_PERSPECTIVE_PAGE_CONFIG: &str = "perspective.page_config";
const TYPE_PERSPECTIVE_STYLE_CLASS: &str = "perspective.style_class";
const TYPE_PERSPECTIVE_STYLESHEET: &str = "perspective.stylesheet";
const TYPE_PERSPECTIVE_MESSAGE_HANDLER: &str = "perspective.message_handler";
const TYPE_PERSPECTIVE_FORM_SUBMISSION_HANDLER: &str = "perspective.form_submission_handler";
const TYPE_PERSPECTIVE_KEY_EVENT: &str = "perspective.key_event";
const TYPE_PERSPECTIVE_STARTUP: &str = "perspective.startup";
const TYPE_PERSPECTIVE_SHUTDOWN: &str = "perspective.shutdown";
const TYPE_PERSPECTIVE_ACCELEROMETER: &str = "perspective.accelerometer";
const TYPE_PERSPECTIVE_BARCODE: &str = "perspective.barcode";
const TYPE_PERSPECTIVE_BLUETOOTH: &str = "perspective.bluetooth";
const TYPE_PERSPECTIVE_AUTH_CHALLENGE: &str = "perspective.auth_challenge";
const TYPE_PERSPECTIVE_NFC_SCAN: &str = "perspective.nfc_scan";
const TYPE_PERSPECTIVE_PAGE_STARTUP: &str = "perspective.page_startup";
const TYPE_PERSPECTIVE_SESSION_PROPS: &str = "perspective.session_props";
const TYPE_SCRIPT_PYTHON: &str = "script.python";
const TYPE_SCRIPT_GATEWAY_EVENT: &str = "script.gateway_event";
const TYPE_NAMED_QUERY: &str = "named_query";
const TYPE_SFC: &str = "sfc";
const TYPE_EVENT_STREAM: &str = "event_stream";
const TYPE_REPORT: &str = "report";
const TYPE_ALARM_PIPELINE: &str = "alarm_pipeline";
const TYPE_PROJECT_PROPERTIES: &str = "project_properties";
const TYPE_UNKNOWN: &str = "unknown";
pub fn inspect_archive(archive_path: &Path) -> Result<ArchiveInspection, AppError> {
let entries = list_archive_entries(archive_path)?;
inspect_entries(archive_path, &entries)
}
pub fn list_archive_entries(archive_path: &Path) -> Result<Vec<String>, AppError> {
let file = File::open(archive_path).map_err(|err| {
AppError::archive_read(archive_path, format!("could not open file: {err}"))
})?;
let mut archive = ZipArchive::new(file).map_err(|err| {
AppError::archive_read(archive_path, format!("not a valid zip archive: {err}"))
})?;
let mut entries = Vec::with_capacity(archive.len());
for index in 0..archive.len() {
let zip_entry = archive.by_index(index).map_err(|err| {
AppError::archive_read(
archive_path,
format!("could not read zip entry at index {index}: {err}"),
)
})?;
let normalized = normalize_zip_entry_name(zip_entry.name());
if !normalized.is_empty() {
entries.push(normalized);
}
}
entries.sort();
entries.dedup();
Ok(entries)
}
pub fn parse_project_metadata(
archive_path: &Path,
selected_project_roots: &[String],
) -> Result<Vec<ProjectMetadata>, AppError> {
let file = File::open(archive_path).map_err(|err| {
AppError::archive_read(archive_path, format!("could not open file: {err}"))
})?;
let mut archive = ZipArchive::new(file).map_err(|err| {
AppError::archive_read(archive_path, format!("not a valid zip archive: {err}"))
})?;
let mut projects = Vec::with_capacity(selected_project_roots.len());
for project_root in selected_project_roots {
let project_json_path = project_json_member_path(project_root);
let mut member = archive.by_name(&project_json_path).map_err(|err| {
AppError::archive_read(
archive_path,
format!("missing expected `{project_json_path}`: {err}"),
)
})?;
let mut bytes = Vec::new();
member.read_to_end(&mut bytes).map_err(|err| {
AppError::archive_read(
archive_path,
format!("could not read `{project_json_path}`: {err}"),
)
})?;
let project = parse_project_json_bytes(archive_path, &project_json_path, &bytes)?;
projects.push(project.with_root(project_root.clone()));
}
Ok(projects)
}
pub fn discover_resources_for_roots(
archive_path: &Path,
selected_project_roots: &[String],
) -> Result<Vec<ProjectResourceInventory>, AppError> {
let entries = list_archive_entries(archive_path)?;
let entry_set: BTreeSet<String> = entries.iter().cloned().collect();
let file = File::open(archive_path).map_err(|err| {
AppError::archive_read(archive_path, format!("could not open file: {err}"))
})?;
let mut archive = ZipArchive::new(file).map_err(|err| {
AppError::archive_read(archive_path, format!("not a valid zip archive: {err}"))
})?;
let mut inventories = Vec::with_capacity(selected_project_roots.len());
for project_root in selected_project_roots {
let resources = discover_resources_for_root_in_archive(
&mut archive,
archive_path,
project_root,
&entries,
&entry_set,
)?;
inventories.push(ProjectResourceInventory {
project_root: project_root.clone(),
resources,
});
}
Ok(inventories)
}
pub fn discover_resources_for_root(
archive_path: &Path,
project_root: &str,
) -> Result<Vec<Resource>, AppError> {
let inventories = discover_resources_for_roots(archive_path, &[project_root.to_string()])?;
Ok(inventories
.into_iter()
.next()
.map(|inventory| inventory.resources)
.unwrap_or_default())
}
pub fn compute_project_counts(resources: &[Resource]) -> ProjectCounts {
let mut resources_by_section = BTreeMap::new();
let mut resources_by_type = BTreeMap::new();
let mut files_by_kind = BTreeMap::new();
let mut files_total = 0usize;
let mut binary_only_resources = 0usize;
for resource in resources {
*resources_by_section
.entry(resource.section.clone())
.or_insert(0usize) += 1;
*resources_by_type
.entry(resource.type_key.clone())
.or_insert(0usize) += 1;
if resource.binary_only {
binary_only_resources += 1;
}
files_total += resource.files.len();
for file in &resource.files {
*files_by_kind
.entry(file.file_kind.clone())
.or_insert(0usize) += 1;
}
}
ProjectCounts {
resources_total: resources.len(),
files_total,
binary_only_resources,
resources_by_section,
resources_by_type,
files_by_kind,
}
}
pub fn compute_coverage(resources: &[Resource]) -> CoverageMetrics {
let unknown_resources = resources
.iter()
.filter(|resource| resource.type_key == TYPE_UNKNOWN)
.count();
let total = resources.len();
let unknown_ratio = if total == 0 {
0.0
} else {
unknown_resources as f64 / total as f64
};
CoverageMetrics {
unknown_resources,
unknown_ratio,
}
}
pub fn build_analytics_bundle(
archive_path: &Path,
generated_at: impl Into<String>,
inspection: &ArchiveInspection,
project_metadata: &[ProjectMetadata],
resource_inventories: &[ProjectResourceInventory],
) -> Result<AnalyticsBundle, AppError> {
let mut metadata_by_root = BTreeMap::new();
for meta in project_metadata {
if metadata_by_root
.insert(meta.project_root.clone(), meta.clone())
.is_some()
{
return Err(AppError::internal(format!(
"duplicate project metadata entry for root `{}`",
meta.project_root
)));
}
}
let mut resources_by_root = BTreeMap::new();
for inventory in resource_inventories {
if resources_by_root
.insert(inventory.project_root.clone(), inventory.resources.clone())
.is_some()
{
return Err(AppError::internal(format!(
"duplicate resource inventory entry for root `{}`",
inventory.project_root
)));
}
}
let mut sorted_selected_roots = inspection.selected_project_roots.clone();
sorted_selected_roots.sort();
let mut projects = Vec::with_capacity(sorted_selected_roots.len());
for project_root in &sorted_selected_roots {
let project = metadata_by_root.remove(project_root).ok_or_else(|| {
AppError::internal(format!(
"missing project metadata for root `{project_root}`"
))
})?;
let resources = resources_by_root.remove(project_root).ok_or_else(|| {
AppError::internal(format!(
"missing resource inventory for root `{project_root}`"
))
})?;
projects.push(ProjectAnalytics {
project_root: project_root.clone(),
counts: compute_project_counts(&resources),
coverage: compute_coverage(&resources),
project,
issues: Vec::new(),
});
}
if !metadata_by_root.is_empty() {
let roots = metadata_by_root
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ");
return Err(AppError::internal(format!(
"project metadata roots not selected by inspection: {roots}"
)));
}
if !resources_by_root.is_empty() {
let roots = resources_by_root
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ");
return Err(AppError::internal(format!(
"resource inventory roots not selected by inspection: {roots}"
)));
}
let summary = aggregate_summary(&projects);
Ok(AnalyticsBundle {
schema_version: "0.1.0".to_string(),
generated_at: generated_at.into(),
input: AnalyticsInput {
archive_path: archive_path.display().to_string(),
archive_kind: inspection.archive_kind.as_str().to_string(),
detected_project_roots: inspection.detected_project_roots.clone(),
selected_project_roots: sorted_selected_roots,
},
summary,
projects,
issues: Vec::new(),
gateway_meta: None,
})
}
pub fn aggregate_summary(projects: &[ProjectAnalytics]) -> AnalyticsSummary {
let mut resources_total = 0usize;
let mut files_total = 0usize;
let mut binary_only_resources = 0usize;
let mut unknown_resources = 0usize;
let mut resources_by_section = BTreeMap::new();
let mut resources_by_type = BTreeMap::new();
let mut files_by_kind = BTreeMap::new();
for project in projects {
resources_total += project.counts.resources_total;
files_total += project.counts.files_total;
binary_only_resources += project.counts.binary_only_resources;
unknown_resources += project.coverage.unknown_resources;
for (key, value) in &project.counts.resources_by_section {
*resources_by_section.entry(key.clone()).or_insert(0usize) += value;
}
for (key, value) in &project.counts.resources_by_type {
*resources_by_type.entry(key.clone()).or_insert(0usize) += value;
}
for (key, value) in &project.counts.files_by_kind {
*files_by_kind.entry(key.clone()).or_insert(0usize) += value;
}
}
let unknown_ratio = if resources_total == 0 {
0.0
} else {
unknown_resources as f64 / resources_total as f64
};
AnalyticsSummary {
projects_total: projects.len(),
resources_total,
files_total,
binary_only_resources,
resources_by_section,
resources_by_type,
files_by_kind,
unknown_resources,
unknown_ratio,
}
}
fn classify_resource_path(path: &str) -> Classification {
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/views") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_VIEW,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/page-config") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_PAGE_CONFIG,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/style-classes") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_STYLE_CLASS,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/stylesheet") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_STYLESHEET,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/message") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_MESSAGE_HANDLER,
};
}
if is_prefix_or_exact(
path,
"com.inductiveautomation.perspective/form-submission-handler",
) {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_FORM_SUBMISSION_HANDLER,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/key-event") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_KEY_EVENT,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/startup") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_STARTUP,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/shutdown") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_SHUTDOWN,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/accelerometer") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_ACCELEROMETER,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/barcode") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_BARCODE,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/bluetooth") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_BLUETOOTH,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/auth-challenge") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_AUTH_CHALLENGE,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/nfc-scan") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_NFC_SCAN,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/page-startup") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_PAGE_STARTUP,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.perspective/session-props") {
return Classification {
section: SECTION_PERSPECTIVE,
type_key: TYPE_PERSPECTIVE_SESSION_PROPS,
};
}
if is_prefix_or_exact(path, "ignition/script-python") {
return Classification {
section: SECTION_SCRIPTING,
type_key: TYPE_SCRIPT_PYTHON,
};
}
if is_prefix_or_exact(path, "ignition/startup")
|| is_prefix_or_exact(path, "ignition/shutdown")
|| is_prefix_or_exact(path, "ignition/update")
|| is_prefix_or_exact(path, "ignition/timer")
|| is_prefix_or_exact(path, "ignition/tag-change")
|| is_prefix_or_exact(path, "ignition/scheduled")
|| is_prefix_or_exact(path, "ignition/event-scripts")
{
return Classification {
section: SECTION_SCRIPTING,
type_key: TYPE_SCRIPT_GATEWAY_EVENT,
};
}
if is_prefix_or_exact(path, "ignition/named-query") {
return Classification {
section: SECTION_NAMED_QUERIES,
type_key: TYPE_NAMED_QUERY,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.sfc") {
return Classification {
section: SECTION_SFC,
type_key: TYPE_SFC,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.eventstream") {
return Classification {
section: SECTION_EVENT_STREAMS,
type_key: TYPE_EVENT_STREAM,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.reporting") {
return Classification {
section: SECTION_REPORTS,
type_key: TYPE_REPORT,
};
}
if is_prefix_or_exact(path, "com.inductiveautomation.alarm-notification") {
return Classification {
section: SECTION_ALARM_PIPELINES,
type_key: TYPE_ALARM_PIPELINE,
};
}
if is_prefix_or_exact(path, "ignition/global-props")
|| is_prefix_or_exact(path, "ignition/designer-properties")
{
return Classification {
section: SECTION_PROPERTIES,
type_key: TYPE_PROJECT_PROPERTIES,
};
}
Classification {
section: SECTION_OTHER,
type_key: TYPE_UNKNOWN,
}
}
fn discover_resources_for_root_in_archive(
archive: &mut ZipArchive<File>,
archive_path: &Path,
project_root: &str,
entries: &[String],
entry_set: &BTreeSet<String>,
) -> Result<Vec<Resource>, AppError> {
let mut resource_json_paths = Vec::new();
for entry in entries {
if is_resource_json_for_root(entry, project_root) {
resource_json_paths.push(entry.clone());
}
}
let mut resources = Vec::with_capacity(resource_json_paths.len());
for resource_json_path in resource_json_paths {
let mut member = archive.by_name(&resource_json_path).map_err(|err| {
AppError::archive_read(
archive_path,
format!("missing expected `{resource_json_path}`: {err}"),
)
})?;
let mut bytes = Vec::new();
member.read_to_end(&mut bytes).map_err(|err| {
AppError::archive_read(
archive_path,
format!("could not read `{resource_json_path}`: {err}"),
)
})?;
let resource = parse_resource(archive_path, &resource_json_path, &bytes)?;
let files = build_resource_files(
archive_path,
&resource_json_path,
&resource.files,
entry_set,
)?;
let resource_path = resource_path_for_project_root(&resource_json_path, project_root);
let classification = classify_resource_path(&resource_path);
resources.push(Resource {
section: classification.section.to_string(),
type_key: classification.type_key.to_string(),
path: resource_path,
resource_json_path,
binary_only: is_binary_only_resource(&files),
attributes: resource.attributes,
files,
});
}
resources.sort_by(|left, right| {
(&left.section, &left.path, &left.resource_json_path).cmp(&(
&right.section,
&right.path,
&right.resource_json_path,
))
});
Ok(resources)
}
fn parse_project_json_bytes(
archive_path: &Path,
project_json_path: &str,
bytes: &[u8],
) -> Result<RawProjectParsed, AppError> {
let parsed: RawProjectFile = serde_json::from_slice(bytes).map_err(|err| {
AppError::json_parse(
archive_path,
project_json_path,
format!("invalid JSON payload: {err}"),
)
})?;
Ok(RawProjectParsed {
title: parsed.title,
description: parsed.description,
parent: parsed.parent,
enabled: parsed.enabled,
inheritable: parsed.inheritable,
})
}
fn project_json_member_path(project_root: &str) -> String {
if project_root.is_empty() {
"project.json".to_string()
} else {
format!("{project_root}project.json")
}
}
fn parse_resource(
archive_path: &Path,
resource_json_path: &str,
bytes: &[u8],
) -> Result<RawResource, AppError> {
let value: Value = serde_json::from_slice(bytes).map_err(|err| {
AppError::json_parse(
archive_path,
resource_json_path,
format!("invalid JSON payload: {err}"),
)
})?;
let object = value.as_object().ok_or_else(|| {
AppError::json_parse(
archive_path,
resource_json_path,
"expected JSON object at resource root",
)
})?;
let files_value = object.get("files").ok_or_else(|| {
AppError::resource_integrity(format!(
"Missing required `files` key in `{resource_json_path}` of `{}`",
archive_path.display()
))
})?;
let files_array = files_value.as_array().ok_or_else(|| {
AppError::resource_integrity(format!(
"Expected `files` array in `{resource_json_path}` of `{}`",
archive_path.display()
))
})?;
let mut files = Vec::with_capacity(files_array.len());
for (index, entry) in files_array.iter().enumerate() {
let file_name = entry.as_str().ok_or_else(|| {
AppError::resource_integrity(format!(
"Expected string at `files[{index}]` in `{resource_json_path}` of `{}`",
archive_path.display()
))
})?;
if file_name.is_empty() {
return Err(AppError::resource_integrity(format!(
"Found empty file name at `files[{index}]` in `{resource_json_path}` of `{}`",
archive_path.display()
)));
}
files.push(file_name.to_string());
}
let mut attributes = BTreeMap::new();
for (key, value) in object {
if key != "files" {
attributes.insert(key.clone(), value.clone());
}
}
Ok(RawResource { files, attributes })
}
fn build_resource_files(
archive_path: &Path,
resource_json_path: &str,
resource_files: &[String],
entry_set: &BTreeSet<String>,
) -> Result<Vec<ResourceFile>, AppError> {
let resource_folder = resource_folder_path(resource_json_path).ok_or_else(|| {
AppError::internal(format!(
"invalid resource json path without suffix: {resource_json_path}"
))
})?;
let mut files = Vec::with_capacity(resource_files.len() + 2);
files.push(ResourceFile {
file_kind: "resource.json".to_string(),
file_zip_path: resource_json_path.to_string(),
});
let mut has_data_bin_declared = false;
for declared in resource_files {
let file_zip_path = format!("{resource_folder}{declared}");
if !entry_set.contains(&file_zip_path) {
return Err(AppError::resource_integrity(format!(
"Declared file `{declared}` is missing for `{resource_json_path}` in `{}`",
archive_path.display()
)));
}
if declared == "data.bin" {
has_data_bin_declared = true;
}
files.push(ResourceFile {
file_kind: file_kind_from_declared_name(declared),
file_zip_path,
});
}
let data_bin_path = format!("{resource_folder}data.bin");
if !has_data_bin_declared && entry_set.contains(&data_bin_path) {
files.push(ResourceFile {
file_kind: "data.bin".to_string(),
file_zip_path: data_bin_path,
});
}
Ok(files)
}
fn is_resource_json_for_root(entry: &str, project_root: &str) -> bool {
if !entry.ends_with("/resource.json") {
return false;
}
if project_root.is_empty() {
return true;
}
entry.starts_with(project_root)
}
fn resource_path_for_project_root(resource_json_path: &str, project_root: &str) -> String {
let folder = resource_folder_path(resource_json_path)
.unwrap_or(resource_json_path)
.trim_end_matches('/')
.to_string();
if project_root.is_empty() {
return folder;
}
folder
.strip_prefix(project_root)
.unwrap_or(&folder)
.to_string()
}
fn resource_folder_path(resource_json_path: &str) -> Option<&str> {
resource_json_path.strip_suffix("resource.json")
}
fn is_prefix_or_exact(path: &str, prefix: &str) -> bool {
if path == prefix {
return true;
}
match path.strip_prefix(prefix) {
Some(rest) => rest.starts_with('/'),
None => false,
}
}
fn file_kind_from_declared_name(declared_file_name: &str) -> String {
let file_name = declared_file_name
.rsplit('/')
.next()
.unwrap_or(declared_file_name);
if file_name.ends_with(".py") {
"script".to_string()
} else {
file_name.to_string()
}
}
fn is_binary_only_resource(files: &[ResourceFile]) -> bool {
let mut payload_count = 0usize;
for file in files {
if file.file_kind == "resource.json" {
continue;
}
payload_count += 1;
if file.file_kind != "data.bin" {
return false;
}
}
payload_count > 0
}
fn inspect_entries(archive_path: &Path, entries: &[String]) -> Result<ArchiveInspection, AppError> {
let kind = detect_archive_kind(entries);
let gateway_roots = detect_gateway_project_roots(entries);
let (detected_project_roots, selected_project_roots) = match kind {
ArchiveKind::ProjectExport => (vec![String::new()], vec![String::new()]),
ArchiveKind::GatewayBackup => (gateway_roots.clone(), gateway_roots),
ArchiveKind::Unknown => {
return Err(AppError::project_root_detection(
archive_path,
"expected `project.json` at archive root or one/more `projects/<name>/project.json` roots",
));
}
};
let project_selection = match selected_project_roots.len() {
0 => ProjectSelection::None,
1 => ProjectSelection::Single {
root: selected_project_roots[0].clone(),
},
_ => ProjectSelection::Multiple {
roots: selected_project_roots.clone(),
},
};
Ok(ArchiveInspection {
archive_kind: kind,
project_selection,
detected_project_roots,
selected_project_roots,
})
}
pub(crate) fn detect_archive_kind(entries: &[String]) -> ArchiveKind {
let has_root_project = entries.iter().any(|entry| entry == "project.json");
if has_root_project {
return ArchiveKind::ProjectExport;
}
let gateway_roots = detect_gateway_project_roots(entries);
if gateway_roots.is_empty() {
ArchiveKind::Unknown
} else {
ArchiveKind::GatewayBackup
}
}
pub(crate) fn detect_gateway_project_roots(entries: &[String]) -> Vec<String> {
let mut roots = BTreeSet::new();
for entry in entries {
if let Some(project_name) = gateway_project_name(entry) {
roots.insert(format!("projects/{project_name}/"));
}
}
roots.into_iter().collect()
}
fn gateway_project_name(entry: &str) -> Option<&str> {
let rest = entry.strip_prefix("projects/")?;
let name = rest.strip_suffix("/project.json")?;
if name.is_empty() || name.contains('/') {
return None;
}
Some(name)
}
fn normalize_zip_entry_name(name: &str) -> String {
name.replace('\\', "/").trim_start_matches('/').to_string()
}
#[derive(Debug)]
struct RawProjectParsed {
title: String,
description: Option<String>,
parent: Option<String>,
enabled: bool,
inheritable: bool,
}
impl RawProjectParsed {
fn with_root(self, project_root: String) -> ProjectMetadata {
ProjectMetadata {
project_root,
title: self.title,
description: self.description,
parent: self.parent,
enabled: self.enabled,
inheritable: self.inheritable,
}
}
}
#[derive(Debug)]
struct RawResource {
files: Vec<String>,
attributes: BTreeMap<String, Value>,
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use super::{
ArchiveKind, ProjectSelection, Resource, ResourceFile, aggregate_summary,
build_analytics_bundle, build_resource_files, classify_resource_path, compute_coverage,
compute_project_counts, detect_archive_kind, detect_gateway_project_roots,
discover_resources_for_root, discover_resources_for_roots, inspect_archive,
is_binary_only_resource, parse_project_json_bytes, parse_project_metadata, parse_resource,
};
use crate::error::AppError;
fn fixture_path(file_name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("example-files")
.join(file_name)
}
fn synthetic_resource(
section: &str,
type_key: &str,
path: &str,
binary_only: bool,
file_kinds: &[&str],
) -> Resource {
let files = file_kinds
.iter()
.enumerate()
.map(|(index, kind)| ResourceFile {
file_kind: (*kind).to_string(),
file_zip_path: format!("{path}/file_{index}"),
})
.collect();
Resource {
section: section.to_string(),
type_key: type_key.to_string(),
path: path.to_string(),
resource_json_path: format!("{path}/resource.json"),
binary_only,
attributes: BTreeMap::new(),
files,
}
}
#[test]
fn archive_kind_detects_project_export_fixture() {
let archive = inspect_archive(&fixture_path("Template_v8.3_example.zip"))
.expect("fixture should be inspectable");
assert_eq!(archive.archive_kind, ArchiveKind::ProjectExport);
}
#[test]
fn archive_kind_detects_gateway_backup_fixture() {
let archive = inspect_archive(&fixture_path("multi-project.gwbk"))
.expect("fixture should be inspectable");
assert_eq!(archive.archive_kind, ArchiveKind::GatewayBackup);
}
#[test]
fn archive_kind_detects_unknown_fixture_as_error() {
let err = inspect_archive(&fixture_path("data_center_industry_pack.1.1.0.zip"))
.expect_err("wrapper archive should fail root detection");
match err {
AppError::ProjectRootDetection { .. } => {}
other => panic!("expected project root detection error, got: {other:?}"),
}
}
#[test]
fn project_roots_for_project_export_is_root_only() {
let archive = inspect_archive(&fixture_path("Template_v8.3_example.zip"))
.expect("fixture should be inspectable");
assert_eq!(archive.detected_project_roots, vec![String::new()]);
assert_eq!(archive.selected_project_roots, vec![String::new()]);
assert_eq!(
archive.project_selection,
ProjectSelection::Single {
root: String::new()
}
);
}
#[test]
fn project_roots_for_multi_project_gateway_are_sorted() {
let archive = inspect_archive(&fixture_path("multi-project.gwbk"))
.expect("fixture should be inspectable");
assert_eq!(
archive.detected_project_roots,
vec![
"projects/IADemo/".to_string(),
"projects/OnlineDemo/".to_string(),
"projects/TagDashboard/".to_string(),
"projects/building-management-system-demo/".to_string(),
"projects/global/".to_string(),
"projects/oil-and-gas-demo/".to_string(),
"projects/prepared-foods-line-demo/".to_string(),
"projects/samplequickstart/".to_string(),
]
);
assert_eq!(
archive.detected_project_roots,
archive.selected_project_roots
);
assert_eq!(
archive.project_selection,
ProjectSelection::Multiple {
roots: vec![
"projects/IADemo/".to_string(),
"projects/OnlineDemo/".to_string(),
"projects/TagDashboard/".to_string(),
"projects/building-management-system-demo/".to_string(),
"projects/global/".to_string(),
"projects/oil-and-gas-demo/".to_string(),
"projects/prepared-foods-line-demo/".to_string(),
"projects/samplequickstart/".to_string(),
]
}
);
}
#[test]
fn archive_kind_prefers_root_project_when_both_shapes_exist() {
let entries = vec![
"project.json".to_string(),
"projects/alpha/project.json".to_string(),
];
assert_eq!(detect_archive_kind(&entries), ArchiveKind::ProjectExport);
}
#[test]
fn project_roots_ignore_invalid_gateway_layouts() {
let entries = vec![
"projects//project.json".to_string(),
"projects/alpha/nested/project.json".to_string(),
"Projects/uppercase/project.json".to_string(),
"projects/valid/project.json".to_string(),
];
assert_eq!(
detect_gateway_project_roots(&entries),
vec!["projects/valid/".to_string()]
);
}
#[test]
fn project_meta_project_export_fixture_yields_single_record() {
let archive_path = fixture_path("Template_v8.3_example.zip");
let inspection = inspect_archive(&archive_path).expect("fixture should be inspectable");
let project_meta =
parse_project_metadata(&archive_path, &inspection.selected_project_roots).unwrap();
assert_eq!(project_meta.len(), 1);
assert_eq!(project_meta[0].project_root, "");
assert_eq!(project_meta[0].title, "Good template");
assert_eq!(project_meta[0].enabled, true);
assert_eq!(project_meta[0].inheritable, false);
}
#[test]
fn project_meta_multi_project_fixture_preserves_selected_root_order() {
let archive_path = fixture_path("multi-project.gwbk");
let selected_roots = vec![
"projects/TagDashboard/".to_string(),
"projects/IADemo/".to_string(),
];
let project_meta = parse_project_metadata(&archive_path, &selected_roots).unwrap();
assert_eq!(project_meta.len(), 2);
assert_eq!(project_meta[0].project_root, "projects/TagDashboard/");
assert_eq!(project_meta[0].title, "IIoT Demo");
assert_eq!(project_meta[1].project_root, "projects/IADemo/");
assert_eq!(project_meta[1].title, "Vision Demo");
}
#[test]
fn project_meta_invalid_json_returns_json_parse_error() {
let err = parse_project_json_bytes(
Path::new("synthetic.zip"),
"project.json",
br#"{"title":"bad","enabled":"not_a_bool"}"#,
)
.expect_err("invalid JSON shape should fail");
match err {
AppError::JsonParse { .. } => {}
other => panic!("expected json parse error, got: {other:?}"),
}
}
#[test]
fn resource_discovery_project_export_counts_expected_resources() {
let archive_path = fixture_path("Template_v8.3_example.zip");
let resources = discover_resources_for_root(&archive_path, "").unwrap();
assert_eq!(resources.len(), 88);
assert!(
resources
.iter()
.all(|resource| resource.files.first().unwrap().file_kind == "resource.json")
);
}
#[test]
fn resource_discovery_gateway_backup_counts_expected_resources_per_project() {
let archive_path = fixture_path("multi-project.gwbk");
let selected_roots = vec![
"projects/IADemo/".to_string(),
"projects/OnlineDemo/".to_string(),
"projects/TagDashboard/".to_string(),
"projects/building-management-system-demo/".to_string(),
"projects/global/".to_string(),
"projects/oil-and-gas-demo/".to_string(),
"projects/prepared-foods-line-demo/".to_string(),
"projects/samplequickstart/".to_string(),
];
let inventory = discover_resources_for_roots(&archive_path, &selected_roots).unwrap();
let counts: Vec<(String, usize)> = inventory
.into_iter()
.map(|entry| (entry.project_root, entry.resources.len()))
.collect();
assert_eq!(
counts,
vec![
("projects/IADemo/".to_string(), 135),
("projects/OnlineDemo/".to_string(), 688),
("projects/TagDashboard/".to_string(), 76),
("projects/building-management-system-demo/".to_string(), 216),
("projects/global/".to_string(), 10),
("projects/oil-and-gas-demo/".to_string(), 24),
("projects/prepared-foods-line-demo/".to_string(), 132),
("projects/samplequickstart/".to_string(), 243),
]
);
}
#[test]
fn resource_discovery_is_deterministic_between_runs() {
let archive_path = fixture_path("Template_v8.3_example.zip");
let first = discover_resources_for_root(&archive_path, "").unwrap();
let second = discover_resources_for_root(&archive_path, "").unwrap();
assert_eq!(first, second);
}
#[test]
fn resource_validation_rejects_missing_files_key() {
let err = parse_resource(
Path::new("synthetic.zip"),
"foo/resource.json",
br#"{"scope":"A"}"#,
)
.expect_err("missing files key should fail");
match err {
AppError::ResourceIntegrity { .. } => {}
other => panic!("expected resource integrity error, got: {other:?}"),
}
}
#[test]
fn resource_validation_rejects_invalid_files_type() {
let err = parse_resource(
Path::new("synthetic.zip"),
"foo/resource.json",
br#"{"files":"not-an-array"}"#,
)
.expect_err("invalid files type should fail");
match err {
AppError::ResourceIntegrity { .. } => {}
other => panic!("expected resource integrity error, got: {other:?}"),
}
}
#[test]
fn resource_validation_rejects_missing_declared_file() {
let entry_set: BTreeSet<String> = ["foo/resource.json".to_string()].into_iter().collect();
let err = build_resource_files(
Path::new("synthetic.zip"),
"foo/resource.json",
&["missing.py".to_string()],
&entry_set,
)
.expect_err("missing declared file should fail");
match err {
AppError::ResourceIntegrity { .. } => {}
other => panic!("expected resource integrity error, got: {other:?}"),
}
}
#[test]
fn resource_validation_appends_data_bin_when_undeclared() {
let entry_set: BTreeSet<String> = [
"foo/resource.json".to_string(),
"foo/view.json".to_string(),
"foo/data.bin".to_string(),
]
.into_iter()
.collect();
let files = build_resource_files(
Path::new("synthetic.zip"),
"foo/resource.json",
&["view.json".to_string()],
&entry_set,
)
.expect("build resource files should succeed");
let ordered_paths: Vec<&str> = files
.iter()
.map(|file| file.file_zip_path.as_str())
.collect();
assert_eq!(
ordered_paths,
vec!["foo/resource.json", "foo/view.json", "foo/data.bin"]
);
}
#[test]
fn resource_validation_does_not_duplicate_declared_data_bin() {
let entry_set: BTreeSet<String> =
["foo/resource.json".to_string(), "foo/data.bin".to_string()]
.into_iter()
.collect();
let files = build_resource_files(
Path::new("synthetic.zip"),
"foo/resource.json",
&["data.bin".to_string()],
&entry_set,
)
.expect("build resource files should succeed");
assert_eq!(files.len(), 2);
assert_eq!(files[1].file_zip_path, "foo/data.bin");
}
#[test]
fn resource_binary_only_detection_requires_only_data_bin_payload() {
let entry_set: BTreeSet<String> =
["foo/resource.json".to_string(), "foo/data.bin".to_string()]
.into_iter()
.collect();
let files = build_resource_files(
Path::new("synthetic.zip"),
"foo/resource.json",
&["data.bin".to_string()],
&entry_set,
)
.unwrap();
assert!(is_binary_only_resource(&files));
let entry_set_with_text: BTreeSet<String> = [
"bar/resource.json".to_string(),
"bar/data.bin".to_string(),
"bar/view.json".to_string(),
]
.into_iter()
.collect();
let files_with_text = build_resource_files(
Path::new("synthetic.zip"),
"bar/resource.json",
&["view.json".to_string()],
&entry_set_with_text,
)
.unwrap();
assert!(!is_binary_only_resource(&files_with_text));
}
#[test]
fn classifier_covers_all_baseline_type_keys() {
let cases = vec![
(
"com.inductiveautomation.perspective/views/Main",
"Perspective",
"perspective.view",
),
(
"com.inductiveautomation.perspective/page-config",
"Perspective",
"perspective.page_config",
),
(
"com.inductiveautomation.perspective/style-classes/theme/default",
"Perspective",
"perspective.style_class",
),
(
"com.inductiveautomation.perspective/stylesheet",
"Perspective",
"perspective.stylesheet",
),
(
"com.inductiveautomation.perspective/message/toast",
"Perspective",
"perspective.message_handler",
),
(
"com.inductiveautomation.perspective/form-submission-handler/Form A",
"Perspective",
"perspective.form_submission_handler",
),
(
"com.inductiveautomation.perspective/key-event/Key A",
"Perspective",
"perspective.key_event",
),
(
"com.inductiveautomation.perspective/startup",
"Perspective",
"perspective.startup",
),
(
"com.inductiveautomation.perspective/shutdown",
"Perspective",
"perspective.shutdown",
),
(
"com.inductiveautomation.perspective/accelerometer",
"Perspective",
"perspective.accelerometer",
),
(
"com.inductiveautomation.perspective/barcode",
"Perspective",
"perspective.barcode",
),
(
"com.inductiveautomation.perspective/bluetooth",
"Perspective",
"perspective.bluetooth",
),
(
"com.inductiveautomation.perspective/auth-challenge",
"Perspective",
"perspective.auth_challenge",
),
(
"com.inductiveautomation.perspective/nfc-scan",
"Perspective",
"perspective.nfc_scan",
),
(
"com.inductiveautomation.perspective/page-startup",
"Perspective",
"perspective.page_startup",
),
(
"com.inductiveautomation.perspective/session-props",
"Perspective",
"perspective.session_props",
),
(
"ignition/script-python/my/script",
"Scripting",
"script.python",
),
(
"ignition/timer/My Timer",
"Scripting",
"script.gateway_event",
),
(
"ignition/named-query/My Query",
"Named Queries",
"named_query",
),
(
"com.inductiveautomation.sfc/charts/Main",
"Sequential Function Charts (SFC)",
"sfc",
),
(
"com.inductiveautomation.eventstream/event-streams/Main",
"Event Streams",
"event_stream",
),
(
"com.inductiveautomation.reporting/reports/Main",
"Reports",
"report",
),
(
"com.inductiveautomation.alarm-notification/alarm-pipelines/Main",
"Alarm Notification Pipelines",
"alarm_pipeline",
),
("ignition/global-props", "Properties", "project_properties"),
];
for (path, expected_section, expected_type_key) in cases {
let classification = classify_resource_path(path);
assert_eq!(
classification.section, expected_section,
"section mismatch for path `{path}`"
);
assert_eq!(
classification.type_key, expected_type_key,
"type mismatch for path `{path}`"
);
}
}
#[test]
fn classifier_uses_unknown_fallback_when_no_rule_matches() {
let classification = classify_resource_path("com.inductiveautomation.vision/windows/Main");
assert_eq!(classification.section, "Other");
assert_eq!(classification.type_key, "unknown");
}
#[test]
fn coverage_metrics_counts_unknown_resources() {
let resources = vec![
synthetic_resource(
"Perspective",
"perspective.view",
"a",
false,
&["resource.json"],
),
synthetic_resource(
"Other",
"unknown",
"b",
true,
&["resource.json", "data.bin"],
),
synthetic_resource(
"Other",
"unknown",
"c",
false,
&["resource.json", "view.json"],
),
];
let coverage = compute_coverage(&resources);
assert_eq!(coverage.unknown_resources, 2);
assert!((coverage.unknown_ratio - (2.0 / 3.0)).abs() < f64::EPSILON);
}
#[test]
fn project_counts_aggregates_resources_files_and_maps() {
let resources = vec![
synthetic_resource(
"Perspective",
"perspective.view",
"com.inductiveautomation.perspective/views/Main",
false,
&["resource.json", "view.json"],
),
synthetic_resource(
"Scripting",
"script.python",
"ignition/script-python/a",
false,
&["resource.json", "script"],
),
synthetic_resource(
"Other",
"unknown",
"com.inductiveautomation.vision/windows/Main",
true,
&["resource.json", "data.bin"],
),
];
let counts = compute_project_counts(&resources);
assert_eq!(counts.resources_total, 3);
assert_eq!(counts.files_total, 6);
assert_eq!(counts.binary_only_resources, 1);
assert_eq!(
counts.resources_by_section,
BTreeMap::from([
("Other".to_string(), 1usize),
("Perspective".to_string(), 1usize),
("Scripting".to_string(), 1usize),
])
);
assert_eq!(
counts.resources_by_type,
BTreeMap::from([
("perspective.view".to_string(), 1usize),
("script.python".to_string(), 1usize),
("unknown".to_string(), 1usize),
])
);
assert_eq!(
counts.files_by_kind,
BTreeMap::from([
("data.bin".to_string(), 1usize),
("resource.json".to_string(), 3usize),
("script".to_string(), 1usize),
("view.json".to_string(), 1usize),
])
);
}
#[test]
fn analytics_schema_has_parity_between_project_export_and_gateway_inputs() {
let project_export_path = fixture_path("Template_v8.3_example.zip");
let project_export_inspection = inspect_archive(&project_export_path).unwrap();
let project_export_meta = parse_project_metadata(
&project_export_path,
&project_export_inspection.selected_project_roots,
)
.unwrap();
let project_export_resources = discover_resources_for_roots(
&project_export_path,
&project_export_inspection.selected_project_roots,
)
.unwrap();
let project_export_bundle = build_analytics_bundle(
&project_export_path,
"2026-03-10T00:00:00Z",
&project_export_inspection,
&project_export_meta,
&project_export_resources,
)
.unwrap();
let gateway_path = fixture_path("multi-project.gwbk");
let gateway_inspection = inspect_archive(&gateway_path).unwrap();
let gateway_meta =
parse_project_metadata(&gateway_path, &gateway_inspection.selected_project_roots)
.unwrap();
let gateway_resources =
discover_resources_for_roots(&gateway_path, &gateway_inspection.selected_project_roots)
.unwrap();
let gateway_bundle = build_analytics_bundle(
&gateway_path,
"2026-03-10T00:00:00Z",
&gateway_inspection,
&gateway_meta,
&gateway_resources,
)
.unwrap();
let project_export_value = serde_json::to_value(project_export_bundle).unwrap();
let gateway_value = serde_json::to_value(gateway_bundle).unwrap();
let project_export_keys: BTreeSet<String> = project_export_value
.as_object()
.unwrap()
.keys()
.cloned()
.collect();
let gateway_keys: BTreeSet<String> =
gateway_value.as_object().unwrap().keys().cloned().collect();
assert_eq!(project_export_keys, gateway_keys);
assert_eq!(
project_export_keys,
BTreeSet::from([
"generated_at".to_string(),
"input".to_string(),
"issues".to_string(),
"projects".to_string(),
"schema_version".to_string(),
"summary".to_string(),
])
);
}
#[test]
fn analytics_aggregation_matches_project_entries_for_multi_project_fixture() {
let gateway_path = fixture_path("multi-project.gwbk");
let inspection = inspect_archive(&gateway_path).unwrap();
let project_meta =
parse_project_metadata(&gateway_path, &inspection.selected_project_roots).unwrap();
let resources =
discover_resources_for_roots(&gateway_path, &inspection.selected_project_roots)
.unwrap();
let bundle = build_analytics_bundle(
&gateway_path,
"2026-03-10T00:00:00Z",
&inspection,
&project_meta,
&resources,
)
.unwrap();
assert_eq!(bundle.projects.len(), 8);
let roots: Vec<String> = bundle
.projects
.iter()
.map(|project| project.project_root.clone())
.collect();
let mut sorted_roots = roots.clone();
sorted_roots.sort();
assert_eq!(roots, sorted_roots);
let resources_total_from_projects: usize = bundle
.projects
.iter()
.map(|project| project.counts.resources_total)
.sum();
let files_total_from_projects: usize = bundle
.projects
.iter()
.map(|project| project.counts.files_total)
.sum();
let binary_total_from_projects: usize = bundle
.projects
.iter()
.map(|project| project.counts.binary_only_resources)
.sum();
let unknown_total_from_projects: usize = bundle
.projects
.iter()
.map(|project| project.coverage.unknown_resources)
.sum();
assert_eq!(bundle.summary.projects_total, bundle.projects.len());
assert_eq!(
bundle.summary.resources_total,
resources_total_from_projects
);
assert_eq!(bundle.summary.files_total, files_total_from_projects);
assert_eq!(
bundle.summary.binary_only_resources,
binary_total_from_projects
);
assert_eq!(
bundle.summary.unknown_resources,
unknown_total_from_projects
);
let expected_ratio = if resources_total_from_projects == 0 {
0.0
} else {
unknown_total_from_projects as f64 / resources_total_from_projects as f64
};
assert!((bundle.summary.unknown_ratio - expected_ratio).abs() < f64::EPSILON);
}
#[test]
fn analytics_aggregation_function_merges_maps_deterministically() {
let project_a = super::ProjectAnalytics {
project_root: "a".to_string(),
project: super::ProjectMetadata {
project_root: "a".to_string(),
title: "A".to_string(),
description: None,
parent: None,
enabled: true,
inheritable: false,
},
counts: super::ProjectCounts {
resources_total: 1,
files_total: 2,
binary_only_resources: 0,
resources_by_section: BTreeMap::from([("Perspective".to_string(), 1usize)]),
resources_by_type: BTreeMap::from([("perspective.view".to_string(), 1usize)]),
files_by_kind: BTreeMap::from([
("resource.json".to_string(), 1usize),
("view.json".to_string(), 1usize),
]),
},
coverage: super::CoverageMetrics {
unknown_resources: 0,
unknown_ratio: 0.0,
},
issues: vec![],
};
let project_b = super::ProjectAnalytics {
project_root: "b".to_string(),
project: super::ProjectMetadata {
project_root: "b".to_string(),
title: "B".to_string(),
description: None,
parent: None,
enabled: true,
inheritable: false,
},
counts: super::ProjectCounts {
resources_total: 2,
files_total: 3,
binary_only_resources: 1,
resources_by_section: BTreeMap::from([
("Other".to_string(), 1usize),
("Scripting".to_string(), 1usize),
]),
resources_by_type: BTreeMap::from([
("script.python".to_string(), 1usize),
("unknown".to_string(), 1usize),
]),
files_by_kind: BTreeMap::from([
("data.bin".to_string(), 1usize),
("resource.json".to_string(), 2usize),
]),
},
coverage: super::CoverageMetrics {
unknown_resources: 1,
unknown_ratio: 0.5,
},
issues: vec![],
};
let summary = aggregate_summary(&[project_a, project_b]);
assert_eq!(summary.projects_total, 2);
assert_eq!(summary.resources_total, 3);
assert_eq!(summary.files_total, 5);
assert_eq!(summary.binary_only_resources, 1);
assert_eq!(summary.unknown_resources, 1);
assert!((summary.unknown_ratio - (1.0 / 3.0)).abs() < f64::EPSILON);
assert_eq!(
summary.resources_by_section,
BTreeMap::from([
("Other".to_string(), 1usize),
("Perspective".to_string(), 1usize),
("Scripting".to_string(), 1usize),
])
);
assert_eq!(
summary.resources_by_type,
BTreeMap::from([
("perspective.view".to_string(), 1usize),
("script.python".to_string(), 1usize),
("unknown".to_string(), 1usize),
])
);
assert_eq!(
summary.files_by_kind,
BTreeMap::from([
("data.bin".to_string(), 1usize),
("resource.json".to_string(), 3usize),
("view.json".to_string(), 1usize),
])
);
}
}