1use std::path::{Path, PathBuf};
28
29use serde::Serialize;
30
31use crate::client::GatewayApi;
32use crate::client::projects::{ProjectCreate, ProjectModify, ProjectRecord};
33use crate::client::query::ListQuery;
34use crate::error::CoreError;
35
36pub const EXPORT_INCLUDES: &[&str] = &[
41 "views",
42 "scripts",
43 "named-queries",
44 "vision-windows",
45 "perspective-themes-styles",
46 "reporting",
47 "alarm-notification-profiles",
48 "webdev-routes",
49 "translations",
50 "sfc-charts",
51];
52
53pub const EXPORT_EXCLUDES: &[&str] = &[
58 "tag-providers",
59 "tags",
60 "udts",
61 "gateway-config",
62 "database-connections",
63 "users-roles",
64 "alarm-journal",
65 "certificates",
66];
67
68#[derive(Debug, Clone, PartialEq, Serialize)]
72pub struct ExportScope {
73 pub includes: Vec<&'static str>,
75 pub excludes: Vec<&'static str>,
77}
78
79impl ExportScope {
80 pub fn new() -> Self {
82 Self {
83 includes: EXPORT_INCLUDES.to_vec(),
84 excludes: EXPORT_EXCLUDES.to_vec(),
85 }
86 }
87}
88
89impl Default for ExportScope {
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95pub const IMPORT_MAX_BYTES: usize = 512 * 1024 * 1024;
99
100const ZIP_MAGIC: [u8; 4] = [0x50, 0x4B, 0x03, 0x04];
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110pub enum CollisionPolicy {
111 Abort,
114 Overwrite,
118}
119
120impl CollisionPolicy {
121 pub fn label(self) -> &'static str {
123 match self {
124 Self::Abort => "abort",
125 Self::Overwrite => "overwrite",
126 }
127 }
128}
129
130fn import_size_error(len: usize) -> Option<CoreError> {
133 (len > IMPORT_MAX_BYTES).then(|| CoreError::InvalidImportFile {
134 reason: format!(
135 "{len} bytes exceeds the {} MB sanity limit",
136 IMPORT_MAX_BYTES / (1024 * 1024)
137 ),
138 })
139}
140
141fn validate_import(zip: &[u8]) -> Result<(), CoreError> {
145 if !zip.starts_with(&ZIP_MAGIC) {
146 return Err(CoreError::InvalidImportFile {
147 reason: "missing ZIP magic (PK\\x03\\x04) — not a project export archive".to_string(),
148 });
149 }
150 if let Some(err) = import_size_error(zip.len()) {
151 return Err(err);
152 }
153 let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip)).map_err(|err| {
162 CoreError::InvalidImportFile {
163 reason: format!("not a readable ZIP archive: {err}"),
164 }
165 })?;
166 for index in 0..archive.len() {
167 let mut file = archive
168 .by_index(index)
169 .map_err(|err| CoreError::InvalidImportFile {
170 reason: format!("cannot read import archive member {index}: {err}"),
171 })?;
172 let name = file.name().to_string();
173 let mut sink = Vec::new();
174 std::io::Read::read_to_end(&mut file, &mut sink).map_err(|err| {
175 CoreError::InvalidImportFile {
176 reason: format!("cannot decompress import member {name:?}: {err}"),
177 }
178 })?;
179 }
180 Ok(())
181}
182
183fn sanitize_basename(raw: &str) -> Option<String> {
188 let name = raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim();
189 if name.is_empty() || name == "." || name == ".." {
190 None
191 } else {
192 Some(name.to_string())
193 }
194}
195
196fn safe_fallback_stem(name: &str) -> String {
200 name.replace(['/', '\\'], "_")
201}
202
203#[derive(Debug, Clone, PartialEq, Serialize)]
205pub struct ProjectSummary {
206 pub name: String,
208 pub title: Option<String>,
210 pub description: Option<String>,
212 pub enabled: bool,
214 pub parent: Option<String>,
216 pub inheritable: Option<bool>,
219}
220
221impl ProjectSummary {
222 fn from_record(record: &ProjectRecord) -> Self {
224 Self {
225 name: record.name.clone(),
226 title: record.title.clone(),
227 description: record.description.clone(),
228 enabled: record.enabled,
229 parent: record.parent.clone(),
230 inheritable: record.inheritable,
231 }
232 }
233}
234
235#[derive(Debug, Serialize)]
237pub struct ProjectsResult {
238 pub projects: Vec<ProjectSummary>,
240}
241
242#[derive(Debug, Default, Clone)]
246pub struct NewOptions {
247 pub enabled: bool,
249 pub title: Option<String>,
251 pub description: Option<String>,
253 pub parent: Option<String>,
255 pub inheritable: Option<bool>,
257}
258
259#[derive(Debug, Default, Clone)]
262pub struct SetOptions {
263 pub title: Option<String>,
265 pub description: Option<String>,
267 pub parent: Option<String>,
269 pub enabled: Option<bool>,
271 pub inheritable: Option<bool>,
273}
274
275impl SetOptions {
276 fn fields_set(&self) -> Vec<String> {
279 let mut fields = Vec::new();
280 if self.title.is_some() {
281 fields.push("title".to_string());
282 }
283 if self.description.is_some() {
284 fields.push("description".to_string());
285 }
286 if self.parent.is_some() {
287 fields.push("parent".to_string());
288 }
289 if self.enabled.is_some() {
290 fields.push("enabled".to_string());
291 }
292 if self.inheritable.is_some() {
293 fields.push("inheritable".to_string());
294 }
295 fields
296 }
297}
298
299#[derive(Debug, Serialize)]
302pub struct ProjectCopyResult {
303 pub from: String,
305 #[serde(flatten)]
307 pub project: ProjectSummary,
308}
309
310#[derive(Debug, Serialize)]
313pub struct ProjectRenameResult {
314 pub previous_name: String,
316 #[serde(flatten)]
318 pub project: ProjectSummary,
319}
320
321#[derive(Debug, Serialize)]
325pub struct ProjectSetResult {
326 #[serde(skip)]
328 pub fields: Vec<String>,
329 #[serde(flatten)]
331 pub project: ProjectSummary,
332}
333
334#[derive(Debug, Serialize)]
336pub struct ProjectDeleteResult {
337 pub deleted: String,
339}
340
341#[derive(Debug, Serialize)]
344pub struct ExportResult {
345 pub project: String,
347 pub file: String,
350 pub bytes: u64,
352 pub scope: ExportScope,
354}
355
356#[derive(Debug, Serialize)]
360pub struct ImportResult {
361 pub name: String,
363 pub collision_policy: String,
365 pub bytes: usize,
367 pub scope: ExportScope,
370 pub outcome: serde_json::Value,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
387pub struct ProjectMetaDelta {
388 pub field: String,
390 pub a: String,
392 pub b: String,
394}
395
396#[derive(Debug, Serialize)]
403pub struct ProjectDiffResult {
404 pub scope: &'static str,
406 pub profile_a: String,
408 pub profile_b: String,
410 pub project: String,
412 pub project_meta: Vec<ProjectMetaDelta>,
415 pub summary: crate::client::resources::DiffSummary,
417 pub entries: Vec<crate::client::resources::MemberDiffEntry>,
419}
420
421pub async fn project_diff(
427 api_a: &dyn GatewayApi,
428 api_b: &dyn GatewayApi,
429 project: &str,
430 profile_a: &str,
431 profile_b: &str,
432) -> Result<ProjectDiffResult, CoreError> {
433 if profile_a == profile_b {
434 return Err(CoreError::InvalidInput {
435 reason: "diffing a profile against itself is a no-op — name two \
436 different profiles"
437 .to_string(),
438 });
439 }
440 let zip_a = crate::actions::resources::export_zip_bytes(api_a, project).await?;
441 let zip_b = crate::actions::resources::export_zip_bytes(api_b, project).await?;
442 let diff = crate::client::resources::diff_members(&zip_a, &zip_b)?;
443 let project_meta = crate::client::resources::project_meta_delta(&zip_a, &zip_b)?
444 .into_iter()
445 .map(|(field, a, b)| ProjectMetaDelta { field, a, b })
446 .collect();
447 Ok(ProjectDiffResult {
448 scope: "project",
449 profile_a: profile_a.to_string(),
450 profile_b: profile_b.to_string(),
451 project: project.to_string(),
452 project_meta,
453 summary: diff.summary,
454 entries: diff.entries,
455 })
456}
457
458#[derive(Debug, Default, Clone)]
462pub struct SyncSelection {
463 pub resources: Vec<String>,
466 pub all_changed: bool,
470}
471
472#[derive(Debug, Serialize)]
476pub struct ProjectSyncResult {
477 pub scope: &'static str,
479 pub profile_a: String,
481 pub profile_b: String,
483 pub project: String,
485 pub synced: Vec<String>,
487 pub removed: Vec<String>,
489}
490
491pub async fn project_sync(
503 api_a: &dyn GatewayApi,
504 api_b: &dyn GatewayApi,
505 project: &str,
506 selection: &SyncSelection,
507 delete: bool,
508 profile_a: &str,
509 profile_b: &str,
510) -> Result<ProjectSyncResult, CoreError> {
511 if selection.resources.is_empty() && !selection.all_changed {
512 return Err(CoreError::InvalidInput {
513 reason: "sync needs a selection — pass --resource PATH (repeatable) \
514 and/or --all-changed"
515 .to_string(),
516 });
517 }
518 let zip_a = crate::actions::resources::export_zip_bytes(api_a, project).await?;
519 let zip_b = crate::actions::resources::export_zip_bytes(api_b, project).await?;
520
521 let mut upserts: Vec<String> = Vec::new();
524 let mut removals: Vec<String> = Vec::new();
525 for path in &selection.resources {
526 match crate::client::resources::read_member(&zip_a, path) {
527 Ok(_) => upserts.push(path.clone()),
528 Err(CoreError::NotFound { .. }) if delete => removals.push(path.clone()),
532 Err(other) => return Err(other),
533 }
534 }
535 if selection.all_changed {
536 for entry in crate::client::resources::diff_members(&zip_a, &zip_b)?.entries {
545 match entry.status {
546 crate::client::resources::MemberStatus::Removed
547 | crate::client::resources::MemberStatus::Changed => {
548 upserts.push(entry.path);
549 }
550 crate::client::resources::MemberStatus::Added if delete => {
551 removals.push(entry.path);
552 }
553 _ => {}
554 }
555 }
556 }
557 upserts.sort();
558 upserts.dedup();
559 removals.sort();
560 removals.dedup();
561
562 let mut surgical = zip_b;
565 for path in &upserts {
566 let bytes = crate::client::resources::read_member(&zip_a, path)?;
567 surgical = crate::client::resources::replace_member(&surgical, path, &bytes)?;
568 }
569 for path in &removals {
570 surgical = crate::client::resources::remove_member(&surgical, path)?;
571 }
572
573 if !upserts.is_empty() || !removals.is_empty() {
578 validate_import(&surgical)?;
579 api_b.project_import(project, surgical, true).await?;
580 }
581 Ok(ProjectSyncResult {
582 scope: "project",
583 profile_a: profile_a.to_string(),
584 profile_b: profile_b.to_string(),
585 project: project.to_string(),
586 synced: upserts,
587 removed: removals,
588 })
589}
590
591pub async fn projects(api: &dyn GatewayApi) -> Result<ProjectsResult, CoreError> {
594 let page = api.projects(&ListQuery::default()).await?;
595 Ok(ProjectsResult {
596 projects: page.items.iter().map(ProjectSummary::from_record).collect(),
597 })
598}
599
600pub async fn project_new(
604 api: &dyn GatewayApi,
605 name: &str,
606 opts: &NewOptions,
607) -> Result<ProjectSummary, CoreError> {
608 let body = ProjectCreate {
609 name: name.to_string(),
610 enabled: opts.enabled,
611 title: opts.title.clone(),
612 description: opts.description.clone(),
613 parent: opts.parent.clone(),
614 inheritable: opts.inheritable,
615 default_db: None,
616 tag_provider: None,
617 user_source: None,
618 };
619 api.project_create(&body).await?;
620 let record = api.project_find(name).await?;
621 Ok(ProjectSummary::from_record(&record))
622}
623
624pub async fn project_copy(
626 api: &dyn GatewayApi,
627 from: &str,
628 to: &str,
629) -> Result<ProjectCopyResult, CoreError> {
630 api.project_copy(from, to).await?;
631 let record = api.project_find(to).await?;
632 Ok(ProjectCopyResult {
633 from: from.to_string(),
634 project: ProjectSummary::from_record(&record),
635 })
636}
637
638pub async fn project_rename(
640 api: &dyn GatewayApi,
641 old: &str,
642 new: &str,
643) -> Result<ProjectRenameResult, CoreError> {
644 api.project_rename(old, new).await?;
645 let record = api.project_find(new).await?;
646 Ok(ProjectRenameResult {
647 previous_name: old.to_string(),
648 project: ProjectSummary::from_record(&record),
649 })
650}
651
652pub async fn project_set(
656 api: &dyn GatewayApi,
657 name: &str,
658 opts: &SetOptions,
659) -> Result<ProjectSetResult, CoreError> {
660 let body = ProjectModify {
661 enabled: opts.enabled,
662 title: opts.title.clone(),
663 description: opts.description.clone(),
664 parent: opts.parent.clone(),
665 inheritable: opts.inheritable,
666 default_db: None,
667 tag_provider: None,
668 user_source: None,
669 };
670 api.project_modify(name, &body).await?;
671 let record = api.project_find(name).await?;
672 Ok(ProjectSetResult {
673 fields: opts.fields_set(),
674 project: ProjectSummary::from_record(&record),
675 })
676}
677
678pub async fn project_delete(
682 api: &dyn GatewayApi,
683 name: &str,
684) -> Result<ProjectDeleteResult, CoreError> {
685 api.project_delete(name).await?;
686 Ok(ProjectDeleteResult {
687 deleted: name.to_string(),
688 })
689}
690
691pub async fn project_export(
699 api: &dyn GatewayApi,
700 name: &str,
701 output: Option<&Path>,
702) -> Result<ExportResult, CoreError> {
703 let scope = ExportScope::new();
704 if let Some(out) = output {
705 let meta = api.project_export_to_file(name, out).await?;
706 return Ok(ExportResult {
707 project: name.to_string(),
708 file: out.display().to_string(),
709 bytes: meta.bytes,
710 scope,
711 });
712 }
713
714 let fallback = format!("{}.zip", safe_fallback_stem(name));
717 let part = PathBuf::from(format!("{fallback}.part"));
718 let meta = match api.project_export_to_file(name, &part).await {
719 Ok(meta) => meta,
720 Err(err) => {
721 let _ = std::fs::remove_file(&part); return Err(err);
723 }
724 };
725 let final_name = meta
726 .filename
727 .as_deref()
728 .and_then(sanitize_basename)
729 .unwrap_or(fallback);
730 if let Err(err) = std::fs::rename(&part, &final_name) {
731 let _ = std::fs::remove_file(&part); return Err(CoreError::Internal(format!(
733 "cannot finalize export {final_name}: {err}"
734 )));
735 }
736 Ok(ExportResult {
737 project: name.to_string(),
738 file: final_name,
739 bytes: meta.bytes,
740 scope,
741 })
742}
743
744pub async fn project_import(
751 api: &dyn GatewayApi,
752 name: &str,
753 zip: Vec<u8>,
754 policy: CollisionPolicy,
755) -> Result<ImportResult, CoreError> {
756 let bytes = zip.len();
757 let scope = ExportScope::new();
758 validate_import(&zip)?;
759 if matches!(policy, CollisionPolicy::Abort) && api.project_find(name).await.is_ok() {
760 return Err(CoreError::ProjectExists {
761 name: name.to_string(),
762 endpoint: None,
763 });
764 }
765 let overwrite = matches!(policy, CollisionPolicy::Overwrite);
766 let outcome = api.project_import(name, zip, overwrite).await?;
767 Ok(ImportResult {
768 name: name.to_string(),
769 collision_policy: policy.label().to_string(),
770 bytes,
771 scope,
772 outcome: outcome.response,
773 })
774}
775
776#[derive(Debug, Serialize)]
781pub struct ExportDecodedResult {
782 pub project: String,
784 pub dir: String,
786 pub members: usize,
788 pub scripts_decoded: usize,
790 pub bytes: u64,
792 pub scope: ExportScope,
794}
795
796pub async fn project_export_decoded(
805 api: &dyn GatewayApi,
806 name: &str,
807 out_dir: Option<&Path>,
808) -> Result<ExportDecodedResult, CoreError> {
809 let zip = crate::actions::resources::export_zip_bytes(api, name).await?;
810 let dir = match out_dir {
811 Some(dir) => dir.to_path_buf(),
812 None => PathBuf::from(format!("{}-export", safe_fallback_stem(name))),
813 };
814 let members = crate::client::scripts_codec::count_file_members(&zip)?;
815 let scripts_decoded = crate::client::scripts_codec::decode_export_tree(&zip, &dir)?;
816 Ok(ExportDecodedResult {
817 project: name.to_string(),
818 dir: dir.display().to_string(),
819 members,
820 scripts_decoded,
821 bytes: zip.len() as u64,
822 scope: ExportScope::new(),
823 })
824}
825
826#[cfg(test)]
827mod tests {
828 use super::{NewOptions, ProjectSummary, SetOptions, project_new, projects};
829 use crate::client::GatewayApi;
830 use crate::client::projects::{ProjectCreate, ProjectModify, ProjectRecord};
831 use crate::client::query::{ListEnvelope, ListMetadata};
832 use crate::error::CoreError;
833
834 use std::sync::Mutex;
835
836 #[derive(Default)]
844 struct ProjectsRig {
845 creates: Mutex<Vec<ProjectCreate>>,
846 modifies: Mutex<Vec<(String, ProjectModify)>>,
847 deletes: Mutex<Vec<String>>,
848 finds: Mutex<Vec<String>>,
849 exports: Mutex<Vec<String>>,
850 imports: Mutex<Vec<(String, usize, bool)>>,
851 absent: bool,
855 export_body: Option<Vec<u8>>,
858 }
859
860 impl ProjectsRig {
861 fn zip_fixture() -> Vec<u8> {
865 use std::io::Write as _;
866 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
867 let options = zip::write::SimpleFileOptions::default();
868 writer
869 .start_file("project.json", options)
870 .expect("fixture member starts");
871 writer
872 .write_all(br#"{"title":"fixture"}"#)
873 .expect("fixture member writes");
874 writer.finish().expect("fixture finalizes").into_inner()
875 }
876 }
877
878 fn record(name: &str) -> ProjectRecord {
879 ProjectRecord {
880 name: name.into(),
881 title: Some(format!("{name} title")),
882 description: None,
883 enabled: true,
884 parent: Some("Base".into()),
885 inheritable: Some(false),
886 default_db: None,
887 tag_provider: None,
888 user_source: None,
889 extra: Default::default(),
890 }
891 }
892
893 fn page(items: Vec<ProjectRecord>) -> ListEnvelope<ProjectRecord> {
894 let total = items.len() as i64;
895 ListEnvelope {
896 items,
897 metadata: ListMetadata {
898 total,
899 matching: total,
900 limit: -1,
901 offset: 0,
902 },
903 }
904 }
905
906 #[async_trait::async_trait]
907 impl GatewayApi for ProjectsRig {
908 async fn bundle_generate(
909 &self,
910 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
911 unreachable!("not part of this action")
912 }
913 async fn bundle_status(
914 &self,
915 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
916 unreachable!("not part of this action")
917 }
918 async fn bundle_download(
919 &self,
920 _out: &std::path::Path,
921 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
922 unreachable!("not part of this action")
923 }
924 async fn tag_provider_list(
925 &self,
926 _query: &crate::client::query::ListQuery,
927 ) -> Result<
928 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
929 CoreError,
930 > {
931 unreachable!("not part of this action")
932 }
933 async fn tag_provider_find(
934 &self,
935 _name: &str,
936 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
937 unreachable!("not part of this action")
938 }
939 async fn tag_provider_create(
940 &self,
941 _body: &[crate::client::tags::TagProviderCreate],
942 ) -> Result<(), CoreError> {
943 unreachable!("not part of this action")
944 }
945 async fn tag_provider_delete(
946 &self,
947 _name: &str,
948 _signature: &str,
949 ) -> Result<(), CoreError> {
950 unreachable!("not part of this action")
951 }
952 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
953 unreachable!("not part of this action")
954 }
955 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
956 unreachable!("not part of this action")
957 }
958 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
959 unreachable!("not part of this action")
960 }
961 async fn backup_download(
962 &self,
963 _out: &std::path::Path,
964 _backup_type: crate::client::backup::BackupType,
965 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
966 unreachable!("not part of this action")
967 }
968 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
969 unreachable!("not part of this action")
970 }
971 async fn eam_task_history(
972 &self,
973 _limit: Option<u32>,
974 _search: Option<&str>,
975 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
976 {
977 unreachable!("not part of this action")
978 }
979 async fn eam_task_definitions(
980 &self,
981 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
982 {
983 unreachable!("not part of this action")
984 }
985 async fn eam_task_find(
986 &self,
987 _name: &str,
988 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
989 unreachable!("not part of this action")
990 }
991 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
992 unreachable!("not part of this action")
993 }
994 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
995 unreachable!("not part of this action")
996 }
997 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
998 unreachable!("not part of this action")
999 }
1000 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
1001 unreachable!("not part of this action")
1002 }
1003 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
1004 unreachable!("not part of this action")
1005 }
1006 async fn eam_tasks_scheduled(
1007 &self,
1008 _running: bool,
1009 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
1010 unreachable!("not part of this action")
1011 }
1012 async fn eam_task_modify(
1013 &self,
1014 _definition: &serde_json::Value,
1015 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
1016 unreachable!("not part of this action")
1017 }
1018 async fn eam_task_delete(
1019 &self,
1020 _name: &str,
1021 _signature: &str,
1022 _confirm: bool,
1023 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
1024 unreachable!("not part of this action")
1025 }
1026 async fn api_call(
1027 &self,
1028 _call: &crate::client::apicall::ApiCallRequest,
1029 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
1030 unreachable!("not part of this action")
1031 }
1032 async fn license_status(
1033 &self,
1034 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
1035 unreachable!("not part of this action")
1036 }
1037 async fn redundancy_status(
1038 &self,
1039 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
1040 unreachable!("not part of this action")
1041 }
1042 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
1043 unreachable!("not part of this action")
1044 }
1045 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
1046 unreachable!("not part of this action")
1047 }
1048 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
1049 unreachable!("not part of this action")
1050 }
1051 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
1052 unreachable!("not part of this action")
1053 }
1054 async fn modules(
1055 &self,
1056 _quarantined: bool,
1057 _query: &crate::client::query::ListQuery,
1058 ) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
1059 unreachable!("not part of this action")
1060 }
1061 async fn metrics_current(
1062 &self,
1063 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
1064 unreachable!("not part of this action")
1065 }
1066 async fn metrics_historic(
1067 &self,
1068 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
1069 unreachable!("not part of this action")
1070 }
1071 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
1072 unreachable!("not part of this action")
1073 }
1074 async fn designers(
1075 &self,
1076 _query: &crate::client::query::ListQuery,
1077 ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
1078 unreachable!("not part of this action")
1079 }
1080 async fn perspective_sessions(
1081 &self,
1082 _query: &crate::client::query::ListQuery,
1083 ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
1084 unreachable!("not part of this action")
1085 }
1086 async fn vision_clients(
1087 &self,
1088 _query: &crate::client::query::ListQuery,
1089 ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
1090 unreachable!("not part of this action")
1091 }
1092 async fn terminate_perspective_session(
1093 &self,
1094 _id: &str,
1095 _message: Option<&str>,
1096 ) -> Result<(), CoreError> {
1097 unreachable!("not part of this action")
1098 }
1099 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
1100 unreachable!("not part of this action")
1101 }
1102 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
1103 unreachable!("not part of this action")
1104 }
1105 async fn database_connections(
1106 &self,
1107 ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1108 {
1109 unreachable!("not part of this action")
1110 }
1111 async fn opc_connections(
1112 &self,
1113 ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1114 {
1115 unreachable!("not part of this action")
1116 }
1117 async fn logs(
1118 &self,
1119 _filter: &crate::client::logs::LogQuery,
1120 ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
1121 unreachable!("not part of this action")
1122 }
1123 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
1124 unreachable!("not part of this action")
1125 }
1126 async fn loggers(
1127 &self,
1128 _query: &crate::client::query::ListQuery,
1129 ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
1130 unreachable!("not part of this action")
1131 }
1132 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
1133 unreachable!("not part of this action")
1134 }
1135 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
1136 unreachable!("not part of this action")
1137 }
1138 async fn restart(&self) -> Result<(), CoreError> {
1139 unreachable!("not part of this action")
1140 }
1141 async fn scan_projects(&self) -> Result<(), CoreError> {
1142 unreachable!("not part of this action")
1143 }
1144 async fn security_properties(
1145 &self,
1146 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
1147 unreachable!("not part of this action")
1148 }
1149 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
1150 unreachable!("not part of this action")
1151 }
1152 async fn webdev_route_call(
1153 &self,
1154 _project: &str,
1155 _route: &str,
1156 _body: &serde_json::Value,
1157 _extra_headers: &[(&str, &str)],
1158 ) -> Result<serde_json::Value, CoreError> {
1159 unreachable!("not part of this action")
1160 }
1161 async fn webdev_route_probe(
1162 &self,
1163 _project: &str,
1164 _route: &str,
1165 _extra_headers: &[(&str, &str)],
1166 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
1167 unreachable!("not part of this action")
1168 }
1169 async fn projects(
1170 &self,
1171 _query: &crate::client::query::ListQuery,
1172 ) -> Result<ListEnvelope<ProjectRecord>, CoreError> {
1173 Ok(page(vec![record("PlantFloor"), record("Base")]))
1174 }
1175 async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError> {
1176 self.finds.lock().unwrap().push(name.into());
1177 if self.absent {
1178 Err(CoreError::NotFound { endpoint: None })
1179 } else {
1180 Ok(record("whatever-the-rig-is-asked-for"))
1181 }
1182 }
1183 async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError> {
1184 self.creates.lock().unwrap().push(body.clone());
1185 Ok(())
1186 }
1187 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
1188 Ok(())
1189 }
1190 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
1191 Ok(())
1192 }
1193 async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError> {
1194 self.modifies
1195 .lock()
1196 .unwrap()
1197 .push((name.into(), body.clone()));
1198 Ok(())
1199 }
1200 async fn project_delete(&self, name: &str) -> Result<(), CoreError> {
1201 self.deletes.lock().unwrap().push(name.into());
1202 Ok(())
1203 }
1204 async fn project_export_to_file(
1205 &self,
1206 name: &str,
1207 out: &std::path::Path,
1208 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
1209 self.exports.lock().unwrap().push(name.into());
1210 let fixture = self.export_body.clone().unwrap_or_else(Self::zip_fixture);
1211 std::fs::write(out, &fixture)
1212 .map_err(|err| CoreError::Internal(format!("rig export write: {err}")))?;
1213 Ok(crate::client::projects::ExportMeta {
1214 filename: Some("rig-export.zip".into()),
1215 bytes: fixture.len() as u64,
1216 content_type: Some("application/zip".into()),
1217 })
1218 }
1219 async fn project_import(
1220 &self,
1221 name: &str,
1222 zip: Vec<u8>,
1223 overwrite: bool,
1224 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
1225 self.imports
1226 .lock()
1227 .unwrap()
1228 .push((name.into(), zip.len(), overwrite));
1229 Ok(crate::client::projects::ImportOutcome {
1230 response: serde_json::json!({"status": "success"}),
1231 })
1232 }
1233 }
1234
1235 #[test]
1239 fn set_options_only_title_serializes_exactly_title() {
1240 let opts = SetOptions {
1241 title: Some("T".into()),
1242 ..Default::default()
1243 };
1244 let body = ProjectModify {
1245 enabled: opts.enabled,
1246 title: opts.title.clone(),
1247 description: opts.description.clone(),
1248 parent: opts.parent.clone(),
1249 inheritable: opts.inheritable,
1250 default_db: None,
1251 tag_provider: None,
1252 user_source: None,
1253 };
1254 assert_eq!(
1255 serde_json::to_value(&body).expect("serializes"),
1256 serde_json::json!({"title": "T"})
1257 );
1258 }
1259
1260 #[tokio::test]
1263 async fn projects_action_selects_the_six_stable_fields() {
1264 let rig = ProjectsRig::default();
1265 let result = projects(&rig).await.expect("list");
1266 assert_eq!(result.projects.len(), 2);
1267 assert_eq!(
1268 result.projects[0],
1269 ProjectSummary {
1270 name: "PlantFloor".into(),
1271 title: Some("PlantFloor title".into()),
1272 description: None,
1273 enabled: true,
1274 parent: Some("Base".into()),
1275 inheritable: Some(false),
1276 }
1277 );
1278 let json = serde_json::to_value(&result).expect("serialize");
1280 let mut keys: Vec<&str> = json["projects"][0]
1281 .as_object()
1282 .unwrap()
1283 .keys()
1284 .map(String::as_str)
1285 .collect();
1286 keys.sort_unstable();
1287 assert_eq!(
1288 keys,
1289 [
1290 "description",
1291 "enabled",
1292 "inheritable",
1293 "name",
1294 "parent",
1295 "title"
1296 ]
1297 );
1298 }
1299
1300 #[tokio::test]
1303 async fn project_new_creates_then_reads_back() {
1304 let rig = ProjectsRig::default();
1305 let opts = NewOptions {
1306 enabled: true,
1307 title: Some("T".into()),
1308 description: None,
1309 parent: Some("Base".into()),
1310 inheritable: Some(true),
1311 };
1312 let summary = project_new(&rig, "child", &opts).await.expect("new");
1313 assert_eq!(summary.name, "whatever-the-rig-is-asked-for");
1314
1315 let creates = rig.creates.lock().unwrap();
1316 assert_eq!(creates.len(), 1);
1317 assert_eq!(
1318 serde_json::to_value(&creates[0]).unwrap(),
1319 serde_json::json!({
1320 "name": "child",
1321 "enabled": true,
1322 "title": "T",
1323 "parent": "Base",
1324 "inheritable": true
1325 })
1326 );
1327 }
1328
1329 #[tokio::test]
1333 async fn project_set_modifies_with_somes_and_reads_back() {
1334 let rig = ProjectsRig::default();
1335 let opts = SetOptions {
1336 title: Some("T".into()),
1337 parent: Some("Base".into()),
1338 ..Default::default()
1339 };
1340 let result = super::project_set(&rig, "x", &opts).await.expect("set");
1341 assert_eq!(result.fields, vec!["title", "parent"]);
1342
1343 let modifies = rig.modifies.lock().unwrap();
1344 assert_eq!(modifies.len(), 1);
1345 assert_eq!(modifies[0].0, "x");
1346 assert_eq!(
1347 serde_json::to_value(&modifies[0].1).unwrap(),
1348 serde_json::json!({"title": "T", "parent": "Base"})
1349 );
1350
1351 let json = serde_json::to_value(&result).expect("serialize");
1353 let keys: Vec<&str> = json
1354 .as_object()
1355 .unwrap()
1356 .keys()
1357 .map(String::as_str)
1358 .collect();
1359 assert_eq!(
1360 keys,
1361 [
1362 "description",
1363 "enabled",
1364 "inheritable",
1365 "name",
1366 "parent",
1367 "title"
1368 ],
1369 "no `fields` key in the agent shape"
1370 );
1371 }
1372
1373 #[tokio::test]
1375 async fn project_delete_records_the_name() {
1376 let rig = ProjectsRig::default();
1377 let result = super::project_delete(&rig, "gone").await.expect("delete");
1378 assert_eq!(result.deleted, "gone");
1379 assert_eq!(*rig.deletes.lock().unwrap(), vec!["gone".to_string()]);
1380 }
1381
1382 #[tokio::test]
1386 async fn import_refuses_non_zip_before_any_network() {
1387 let rig = ProjectsRig::default();
1388 let err = super::project_import(
1389 &rig,
1390 "x",
1391 b"definitely not a zip".to_vec(),
1392 super::CollisionPolicy::Abort,
1393 )
1394 .await
1395 .expect_err("the magic guard refuses");
1396 assert_eq!(
1397 err.exit_code(),
1398 2,
1399 "usage class — the caller must fix the file"
1400 );
1401 assert_eq!(err.code(), "invalid_import_file");
1402 assert!(
1403 rig.finds.lock().unwrap().is_empty(),
1404 "zero pre-check calls — the guard runs first"
1405 );
1406 assert!(rig.imports.lock().unwrap().is_empty(), "zero uploads");
1407 }
1408
1409 #[tokio::test]
1415 async fn import_refuses_truncated_zip_before_any_network() {
1416 let rig = ProjectsRig::default();
1417 let truncated = {
1418 let full = ProjectsRig::zip_fixture();
1419 let cut = full.len() - 10;
1423 full[..cut].to_vec()
1424 };
1425 let err = super::project_import(&rig, "x", truncated, super::CollisionPolicy::Overwrite)
1426 .await
1427 .expect_err("the structure guard refuses");
1428 assert_eq!(err.exit_code(), 2);
1429 assert_eq!(err.code(), "invalid_import_file");
1430 assert!(
1431 rig.finds.lock().unwrap().is_empty() && rig.imports.lock().unwrap().is_empty(),
1432 "zero network of any kind — the structure guard runs before everything"
1433 );
1434 }
1435
1436 #[test]
1440 fn import_size_guard_refuses_over_512mb() {
1441 let err = super::import_size_error(super::IMPORT_MAX_BYTES + 1)
1442 .expect("one byte over the limit refuses");
1443 assert_eq!(err.exit_code(), 2);
1444 assert_eq!(err.code(), "invalid_import_file");
1445 let message = err.to_string();
1446 assert!(
1447 message.contains("512 MB"),
1448 "the reason names the limit: {message}"
1449 );
1450 assert!(
1451 super::import_size_error(super::IMPORT_MAX_BYTES).is_none(),
1452 "exactly at the limit is fine"
1453 );
1454 }
1455
1456 #[tokio::test]
1461 async fn import_abort_over_existing_refuses_project_exists() {
1462 let rig = ProjectsRig::default(); let err = super::project_import(
1464 &rig,
1465 "PlantFloor",
1466 ProjectsRig::zip_fixture(),
1467 super::CollisionPolicy::Abort,
1468 )
1469 .await
1470 .expect_err("the collision pre-check refuses");
1471 assert!(
1472 matches!(&err, CoreError::ProjectExists { name, .. } if name == "PlantFloor"),
1473 "wrong class: {err}"
1474 );
1475 assert_eq!(err.exit_code(), 6);
1476 assert_eq!(err.code(), "project_exists");
1477 let hint = err.hint().expect("hint required");
1478 assert!(
1479 hint.contains("--collision-policy overwrite"),
1480 "hint names the flag: {hint}"
1481 );
1482 assert!(
1483 hint.contains("ENTIRE project") && hint.contains("Designer-only"),
1484 "hint warns replace-not-merge: {hint}"
1485 );
1486 assert!(
1487 rig.imports.lock().unwrap().is_empty(),
1488 "the refusal happened BEFORE any upload"
1489 );
1490 assert_eq!(*rig.finds.lock().unwrap(), vec!["PlantFloor".to_string()]);
1491 }
1492
1493 #[tokio::test]
1496 async fn import_abort_when_free_uploads_without_overwrite() {
1497 let rig = ProjectsRig {
1498 absent: true,
1499 ..Default::default()
1500 };
1501 let result = super::project_import(
1502 &rig,
1503 "fresh",
1504 ProjectsRig::zip_fixture(),
1505 super::CollisionPolicy::Abort,
1506 )
1507 .await
1508 .expect("free name imports");
1509 assert_eq!(result.name, "fresh");
1510 assert_eq!(result.collision_policy, "abort");
1511 assert_eq!(result.bytes, ProjectsRig::zip_fixture().len());
1512 assert_eq!(
1513 result.scope,
1514 super::ExportScope::new(),
1515 "import carries the SAME scope consts as export"
1516 );
1517 assert_eq!(
1518 *rig.imports.lock().unwrap(),
1519 vec![("fresh".to_string(), ProjectsRig::zip_fixture().len(), false)]
1520 );
1521 }
1522
1523 #[tokio::test]
1526 async fn import_overwrite_skips_pre_check_and_uploads() {
1527 let rig = ProjectsRig::default(); let result = super::project_import(
1529 &rig,
1530 "PlantFloor",
1531 ProjectsRig::zip_fixture(),
1532 super::CollisionPolicy::Overwrite,
1533 )
1534 .await
1535 .expect("overwrite imports without a pre-check");
1536 assert_eq!(result.collision_policy, "overwrite");
1537 assert!(
1538 rig.finds.lock().unwrap().is_empty(),
1539 "overwrite performs ZERO pre-check calls"
1540 );
1541 assert_eq!(
1542 *rig.imports.lock().unwrap(),
1543 vec![(
1544 "PlantFloor".to_string(),
1545 ProjectsRig::zip_fixture().len(),
1546 true
1547 )]
1548 );
1549 }
1550
1551 #[test]
1555 fn export_scope_arrays_are_data() {
1556 assert!(
1557 super::EXPORT_EXCLUDES.contains(&"tag-providers"),
1558 "the headline exclusion (tags are gateway config, not project export)"
1559 );
1560 assert!(super::EXPORT_EXCLUDES.contains(&"tags"));
1561 assert!(super::EXPORT_EXCLUDES.contains(&"udts"));
1562 assert!(super::EXPORT_INCLUDES.contains(&"views"));
1563 assert!(super::EXPORT_INCLUDES.contains(&"scripts"));
1564 assert!(super::EXPORT_INCLUDES.contains(&"named-queries"));
1565 let json = serde_json::to_value(super::ExportScope::new()).expect("scope serializes");
1566 assert_eq!(
1567 json["includes"]
1568 .as_array()
1569 .expect("includes is an array")
1570 .len(),
1571 super::EXPORT_INCLUDES.len()
1572 );
1573 assert_eq!(
1574 json["excludes"][0], "tag-providers",
1575 "declaration order is the agent-visible order"
1576 );
1577 }
1578
1579 #[tokio::test]
1582 async fn export_to_explicit_path_streams_and_reports() {
1583 let rig = ProjectsRig::default();
1584 let dir = tempfile::tempdir().expect("tempdir");
1585 let out = dir.path().join("proj.zip");
1586 let result = super::project_export(&rig, "My Proj", Some(&out))
1587 .await
1588 .expect("export");
1589 assert_eq!(result.project, "My Proj");
1590 assert_eq!(result.file, out.display().to_string());
1591 assert_eq!(result.bytes as usize, ProjectsRig::zip_fixture().len());
1592 assert_eq!(
1593 std::fs::read(&out).expect("file written"),
1594 ProjectsRig::zip_fixture(),
1595 "the fixture landed byte-for-byte"
1596 );
1597 assert_eq!(result.scope, super::ExportScope::new());
1598 assert_eq!(*rig.exports.lock().unwrap(), vec!["My Proj".to_string()]);
1599 }
1600
1601 #[test]
1605 fn sanitize_basename_strips_path_components() {
1606 assert_eq!(
1607 super::sanitize_basename("MyProj-export.zip"),
1608 Some("MyProj-export.zip".to_string())
1609 );
1610 assert_eq!(
1611 super::sanitize_basename("../../etc/passwd"),
1612 Some("passwd".to_string()),
1613 "path components never survive"
1614 );
1615 assert_eq!(
1616 super::sanitize_basename(r"..\..\win\evil.zip"),
1617 Some("evil.zip".to_string())
1618 );
1619 assert_eq!(super::sanitize_basename(".."), None);
1620 assert_eq!(super::sanitize_basename("."), None);
1621 assert_eq!(super::sanitize_basename(" "), None);
1622 assert_eq!(super::safe_fallback_stem("a/b\\c"), "a_b_c");
1623 }
1624
1625 #[tokio::test]
1629 async fn project_diff_same_profile_refuses_before_any_export() {
1630 let rig = ProjectsRig::default();
1631 let err = super::project_diff(&rig, &rig, "p", "dev", "dev")
1632 .await
1633 .expect_err("the same-profile refusal");
1634 assert_eq!(err.exit_code(), 2);
1635 assert_eq!(err.code(), "invalid_input");
1636 assert!(
1637 rig.exports.lock().unwrap().is_empty(),
1638 "zero exports — the refusal leads"
1639 );
1640 }
1641
1642 #[tokio::test]
1645 async fn project_sync_selection_less_refuses_before_any_export() {
1646 let rig = ProjectsRig::default();
1647 let err = super::project_sync(
1648 &rig,
1649 &rig,
1650 "p",
1651 &super::SyncSelection::default(),
1652 false,
1653 "a",
1654 "b",
1655 )
1656 .await
1657 .expect_err("the selection-less refusal");
1658 assert_eq!(err.exit_code(), 2);
1659 assert_eq!(err.code(), "invalid_input");
1660 assert!(rig.exports.lock().unwrap().is_empty());
1661 }
1662
1663 fn script_bearing_zip() -> Vec<u8> {
1670 use std::io::Write as _;
1671 let view = br#"{
1672 "scope": "G",
1673 "children": [
1674 {
1675 "type": "ia.display.label",
1676 "eventScripts": {
1677 "actionPerformed": {
1678 "config": {
1679 "script": "\tprint \u0027clicked\u0027\n\tprint \u0027done\u0027"
1680 }
1681 }
1682 }
1683 },
1684 {
1685 "type": "ia.chart",
1686 "transform": {
1687 "script": "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint \u0027end\u0027"
1688 },
1689 "props": {
1690 "expression": "toStr({view.args.x} * 2)"
1691 }
1692 }
1693 ]
1694}"#;
1695 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1696 let options = zip::write::SimpleFileOptions::default();
1697 writer.start_file("project.json", options).expect("starts");
1698 writer.write_all(br#"{"title":"T"}"#).expect("writes");
1699 writer
1700 .start_file("c/resources/views/Dash/view.json", options)
1701 .expect("starts");
1702 writer.write_all(view).expect("writes");
1703 writer
1704 .start_file("c/resources/views/Dash/resource.json", options)
1705 .expect("starts");
1706 writer
1707 .write_all(br#"{"scope":"G","version":1,"files":["view.json"]}"#)
1708 .expect("writes");
1709 writer
1710 .start_file("ignition/resources/scratch", options)
1711 .expect("starts");
1712 writer.write_all(b"print('plain')").expect("writes");
1713 writer.finish().expect("finalize").into_inner()
1714 }
1715
1716 #[tokio::test]
1720 async fn project_export_decoded_writes_the_tree() {
1721 let rig = ProjectsRig {
1722 export_body: Some(script_bearing_zip()),
1723 ..Default::default()
1724 };
1725 let dir = tempfile::tempdir().expect("tempdir");
1726 let result = super::project_export_decoded(&rig, "p", Some(dir.path()))
1727 .await
1728 .expect("decode export");
1729 assert_eq!(result.members, 4);
1730 assert_eq!(result.scripts_decoded, 2);
1731 assert_eq!(result.dir, dir.path().display().to_string());
1732 assert!(
1733 dir.path()
1734 .join("c/resources/views/Dash/view.json.1.py")
1735 .is_file()
1736 );
1737 assert!(
1738 dir.path()
1739 .join("c/resources/views/Dash/view.json.2.py")
1740 .is_file()
1741 );
1742 assert!(
1743 dir.path()
1744 .join(crate::client::scripts_codec::MANIFEST_NAME)
1745 .is_file()
1746 );
1747 assert_eq!(
1750 std::fs::read(dir.path().join("ignition/resources/scratch")).expect("scratch member"),
1751 b"print('plain')"
1752 );
1753 let json = serde_json::to_value(&result).expect("serialize");
1756 let mut keys: Vec<&str> = json
1757 .as_object()
1758 .unwrap()
1759 .keys()
1760 .map(String::as_str)
1761 .collect();
1762 keys.sort_unstable();
1763 assert_eq!(
1764 keys,
1765 [
1766 "bytes",
1767 "dir",
1768 "members",
1769 "project",
1770 "scope",
1771 "scripts_decoded"
1772 ]
1773 );
1774 }
1775}