use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::client::GatewayApi;
use crate::client::projects::{ProjectCreate, ProjectModify, ProjectRecord};
use crate::client::query::ListQuery;
use crate::error::CoreError;
pub const EXPORT_INCLUDES: &[&str] = &[
"views",
"scripts",
"named-queries",
"vision-windows",
"perspective-themes-styles",
"reporting",
"alarm-notification-profiles",
"webdev-routes",
"translations",
"sfc-charts",
];
pub const EXPORT_EXCLUDES: &[&str] = &[
"tag-providers",
"tags",
"udts",
"gateway-config",
"database-connections",
"users-roles",
"alarm-journal",
"certificates",
];
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ExportScope {
pub includes: Vec<&'static str>,
pub excludes: Vec<&'static str>,
}
impl ExportScope {
pub fn new() -> Self {
Self {
includes: EXPORT_INCLUDES.to_vec(),
excludes: EXPORT_EXCLUDES.to_vec(),
}
}
}
impl Default for ExportScope {
fn default() -> Self {
Self::new()
}
}
pub const IMPORT_MAX_BYTES: usize = 512 * 1024 * 1024;
const ZIP_MAGIC: [u8; 4] = [0x50, 0x4B, 0x03, 0x04];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum CollisionPolicy {
Abort,
Overwrite,
}
impl CollisionPolicy {
pub fn label(self) -> &'static str {
match self {
Self::Abort => "abort",
Self::Overwrite => "overwrite",
}
}
}
fn import_size_error(len: usize) -> Option<CoreError> {
(len > IMPORT_MAX_BYTES).then(|| CoreError::InvalidImportFile {
reason: format!(
"{len} bytes exceeds the {} MB sanity limit",
IMPORT_MAX_BYTES / (1024 * 1024)
),
})
}
fn validate_import(zip: &[u8]) -> Result<(), CoreError> {
if !zip.starts_with(&ZIP_MAGIC) {
return Err(CoreError::InvalidImportFile {
reason: "missing ZIP magic (PK\\x03\\x04) — not a project export archive".to_string(),
});
}
if let Some(err) = import_size_error(zip.len()) {
return Err(err);
}
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip)).map_err(|err| {
CoreError::InvalidImportFile {
reason: format!("not a readable ZIP archive: {err}"),
}
})?;
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|err| CoreError::InvalidImportFile {
reason: format!("cannot read import archive member {index}: {err}"),
})?;
let name = file.name().to_string();
let mut sink = Vec::new();
std::io::Read::read_to_end(&mut file, &mut sink).map_err(|err| {
CoreError::InvalidImportFile {
reason: format!("cannot decompress import member {name:?}: {err}"),
}
})?;
}
Ok(())
}
fn sanitize_basename(raw: &str) -> Option<String> {
let name = raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim();
if name.is_empty() || name == "." || name == ".." {
None
} else {
Some(name.to_string())
}
}
fn safe_fallback_stem(name: &str) -> String {
name.replace(['/', '\\'], "_")
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ProjectSummary {
pub name: String,
pub title: Option<String>,
pub description: Option<String>,
pub enabled: bool,
pub parent: Option<String>,
pub inheritable: Option<bool>,
}
impl ProjectSummary {
fn from_record(record: &ProjectRecord) -> Self {
Self {
name: record.name.clone(),
title: record.title.clone(),
description: record.description.clone(),
enabled: record.enabled,
parent: record.parent.clone(),
inheritable: record.inheritable,
}
}
}
#[derive(Debug, Serialize)]
pub struct ProjectsResult {
pub projects: Vec<ProjectSummary>,
}
#[derive(Debug, Default, Clone)]
pub struct NewOptions {
pub enabled: bool,
pub title: Option<String>,
pub description: Option<String>,
pub parent: Option<String>,
pub inheritable: Option<bool>,
}
#[derive(Debug, Default, Clone)]
pub struct SetOptions {
pub title: Option<String>,
pub description: Option<String>,
pub parent: Option<String>,
pub enabled: Option<bool>,
pub inheritable: Option<bool>,
}
impl SetOptions {
fn fields_set(&self) -> Vec<String> {
let mut fields = Vec::new();
if self.title.is_some() {
fields.push("title".to_string());
}
if self.description.is_some() {
fields.push("description".to_string());
}
if self.parent.is_some() {
fields.push("parent".to_string());
}
if self.enabled.is_some() {
fields.push("enabled".to_string());
}
if self.inheritable.is_some() {
fields.push("inheritable".to_string());
}
fields
}
}
#[derive(Debug, Serialize)]
pub struct ProjectCopyResult {
pub from: String,
#[serde(flatten)]
pub project: ProjectSummary,
}
#[derive(Debug, Serialize)]
pub struct ProjectRenameResult {
pub previous_name: String,
#[serde(flatten)]
pub project: ProjectSummary,
}
#[derive(Debug, Serialize)]
pub struct ProjectSetResult {
#[serde(skip)]
pub fields: Vec<String>,
#[serde(flatten)]
pub project: ProjectSummary,
}
#[derive(Debug, Serialize)]
pub struct ProjectDeleteResult {
pub deleted: String,
}
#[derive(Debug, Serialize)]
pub struct ExportResult {
pub project: String,
pub file: String,
pub bytes: u64,
pub scope: ExportScope,
}
#[derive(Debug, Serialize)]
pub struct ImportResult {
pub name: String,
pub collision_policy: String,
pub bytes: usize,
pub scope: ExportScope,
pub outcome: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ProjectMetaDelta {
pub field: String,
pub a: String,
pub b: String,
}
#[derive(Debug, Serialize)]
pub struct ProjectDiffResult {
pub scope: &'static str,
pub profile_a: String,
pub profile_b: String,
pub project: String,
pub project_meta: Vec<ProjectMetaDelta>,
pub summary: crate::client::resources::DiffSummary,
pub entries: Vec<crate::client::resources::MemberDiffEntry>,
}
pub async fn project_diff(
api_a: &dyn GatewayApi,
api_b: &dyn GatewayApi,
project: &str,
profile_a: &str,
profile_b: &str,
) -> Result<ProjectDiffResult, CoreError> {
if profile_a == profile_b {
return Err(CoreError::InvalidInput {
reason: "diffing a profile against itself is a no-op — name two \
different profiles"
.to_string(),
});
}
let zip_a = crate::actions::resources::export_zip_bytes(api_a, project).await?;
let zip_b = crate::actions::resources::export_zip_bytes(api_b, project).await?;
let diff = crate::client::resources::diff_members(&zip_a, &zip_b)?;
let project_meta = crate::client::resources::project_meta_delta(&zip_a, &zip_b)?
.into_iter()
.map(|(field, a, b)| ProjectMetaDelta { field, a, b })
.collect();
Ok(ProjectDiffResult {
scope: "project",
profile_a: profile_a.to_string(),
profile_b: profile_b.to_string(),
project: project.to_string(),
project_meta,
summary: diff.summary,
entries: diff.entries,
})
}
#[derive(Debug, Default, Clone)]
pub struct SyncSelection {
pub resources: Vec<String>,
pub all_changed: bool,
}
#[derive(Debug, Serialize)]
pub struct ProjectSyncResult {
pub scope: &'static str,
pub profile_a: String,
pub profile_b: String,
pub project: String,
pub synced: Vec<String>,
pub removed: Vec<String>,
}
pub async fn project_sync(
api_a: &dyn GatewayApi,
api_b: &dyn GatewayApi,
project: &str,
selection: &SyncSelection,
delete: bool,
profile_a: &str,
profile_b: &str,
) -> Result<ProjectSyncResult, CoreError> {
if selection.resources.is_empty() && !selection.all_changed {
return Err(CoreError::InvalidInput {
reason: "sync needs a selection — pass --resource PATH (repeatable) \
and/or --all-changed"
.to_string(),
});
}
let zip_a = crate::actions::resources::export_zip_bytes(api_a, project).await?;
let zip_b = crate::actions::resources::export_zip_bytes(api_b, project).await?;
let mut upserts: Vec<String> = Vec::new();
let mut removals: Vec<String> = Vec::new();
for path in &selection.resources {
match crate::client::resources::read_member(&zip_a, path) {
Ok(_) => upserts.push(path.clone()),
Err(CoreError::NotFound { .. }) if delete => removals.push(path.clone()),
Err(other) => return Err(other),
}
}
if selection.all_changed {
for entry in crate::client::resources::diff_members(&zip_a, &zip_b)?.entries {
match entry.status {
crate::client::resources::MemberStatus::Removed
| crate::client::resources::MemberStatus::Changed => {
upserts.push(entry.path);
}
crate::client::resources::MemberStatus::Added if delete => {
removals.push(entry.path);
}
_ => {}
}
}
}
upserts.sort();
upserts.dedup();
removals.sort();
removals.dedup();
let mut surgical = zip_b;
for path in &upserts {
let bytes = crate::client::resources::read_member(&zip_a, path)?;
surgical = crate::client::resources::replace_member(&surgical, path, &bytes)?;
}
for path in &removals {
surgical = crate::client::resources::remove_member(&surgical, path)?;
}
if !upserts.is_empty() || !removals.is_empty() {
validate_import(&surgical)?;
api_b.project_import(project, surgical, true).await?;
}
Ok(ProjectSyncResult {
scope: "project",
profile_a: profile_a.to_string(),
profile_b: profile_b.to_string(),
project: project.to_string(),
synced: upserts,
removed: removals,
})
}
pub async fn projects(api: &dyn GatewayApi) -> Result<ProjectsResult, CoreError> {
let page = api.projects(&ListQuery::default()).await?;
Ok(ProjectsResult {
projects: page.items.iter().map(ProjectSummary::from_record).collect(),
})
}
pub async fn project_new(
api: &dyn GatewayApi,
name: &str,
opts: &NewOptions,
) -> Result<ProjectSummary, CoreError> {
let body = ProjectCreate {
name: name.to_string(),
enabled: opts.enabled,
title: opts.title.clone(),
description: opts.description.clone(),
parent: opts.parent.clone(),
inheritable: opts.inheritable,
default_db: None,
tag_provider: None,
user_source: None,
};
api.project_create(&body).await?;
let record = api.project_find(name).await?;
Ok(ProjectSummary::from_record(&record))
}
pub async fn project_copy(
api: &dyn GatewayApi,
from: &str,
to: &str,
) -> Result<ProjectCopyResult, CoreError> {
api.project_copy(from, to).await?;
let record = api.project_find(to).await?;
Ok(ProjectCopyResult {
from: from.to_string(),
project: ProjectSummary::from_record(&record),
})
}
pub async fn project_rename(
api: &dyn GatewayApi,
old: &str,
new: &str,
) -> Result<ProjectRenameResult, CoreError> {
api.project_rename(old, new).await?;
let record = api.project_find(new).await?;
Ok(ProjectRenameResult {
previous_name: old.to_string(),
project: ProjectSummary::from_record(&record),
})
}
pub async fn project_set(
api: &dyn GatewayApi,
name: &str,
opts: &SetOptions,
) -> Result<ProjectSetResult, CoreError> {
let body = ProjectModify {
enabled: opts.enabled,
title: opts.title.clone(),
description: opts.description.clone(),
parent: opts.parent.clone(),
inheritable: opts.inheritable,
default_db: None,
tag_provider: None,
user_source: None,
};
api.project_modify(name, &body).await?;
let record = api.project_find(name).await?;
Ok(ProjectSetResult {
fields: opts.fields_set(),
project: ProjectSummary::from_record(&record),
})
}
pub async fn project_delete(
api: &dyn GatewayApi,
name: &str,
) -> Result<ProjectDeleteResult, CoreError> {
api.project_delete(name).await?;
Ok(ProjectDeleteResult {
deleted: name.to_string(),
})
}
pub async fn project_export(
api: &dyn GatewayApi,
name: &str,
output: Option<&Path>,
) -> Result<ExportResult, CoreError> {
let scope = ExportScope::new();
if let Some(out) = output {
let meta = api.project_export_to_file(name, out).await?;
return Ok(ExportResult {
project: name.to_string(),
file: out.display().to_string(),
bytes: meta.bytes,
scope,
});
}
let fallback = format!("{}.zip", safe_fallback_stem(name));
let part = PathBuf::from(format!("{fallback}.part"));
let meta = match api.project_export_to_file(name, &part).await {
Ok(meta) => meta,
Err(err) => {
let _ = std::fs::remove_file(&part); return Err(err);
}
};
let final_name = meta
.filename
.as_deref()
.and_then(sanitize_basename)
.unwrap_or(fallback);
if let Err(err) = std::fs::rename(&part, &final_name) {
let _ = std::fs::remove_file(&part); return Err(CoreError::Internal(format!(
"cannot finalize export {final_name}: {err}"
)));
}
Ok(ExportResult {
project: name.to_string(),
file: final_name,
bytes: meta.bytes,
scope,
})
}
pub async fn project_import(
api: &dyn GatewayApi,
name: &str,
zip: Vec<u8>,
policy: CollisionPolicy,
) -> Result<ImportResult, CoreError> {
let bytes = zip.len();
let scope = ExportScope::new();
validate_import(&zip)?;
if matches!(policy, CollisionPolicy::Abort) && api.project_find(name).await.is_ok() {
return Err(CoreError::ProjectExists {
name: name.to_string(),
endpoint: None,
});
}
let overwrite = matches!(policy, CollisionPolicy::Overwrite);
let outcome = api.project_import(name, zip, overwrite).await?;
Ok(ImportResult {
name: name.to_string(),
collision_policy: policy.label().to_string(),
bytes,
scope,
outcome: outcome.response,
})
}
#[derive(Debug, Serialize)]
pub struct ExportDecodedResult {
pub project: String,
pub dir: String,
pub members: usize,
pub scripts_decoded: usize,
pub bytes: u64,
pub scope: ExportScope,
}
pub async fn project_export_decoded(
api: &dyn GatewayApi,
name: &str,
out_dir: Option<&Path>,
) -> Result<ExportDecodedResult, CoreError> {
let zip = crate::actions::resources::export_zip_bytes(api, name).await?;
let dir = match out_dir {
Some(dir) => dir.to_path_buf(),
None => PathBuf::from(format!("{}-export", safe_fallback_stem(name))),
};
let members = crate::client::scripts_codec::count_file_members(&zip)?;
let scripts_decoded = crate::client::scripts_codec::decode_export_tree(&zip, &dir)?;
Ok(ExportDecodedResult {
project: name.to_string(),
dir: dir.display().to_string(),
members,
scripts_decoded,
bytes: zip.len() as u64,
scope: ExportScope::new(),
})
}
#[cfg(test)]
mod tests {
use super::{NewOptions, ProjectSummary, SetOptions, project_new, projects};
use crate::client::GatewayApi;
use crate::client::projects::{ProjectCreate, ProjectModify, ProjectRecord};
use crate::client::query::{ListEnvelope, ListMetadata};
use crate::error::CoreError;
use std::sync::Mutex;
#[derive(Default)]
struct ProjectsRig {
creates: Mutex<Vec<ProjectCreate>>,
modifies: Mutex<Vec<(String, ProjectModify)>>,
deletes: Mutex<Vec<String>>,
finds: Mutex<Vec<String>>,
exports: Mutex<Vec<String>>,
imports: Mutex<Vec<(String, usize, bool)>>,
absent: bool,
export_body: Option<Vec<u8>>,
}
impl ProjectsRig {
fn zip_fixture() -> Vec<u8> {
use std::io::Write as _;
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
writer
.start_file("project.json", options)
.expect("fixture member starts");
writer
.write_all(br#"{"title":"fixture"}"#)
.expect("fixture member writes");
writer.finish().expect("fixture finalizes").into_inner()
}
}
fn record(name: &str) -> ProjectRecord {
ProjectRecord {
name: name.into(),
title: Some(format!("{name} title")),
description: None,
enabled: true,
parent: Some("Base".into()),
inheritable: Some(false),
default_db: None,
tag_provider: None,
user_source: None,
extra: Default::default(),
}
}
fn page(items: Vec<ProjectRecord>) -> ListEnvelope<ProjectRecord> {
let total = items.len() as i64;
ListEnvelope {
items,
metadata: ListMetadata {
total,
matching: total,
limit: -1,
offset: 0,
},
}
}
#[async_trait::async_trait]
impl GatewayApi for ProjectsRig {
async fn bundle_generate(
&self,
) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn bundle_status(
&self,
) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn bundle_download(
&self,
_out: &std::path::Path,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_list(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn tag_provider_find(
&self,
_name: &str,
) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_create(
&self,
_body: &[crate::client::tags::TagProviderCreate],
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_delete(
&self,
_name: &str,
_signature: &str,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
unreachable!("not part of this action")
}
async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
unreachable!("not part of this action")
}
async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
unreachable!("not part of this action")
}
async fn backup_download(
&self,
_out: &std::path::Path,
_backup_type: crate::client::backup::BackupType,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
unreachable!("not part of this action")
}
async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_history(
&self,
_limit: Option<u32>,
_search: Option<&str>,
) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
{
unreachable!("not part of this action")
}
async fn eam_task_definitions(
&self,
) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
{
unreachable!("not part of this action")
}
async fn eam_task_find(
&self,
_name: &str,
) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_tasks_scheduled(
&self,
_running: bool,
) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_modify(
&self,
_definition: &serde_json::Value,
) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_delete(
&self,
_name: &str,
_signature: &str,
_confirm: bool,
) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
unreachable!("not part of this action")
}
async fn api_call(
&self,
_call: &crate::client::apicall::ApiCallRequest,
) -> Result<crate::client::apicall::ApiCallData, CoreError> {
unreachable!("not part of this action")
}
async fn license_status(
&self,
) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn redundancy_status(
&self,
) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
unreachable!("not part of this action")
}
async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
unreachable!("not part of this action")
}
async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
unreachable!("not part of this action")
}
async fn modules(
&self,
_quarantined: bool,
_query: &crate::client::query::ListQuery,
) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
unreachable!("not part of this action")
}
async fn metrics_current(
&self,
) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
unreachable!("not part of this action")
}
async fn metrics_historic(
&self,
) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
unreachable!("not part of this action")
}
async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
unreachable!("not part of this action")
}
async fn designers(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
unreachable!("not part of this action")
}
async fn perspective_sessions(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
unreachable!("not part of this action")
}
async fn vision_clients(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
unreachable!("not part of this action")
}
async fn terminate_perspective_session(
&self,
_id: &str,
_message: Option<&str>,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn database_connections(
&self,
) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
{
unreachable!("not part of this action")
}
async fn opc_connections(
&self,
) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
{
unreachable!("not part of this action")
}
async fn logs(
&self,
_filter: &crate::client::logs::LogQuery,
) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
unreachable!("not part of this action")
}
async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
unreachable!("not part of this action")
}
async fn loggers(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
unreachable!("not part of this action")
}
async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn reset_logger_levels(&self) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn restart(&self) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn scan_projects(&self) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn security_properties(
&self,
) -> Result<crate::client::restart::SecurityProperties, CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_call(
&self,
_project: &str,
_route: &str,
_body: &serde_json::Value,
_extra_headers: &[(&str, &str)],
) -> Result<serde_json::Value, CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_probe(
&self,
_project: &str,
_route: &str,
_extra_headers: &[(&str, &str)],
) -> Result<crate::client::webdev::RouteProbe, CoreError> {
unreachable!("not part of this action")
}
async fn projects(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<ListEnvelope<ProjectRecord>, CoreError> {
Ok(page(vec![record("PlantFloor"), record("Base")]))
}
async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError> {
self.finds.lock().unwrap().push(name.into());
if self.absent {
Err(CoreError::NotFound { endpoint: None })
} else {
Ok(record("whatever-the-rig-is-asked-for"))
}
}
async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError> {
self.creates.lock().unwrap().push(body.clone());
Ok(())
}
async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
Ok(())
}
async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
Ok(())
}
async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError> {
self.modifies
.lock()
.unwrap()
.push((name.into(), body.clone()));
Ok(())
}
async fn project_delete(&self, name: &str) -> Result<(), CoreError> {
self.deletes.lock().unwrap().push(name.into());
Ok(())
}
async fn project_export_to_file(
&self,
name: &str,
out: &std::path::Path,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
self.exports.lock().unwrap().push(name.into());
let fixture = self.export_body.clone().unwrap_or_else(Self::zip_fixture);
std::fs::write(out, &fixture)
.map_err(|err| CoreError::Internal(format!("rig export write: {err}")))?;
Ok(crate::client::projects::ExportMeta {
filename: Some("rig-export.zip".into()),
bytes: fixture.len() as u64,
content_type: Some("application/zip".into()),
})
}
async fn project_import(
&self,
name: &str,
zip: Vec<u8>,
overwrite: bool,
) -> Result<crate::client::projects::ImportOutcome, CoreError> {
self.imports
.lock()
.unwrap()
.push((name.into(), zip.len(), overwrite));
Ok(crate::client::projects::ImportOutcome {
response: serde_json::json!({"status": "success"}),
})
}
}
#[test]
fn set_options_only_title_serializes_exactly_title() {
let opts = SetOptions {
title: Some("T".into()),
..Default::default()
};
let body = ProjectModify {
enabled: opts.enabled,
title: opts.title.clone(),
description: opts.description.clone(),
parent: opts.parent.clone(),
inheritable: opts.inheritable,
default_db: None,
tag_provider: None,
user_source: None,
};
assert_eq!(
serde_json::to_value(&body).expect("serializes"),
serde_json::json!({"title": "T"})
);
}
#[tokio::test]
async fn projects_action_selects_the_six_stable_fields() {
let rig = ProjectsRig::default();
let result = projects(&rig).await.expect("list");
assert_eq!(result.projects.len(), 2);
assert_eq!(
result.projects[0],
ProjectSummary {
name: "PlantFloor".into(),
title: Some("PlantFloor title".into()),
description: None,
enabled: true,
parent: Some("Base".into()),
inheritable: Some(false),
}
);
let json = serde_json::to_value(&result).expect("serialize");
let mut keys: Vec<&str> = json["projects"][0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"description",
"enabled",
"inheritable",
"name",
"parent",
"title"
]
);
}
#[tokio::test]
async fn project_new_creates_then_reads_back() {
let rig = ProjectsRig::default();
let opts = NewOptions {
enabled: true,
title: Some("T".into()),
description: None,
parent: Some("Base".into()),
inheritable: Some(true),
};
let summary = project_new(&rig, "child", &opts).await.expect("new");
assert_eq!(summary.name, "whatever-the-rig-is-asked-for");
let creates = rig.creates.lock().unwrap();
assert_eq!(creates.len(), 1);
assert_eq!(
serde_json::to_value(&creates[0]).unwrap(),
serde_json::json!({
"name": "child",
"enabled": true,
"title": "T",
"parent": "Base",
"inheritable": true
})
);
}
#[tokio::test]
async fn project_set_modifies_with_somes_and_reads_back() {
let rig = ProjectsRig::default();
let opts = SetOptions {
title: Some("T".into()),
parent: Some("Base".into()),
..Default::default()
};
let result = super::project_set(&rig, "x", &opts).await.expect("set");
assert_eq!(result.fields, vec!["title", "parent"]);
let modifies = rig.modifies.lock().unwrap();
assert_eq!(modifies.len(), 1);
assert_eq!(modifies[0].0, "x");
assert_eq!(
serde_json::to_value(&modifies[0].1).unwrap(),
serde_json::json!({"title": "T", "parent": "Base"})
);
let json = serde_json::to_value(&result).expect("serialize");
let keys: Vec<&str> = json
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(
keys,
[
"description",
"enabled",
"inheritable",
"name",
"parent",
"title"
],
"no `fields` key in the agent shape"
);
}
#[tokio::test]
async fn project_delete_records_the_name() {
let rig = ProjectsRig::default();
let result = super::project_delete(&rig, "gone").await.expect("delete");
assert_eq!(result.deleted, "gone");
assert_eq!(*rig.deletes.lock().unwrap(), vec!["gone".to_string()]);
}
#[tokio::test]
async fn import_refuses_non_zip_before_any_network() {
let rig = ProjectsRig::default();
let err = super::project_import(
&rig,
"x",
b"definitely not a zip".to_vec(),
super::CollisionPolicy::Abort,
)
.await
.expect_err("the magic guard refuses");
assert_eq!(
err.exit_code(),
2,
"usage class — the caller must fix the file"
);
assert_eq!(err.code(), "invalid_import_file");
assert!(
rig.finds.lock().unwrap().is_empty(),
"zero pre-check calls — the guard runs first"
);
assert!(rig.imports.lock().unwrap().is_empty(), "zero uploads");
}
#[tokio::test]
async fn import_refuses_truncated_zip_before_any_network() {
let rig = ProjectsRig::default();
let truncated = {
let full = ProjectsRig::zip_fixture();
let cut = full.len() - 10;
full[..cut].to_vec()
};
let err = super::project_import(&rig, "x", truncated, super::CollisionPolicy::Overwrite)
.await
.expect_err("the structure guard refuses");
assert_eq!(err.exit_code(), 2);
assert_eq!(err.code(), "invalid_import_file");
assert!(
rig.finds.lock().unwrap().is_empty() && rig.imports.lock().unwrap().is_empty(),
"zero network of any kind — the structure guard runs before everything"
);
}
#[test]
fn import_size_guard_refuses_over_512mb() {
let err = super::import_size_error(super::IMPORT_MAX_BYTES + 1)
.expect("one byte over the limit refuses");
assert_eq!(err.exit_code(), 2);
assert_eq!(err.code(), "invalid_import_file");
let message = err.to_string();
assert!(
message.contains("512 MB"),
"the reason names the limit: {message}"
);
assert!(
super::import_size_error(super::IMPORT_MAX_BYTES).is_none(),
"exactly at the limit is fine"
);
}
#[tokio::test]
async fn import_abort_over_existing_refuses_project_exists() {
let rig = ProjectsRig::default(); let err = super::project_import(
&rig,
"PlantFloor",
ProjectsRig::zip_fixture(),
super::CollisionPolicy::Abort,
)
.await
.expect_err("the collision pre-check refuses");
assert!(
matches!(&err, CoreError::ProjectExists { name, .. } if name == "PlantFloor"),
"wrong class: {err}"
);
assert_eq!(err.exit_code(), 6);
assert_eq!(err.code(), "project_exists");
let hint = err.hint().expect("hint required");
assert!(
hint.contains("--collision-policy overwrite"),
"hint names the flag: {hint}"
);
assert!(
hint.contains("ENTIRE project") && hint.contains("Designer-only"),
"hint warns replace-not-merge: {hint}"
);
assert!(
rig.imports.lock().unwrap().is_empty(),
"the refusal happened BEFORE any upload"
);
assert_eq!(*rig.finds.lock().unwrap(), vec!["PlantFloor".to_string()]);
}
#[tokio::test]
async fn import_abort_when_free_uploads_without_overwrite() {
let rig = ProjectsRig {
absent: true,
..Default::default()
};
let result = super::project_import(
&rig,
"fresh",
ProjectsRig::zip_fixture(),
super::CollisionPolicy::Abort,
)
.await
.expect("free name imports");
assert_eq!(result.name, "fresh");
assert_eq!(result.collision_policy, "abort");
assert_eq!(result.bytes, ProjectsRig::zip_fixture().len());
assert_eq!(
result.scope,
super::ExportScope::new(),
"import carries the SAME scope consts as export"
);
assert_eq!(
*rig.imports.lock().unwrap(),
vec![("fresh".to_string(), ProjectsRig::zip_fixture().len(), false)]
);
}
#[tokio::test]
async fn import_overwrite_skips_pre_check_and_uploads() {
let rig = ProjectsRig::default(); let result = super::project_import(
&rig,
"PlantFloor",
ProjectsRig::zip_fixture(),
super::CollisionPolicy::Overwrite,
)
.await
.expect("overwrite imports without a pre-check");
assert_eq!(result.collision_policy, "overwrite");
assert!(
rig.finds.lock().unwrap().is_empty(),
"overwrite performs ZERO pre-check calls"
);
assert_eq!(
*rig.imports.lock().unwrap(),
vec![(
"PlantFloor".to_string(),
ProjectsRig::zip_fixture().len(),
true
)]
);
}
#[test]
fn export_scope_arrays_are_data() {
assert!(
super::EXPORT_EXCLUDES.contains(&"tag-providers"),
"the headline exclusion (tags are gateway config, not project export)"
);
assert!(super::EXPORT_EXCLUDES.contains(&"tags"));
assert!(super::EXPORT_EXCLUDES.contains(&"udts"));
assert!(super::EXPORT_INCLUDES.contains(&"views"));
assert!(super::EXPORT_INCLUDES.contains(&"scripts"));
assert!(super::EXPORT_INCLUDES.contains(&"named-queries"));
let json = serde_json::to_value(super::ExportScope::new()).expect("scope serializes");
assert_eq!(
json["includes"]
.as_array()
.expect("includes is an array")
.len(),
super::EXPORT_INCLUDES.len()
);
assert_eq!(
json["excludes"][0], "tag-providers",
"declaration order is the agent-visible order"
);
}
#[tokio::test]
async fn export_to_explicit_path_streams_and_reports() {
let rig = ProjectsRig::default();
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("proj.zip");
let result = super::project_export(&rig, "My Proj", Some(&out))
.await
.expect("export");
assert_eq!(result.project, "My Proj");
assert_eq!(result.file, out.display().to_string());
assert_eq!(result.bytes as usize, ProjectsRig::zip_fixture().len());
assert_eq!(
std::fs::read(&out).expect("file written"),
ProjectsRig::zip_fixture(),
"the fixture landed byte-for-byte"
);
assert_eq!(result.scope, super::ExportScope::new());
assert_eq!(*rig.exports.lock().unwrap(), vec!["My Proj".to_string()]);
}
#[test]
fn sanitize_basename_strips_path_components() {
assert_eq!(
super::sanitize_basename("MyProj-export.zip"),
Some("MyProj-export.zip".to_string())
);
assert_eq!(
super::sanitize_basename("../../etc/passwd"),
Some("passwd".to_string()),
"path components never survive"
);
assert_eq!(
super::sanitize_basename(r"..\..\win\evil.zip"),
Some("evil.zip".to_string())
);
assert_eq!(super::sanitize_basename(".."), None);
assert_eq!(super::sanitize_basename("."), None);
assert_eq!(super::sanitize_basename(" "), None);
assert_eq!(super::safe_fallback_stem("a/b\\c"), "a_b_c");
}
#[tokio::test]
async fn project_diff_same_profile_refuses_before_any_export() {
let rig = ProjectsRig::default();
let err = super::project_diff(&rig, &rig, "p", "dev", "dev")
.await
.expect_err("the same-profile refusal");
assert_eq!(err.exit_code(), 2);
assert_eq!(err.code(), "invalid_input");
assert!(
rig.exports.lock().unwrap().is_empty(),
"zero exports — the refusal leads"
);
}
#[tokio::test]
async fn project_sync_selection_less_refuses_before_any_export() {
let rig = ProjectsRig::default();
let err = super::project_sync(
&rig,
&rig,
"p",
&super::SyncSelection::default(),
false,
"a",
"b",
)
.await
.expect_err("the selection-less refusal");
assert_eq!(err.exit_code(), 2);
assert_eq!(err.code(), "invalid_input");
assert!(rig.exports.lock().unwrap().is_empty());
}
fn script_bearing_zip() -> Vec<u8> {
use std::io::Write as _;
let view = br#"{
"scope": "G",
"children": [
{
"type": "ia.display.label",
"eventScripts": {
"actionPerformed": {
"config": {
"script": "\tprint \u0027clicked\u0027\n\tprint \u0027done\u0027"
}
}
}
},
{
"type": "ia.chart",
"transform": {
"script": "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint \u0027end\u0027"
},
"props": {
"expression": "toStr({view.args.x} * 2)"
}
}
]
}"#;
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
writer.start_file("project.json", options).expect("starts");
writer.write_all(br#"{"title":"T"}"#).expect("writes");
writer
.start_file("c/resources/views/Dash/view.json", options)
.expect("starts");
writer.write_all(view).expect("writes");
writer
.start_file("c/resources/views/Dash/resource.json", options)
.expect("starts");
writer
.write_all(br#"{"scope":"G","version":1,"files":["view.json"]}"#)
.expect("writes");
writer
.start_file("ignition/resources/scratch", options)
.expect("starts");
writer.write_all(b"print('plain')").expect("writes");
writer.finish().expect("finalize").into_inner()
}
#[tokio::test]
async fn project_export_decoded_writes_the_tree() {
let rig = ProjectsRig {
export_body: Some(script_bearing_zip()),
..Default::default()
};
let dir = tempfile::tempdir().expect("tempdir");
let result = super::project_export_decoded(&rig, "p", Some(dir.path()))
.await
.expect("decode export");
assert_eq!(result.members, 4);
assert_eq!(result.scripts_decoded, 2);
assert_eq!(result.dir, dir.path().display().to_string());
assert!(
dir.path()
.join("c/resources/views/Dash/view.json.1.py")
.is_file()
);
assert!(
dir.path()
.join("c/resources/views/Dash/view.json.2.py")
.is_file()
);
assert!(
dir.path()
.join(crate::client::scripts_codec::MANIFEST_NAME)
.is_file()
);
assert_eq!(
std::fs::read(dir.path().join("ignition/resources/scratch")).expect("scratch member"),
b"print('plain')"
);
let json = serde_json::to_value(&result).expect("serialize");
let mut keys: Vec<&str> = json
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"bytes",
"dir",
"members",
"project",
"scope",
"scripts_decoded"
]
);
}
}