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 uninstall_module(
909 &self,
910 _gateway_module_id: &str,
911 _module_id: &str,
912 ) -> Result<(), CoreError> {
913 unreachable!("not part of this action")
914 }
915 async fn bundle_generate(
916 &self,
917 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
918 unreachable!("not part of this action")
919 }
920 async fn bundle_status(
921 &self,
922 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
923 unreachable!("not part of this action")
924 }
925 async fn bundle_download(
926 &self,
927 _out: &std::path::Path,
928 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
929 unreachable!("not part of this action")
930 }
931 async fn tag_provider_list(
932 &self,
933 _query: &crate::client::query::ListQuery,
934 ) -> Result<
935 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
936 CoreError,
937 > {
938 unreachable!("not part of this action")
939 }
940 async fn tag_provider_find(
941 &self,
942 _name: &str,
943 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
944 unreachable!("not part of this action")
945 }
946 async fn tag_provider_create(
947 &self,
948 _body: &[crate::client::tags::TagProviderCreate],
949 ) -> Result<(), CoreError> {
950 unreachable!("not part of this action")
951 }
952 async fn tag_provider_delete(
953 &self,
954 _name: &str,
955 _signature: &str,
956 ) -> Result<(), CoreError> {
957 unreachable!("not part of this action")
958 }
959 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
960 unreachable!("not part of this action")
961 }
962 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
963 unreachable!("not part of this action")
964 }
965 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
966 unreachable!("not part of this action")
967 }
968 async fn backup_download(
969 &self,
970 _out: &std::path::Path,
971 _backup_type: crate::client::backup::BackupType,
972 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
973 unreachable!("not part of this action")
974 }
975 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
976 unreachable!("not part of this action")
977 }
978 async fn eam_task_history(
979 &self,
980 _limit: Option<u32>,
981 _search: Option<&str>,
982 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
983 {
984 unreachable!("not part of this action")
985 }
986 async fn eam_task_definitions(
987 &self,
988 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
989 {
990 unreachable!("not part of this action")
991 }
992 async fn eam_task_find(
993 &self,
994 _name: &str,
995 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
996 unreachable!("not part of this action")
997 }
998 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
999 unreachable!("not part of this action")
1000 }
1001 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
1002 unreachable!("not part of this action")
1003 }
1004 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
1005 unreachable!("not part of this action")
1006 }
1007 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
1008 unreachable!("not part of this action")
1009 }
1010 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
1011 unreachable!("not part of this action")
1012 }
1013 async fn eam_tasks_scheduled(
1014 &self,
1015 _running: bool,
1016 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
1017 unreachable!("not part of this action")
1018 }
1019 async fn eam_task_modify(
1020 &self,
1021 _definition: &serde_json::Value,
1022 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
1023 unreachable!("not part of this action")
1024 }
1025 async fn eam_task_delete(
1026 &self,
1027 _name: &str,
1028 _signature: &str,
1029 _confirm: bool,
1030 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
1031 unreachable!("not part of this action")
1032 }
1033 async fn api_call(
1034 &self,
1035 _call: &crate::client::apicall::ApiCallRequest,
1036 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
1037 unreachable!("not part of this action")
1038 }
1039 async fn license_status(
1040 &self,
1041 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
1042 unreachable!("not part of this action")
1043 }
1044 async fn redundancy_status(
1045 &self,
1046 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
1047 unreachable!("not part of this action")
1048 }
1049 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
1050 unreachable!("not part of this action")
1051 }
1052 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
1053 unreachable!("not part of this action")
1054 }
1055 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
1056 unreachable!("not part of this action")
1057 }
1058 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
1059 unreachable!("not part of this action")
1060 }
1061 async fn modules(
1062 &self,
1063 _quarantined: bool,
1064 _query: &crate::client::query::ListQuery,
1065 ) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
1066 unreachable!("not part of this action")
1067 }
1068 async fn metrics_current(
1069 &self,
1070 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
1071 unreachable!("not part of this action")
1072 }
1073 async fn metrics_historic(
1074 &self,
1075 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
1076 unreachable!("not part of this action")
1077 }
1078 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
1079 unreachable!("not part of this action")
1080 }
1081 async fn designers(
1082 &self,
1083 _query: &crate::client::query::ListQuery,
1084 ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
1085 unreachable!("not part of this action")
1086 }
1087 async fn perspective_sessions(
1088 &self,
1089 _query: &crate::client::query::ListQuery,
1090 ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
1091 unreachable!("not part of this action")
1092 }
1093 async fn vision_clients(
1094 &self,
1095 _query: &crate::client::query::ListQuery,
1096 ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
1097 unreachable!("not part of this action")
1098 }
1099 async fn terminate_perspective_session(
1100 &self,
1101 _id: &str,
1102 _message: Option<&str>,
1103 ) -> Result<(), CoreError> {
1104 unreachable!("not part of this action")
1105 }
1106 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
1107 unreachable!("not part of this action")
1108 }
1109 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
1110 unreachable!("not part of this action")
1111 }
1112 async fn database_connections(
1113 &self,
1114 ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1115 {
1116 unreachable!("not part of this action")
1117 }
1118 async fn opc_connections(
1119 &self,
1120 ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1121 {
1122 unreachable!("not part of this action")
1123 }
1124 async fn logs(
1125 &self,
1126 _filter: &crate::client::logs::LogQuery,
1127 ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
1128 unreachable!("not part of this action")
1129 }
1130 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
1131 unreachable!("not part of this action")
1132 }
1133 async fn loggers(
1134 &self,
1135 _query: &crate::client::query::ListQuery,
1136 ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
1137 unreachable!("not part of this action")
1138 }
1139 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
1140 unreachable!("not part of this action")
1141 }
1142 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
1143 unreachable!("not part of this action")
1144 }
1145 async fn restart(&self) -> Result<(), CoreError> {
1146 unreachable!("not part of this action")
1147 }
1148 async fn scan_projects(&self) -> Result<(), CoreError> {
1149 unreachable!("not part of this action")
1150 }
1151 async fn security_properties(
1152 &self,
1153 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
1154 unreachable!("not part of this action")
1155 }
1156 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
1157 unreachable!("not part of this action")
1158 }
1159 async fn webdev_route_call(
1160 &self,
1161 _project: &str,
1162 _route: &str,
1163 _body: &serde_json::Value,
1164 _extra_headers: &[(&str, &str)],
1165 ) -> Result<serde_json::Value, CoreError> {
1166 unreachable!("not part of this action")
1167 }
1168 async fn webdev_route_probe(
1169 &self,
1170 _project: &str,
1171 _route: &str,
1172 _extra_headers: &[(&str, &str)],
1173 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
1174 unreachable!("not part of this action")
1175 }
1176 async fn projects(
1177 &self,
1178 _query: &crate::client::query::ListQuery,
1179 ) -> Result<ListEnvelope<ProjectRecord>, CoreError> {
1180 Ok(page(vec![record("PlantFloor"), record("Base")]))
1181 }
1182 async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError> {
1183 self.finds.lock().unwrap().push(name.into());
1184 if self.absent {
1185 Err(CoreError::NotFound { endpoint: None })
1186 } else {
1187 Ok(record("whatever-the-rig-is-asked-for"))
1188 }
1189 }
1190 async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError> {
1191 self.creates.lock().unwrap().push(body.clone());
1192 Ok(())
1193 }
1194 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
1195 Ok(())
1196 }
1197 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
1198 Ok(())
1199 }
1200 async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError> {
1201 self.modifies
1202 .lock()
1203 .unwrap()
1204 .push((name.into(), body.clone()));
1205 Ok(())
1206 }
1207 async fn project_delete(&self, name: &str) -> Result<(), CoreError> {
1208 self.deletes.lock().unwrap().push(name.into());
1209 Ok(())
1210 }
1211 async fn project_export_to_file(
1212 &self,
1213 name: &str,
1214 out: &std::path::Path,
1215 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
1216 self.exports.lock().unwrap().push(name.into());
1217 let fixture = self.export_body.clone().unwrap_or_else(Self::zip_fixture);
1218 std::fs::write(out, &fixture)
1219 .map_err(|err| CoreError::Internal(format!("rig export write: {err}")))?;
1220 Ok(crate::client::projects::ExportMeta {
1221 filename: Some("rig-export.zip".into()),
1222 bytes: fixture.len() as u64,
1223 content_type: Some("application/zip".into()),
1224 })
1225 }
1226 async fn project_import(
1227 &self,
1228 name: &str,
1229 zip: Vec<u8>,
1230 overwrite: bool,
1231 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
1232 self.imports
1233 .lock()
1234 .unwrap()
1235 .push((name.into(), zip.len(), overwrite));
1236 Ok(crate::client::projects::ImportOutcome {
1237 response: serde_json::json!({"status": "success"}),
1238 })
1239 }
1240 }
1241
1242 #[test]
1246 fn set_options_only_title_serializes_exactly_title() {
1247 let opts = SetOptions {
1248 title: Some("T".into()),
1249 ..Default::default()
1250 };
1251 let body = ProjectModify {
1252 enabled: opts.enabled,
1253 title: opts.title.clone(),
1254 description: opts.description.clone(),
1255 parent: opts.parent.clone(),
1256 inheritable: opts.inheritable,
1257 default_db: None,
1258 tag_provider: None,
1259 user_source: None,
1260 };
1261 assert_eq!(
1262 serde_json::to_value(&body).expect("serializes"),
1263 serde_json::json!({"title": "T"})
1264 );
1265 }
1266
1267 #[tokio::test]
1270 async fn projects_action_selects_the_six_stable_fields() {
1271 let rig = ProjectsRig::default();
1272 let result = projects(&rig).await.expect("list");
1273 assert_eq!(result.projects.len(), 2);
1274 assert_eq!(
1275 result.projects[0],
1276 ProjectSummary {
1277 name: "PlantFloor".into(),
1278 title: Some("PlantFloor title".into()),
1279 description: None,
1280 enabled: true,
1281 parent: Some("Base".into()),
1282 inheritable: Some(false),
1283 }
1284 );
1285 let json = serde_json::to_value(&result).expect("serialize");
1287 let mut keys: Vec<&str> = json["projects"][0]
1288 .as_object()
1289 .unwrap()
1290 .keys()
1291 .map(String::as_str)
1292 .collect();
1293 keys.sort_unstable();
1294 assert_eq!(
1295 keys,
1296 [
1297 "description",
1298 "enabled",
1299 "inheritable",
1300 "name",
1301 "parent",
1302 "title"
1303 ]
1304 );
1305 }
1306
1307 #[tokio::test]
1310 async fn project_new_creates_then_reads_back() {
1311 let rig = ProjectsRig::default();
1312 let opts = NewOptions {
1313 enabled: true,
1314 title: Some("T".into()),
1315 description: None,
1316 parent: Some("Base".into()),
1317 inheritable: Some(true),
1318 };
1319 let summary = project_new(&rig, "child", &opts).await.expect("new");
1320 assert_eq!(summary.name, "whatever-the-rig-is-asked-for");
1321
1322 let creates = rig.creates.lock().unwrap();
1323 assert_eq!(creates.len(), 1);
1324 assert_eq!(
1325 serde_json::to_value(&creates[0]).unwrap(),
1326 serde_json::json!({
1327 "name": "child",
1328 "enabled": true,
1329 "title": "T",
1330 "parent": "Base",
1331 "inheritable": true
1332 })
1333 );
1334 }
1335
1336 #[tokio::test]
1340 async fn project_set_modifies_with_somes_and_reads_back() {
1341 let rig = ProjectsRig::default();
1342 let opts = SetOptions {
1343 title: Some("T".into()),
1344 parent: Some("Base".into()),
1345 ..Default::default()
1346 };
1347 let result = super::project_set(&rig, "x", &opts).await.expect("set");
1348 assert_eq!(result.fields, vec!["title", "parent"]);
1349
1350 let modifies = rig.modifies.lock().unwrap();
1351 assert_eq!(modifies.len(), 1);
1352 assert_eq!(modifies[0].0, "x");
1353 assert_eq!(
1354 serde_json::to_value(&modifies[0].1).unwrap(),
1355 serde_json::json!({"title": "T", "parent": "Base"})
1356 );
1357
1358 let json = serde_json::to_value(&result).expect("serialize");
1360 let keys: Vec<&str> = json
1361 .as_object()
1362 .unwrap()
1363 .keys()
1364 .map(String::as_str)
1365 .collect();
1366 assert_eq!(
1367 keys,
1368 [
1369 "description",
1370 "enabled",
1371 "inheritable",
1372 "name",
1373 "parent",
1374 "title"
1375 ],
1376 "no `fields` key in the agent shape"
1377 );
1378 }
1379
1380 #[tokio::test]
1382 async fn project_delete_records_the_name() {
1383 let rig = ProjectsRig::default();
1384 let result = super::project_delete(&rig, "gone").await.expect("delete");
1385 assert_eq!(result.deleted, "gone");
1386 assert_eq!(*rig.deletes.lock().unwrap(), vec!["gone".to_string()]);
1387 }
1388
1389 #[tokio::test]
1393 async fn import_refuses_non_zip_before_any_network() {
1394 let rig = ProjectsRig::default();
1395 let err = super::project_import(
1396 &rig,
1397 "x",
1398 b"definitely not a zip".to_vec(),
1399 super::CollisionPolicy::Abort,
1400 )
1401 .await
1402 .expect_err("the magic guard refuses");
1403 assert_eq!(
1404 err.exit_code(),
1405 2,
1406 "usage class — the caller must fix the file"
1407 );
1408 assert_eq!(err.code(), "invalid_import_file");
1409 assert!(
1410 rig.finds.lock().unwrap().is_empty(),
1411 "zero pre-check calls — the guard runs first"
1412 );
1413 assert!(rig.imports.lock().unwrap().is_empty(), "zero uploads");
1414 }
1415
1416 #[tokio::test]
1422 async fn import_refuses_truncated_zip_before_any_network() {
1423 let rig = ProjectsRig::default();
1424 let truncated = {
1425 let full = ProjectsRig::zip_fixture();
1426 let cut = full.len() - 10;
1430 full[..cut].to_vec()
1431 };
1432 let err = super::project_import(&rig, "x", truncated, super::CollisionPolicy::Overwrite)
1433 .await
1434 .expect_err("the structure guard refuses");
1435 assert_eq!(err.exit_code(), 2);
1436 assert_eq!(err.code(), "invalid_import_file");
1437 assert!(
1438 rig.finds.lock().unwrap().is_empty() && rig.imports.lock().unwrap().is_empty(),
1439 "zero network of any kind — the structure guard runs before everything"
1440 );
1441 }
1442
1443 #[test]
1447 fn import_size_guard_refuses_over_512mb() {
1448 let err = super::import_size_error(super::IMPORT_MAX_BYTES + 1)
1449 .expect("one byte over the limit refuses");
1450 assert_eq!(err.exit_code(), 2);
1451 assert_eq!(err.code(), "invalid_import_file");
1452 let message = err.to_string();
1453 assert!(
1454 message.contains("512 MB"),
1455 "the reason names the limit: {message}"
1456 );
1457 assert!(
1458 super::import_size_error(super::IMPORT_MAX_BYTES).is_none(),
1459 "exactly at the limit is fine"
1460 );
1461 }
1462
1463 #[tokio::test]
1468 async fn import_abort_over_existing_refuses_project_exists() {
1469 let rig = ProjectsRig::default(); let err = super::project_import(
1471 &rig,
1472 "PlantFloor",
1473 ProjectsRig::zip_fixture(),
1474 super::CollisionPolicy::Abort,
1475 )
1476 .await
1477 .expect_err("the collision pre-check refuses");
1478 assert!(
1479 matches!(&err, CoreError::ProjectExists { name, .. } if name == "PlantFloor"),
1480 "wrong class: {err}"
1481 );
1482 assert_eq!(err.exit_code(), 6);
1483 assert_eq!(err.code(), "project_exists");
1484 let hint = err.hint().expect("hint required");
1485 assert!(
1486 hint.contains("--collision-policy overwrite"),
1487 "hint names the flag: {hint}"
1488 );
1489 assert!(
1490 hint.contains("ENTIRE project") && hint.contains("Designer-only"),
1491 "hint warns replace-not-merge: {hint}"
1492 );
1493 assert!(
1494 rig.imports.lock().unwrap().is_empty(),
1495 "the refusal happened BEFORE any upload"
1496 );
1497 assert_eq!(*rig.finds.lock().unwrap(), vec!["PlantFloor".to_string()]);
1498 }
1499
1500 #[tokio::test]
1503 async fn import_abort_when_free_uploads_without_overwrite() {
1504 let rig = ProjectsRig {
1505 absent: true,
1506 ..Default::default()
1507 };
1508 let result = super::project_import(
1509 &rig,
1510 "fresh",
1511 ProjectsRig::zip_fixture(),
1512 super::CollisionPolicy::Abort,
1513 )
1514 .await
1515 .expect("free name imports");
1516 assert_eq!(result.name, "fresh");
1517 assert_eq!(result.collision_policy, "abort");
1518 assert_eq!(result.bytes, ProjectsRig::zip_fixture().len());
1519 assert_eq!(
1520 result.scope,
1521 super::ExportScope::new(),
1522 "import carries the SAME scope consts as export"
1523 );
1524 assert_eq!(
1525 *rig.imports.lock().unwrap(),
1526 vec![("fresh".to_string(), ProjectsRig::zip_fixture().len(), false)]
1527 );
1528 }
1529
1530 #[tokio::test]
1533 async fn import_overwrite_skips_pre_check_and_uploads() {
1534 let rig = ProjectsRig::default(); let result = super::project_import(
1536 &rig,
1537 "PlantFloor",
1538 ProjectsRig::zip_fixture(),
1539 super::CollisionPolicy::Overwrite,
1540 )
1541 .await
1542 .expect("overwrite imports without a pre-check");
1543 assert_eq!(result.collision_policy, "overwrite");
1544 assert!(
1545 rig.finds.lock().unwrap().is_empty(),
1546 "overwrite performs ZERO pre-check calls"
1547 );
1548 assert_eq!(
1549 *rig.imports.lock().unwrap(),
1550 vec![(
1551 "PlantFloor".to_string(),
1552 ProjectsRig::zip_fixture().len(),
1553 true
1554 )]
1555 );
1556 }
1557
1558 #[test]
1562 fn export_scope_arrays_are_data() {
1563 assert!(
1564 super::EXPORT_EXCLUDES.contains(&"tag-providers"),
1565 "the headline exclusion (tags are gateway config, not project export)"
1566 );
1567 assert!(super::EXPORT_EXCLUDES.contains(&"tags"));
1568 assert!(super::EXPORT_EXCLUDES.contains(&"udts"));
1569 assert!(super::EXPORT_INCLUDES.contains(&"views"));
1570 assert!(super::EXPORT_INCLUDES.contains(&"scripts"));
1571 assert!(super::EXPORT_INCLUDES.contains(&"named-queries"));
1572 let json = serde_json::to_value(super::ExportScope::new()).expect("scope serializes");
1573 assert_eq!(
1574 json["includes"]
1575 .as_array()
1576 .expect("includes is an array")
1577 .len(),
1578 super::EXPORT_INCLUDES.len()
1579 );
1580 assert_eq!(
1581 json["excludes"][0], "tag-providers",
1582 "declaration order is the agent-visible order"
1583 );
1584 }
1585
1586 #[tokio::test]
1589 async fn export_to_explicit_path_streams_and_reports() {
1590 let rig = ProjectsRig::default();
1591 let dir = tempfile::tempdir().expect("tempdir");
1592 let out = dir.path().join("proj.zip");
1593 let result = super::project_export(&rig, "My Proj", Some(&out))
1594 .await
1595 .expect("export");
1596 assert_eq!(result.project, "My Proj");
1597 assert_eq!(result.file, out.display().to_string());
1598 assert_eq!(result.bytes as usize, ProjectsRig::zip_fixture().len());
1599 assert_eq!(
1600 std::fs::read(&out).expect("file written"),
1601 ProjectsRig::zip_fixture(),
1602 "the fixture landed byte-for-byte"
1603 );
1604 assert_eq!(result.scope, super::ExportScope::new());
1605 assert_eq!(*rig.exports.lock().unwrap(), vec!["My Proj".to_string()]);
1606 }
1607
1608 #[test]
1612 fn sanitize_basename_strips_path_components() {
1613 assert_eq!(
1614 super::sanitize_basename("MyProj-export.zip"),
1615 Some("MyProj-export.zip".to_string())
1616 );
1617 assert_eq!(
1618 super::sanitize_basename("../../etc/passwd"),
1619 Some("passwd".to_string()),
1620 "path components never survive"
1621 );
1622 assert_eq!(
1623 super::sanitize_basename(r"..\..\win\evil.zip"),
1624 Some("evil.zip".to_string())
1625 );
1626 assert_eq!(super::sanitize_basename(".."), None);
1627 assert_eq!(super::sanitize_basename("."), None);
1628 assert_eq!(super::sanitize_basename(" "), None);
1629 assert_eq!(super::safe_fallback_stem("a/b\\c"), "a_b_c");
1630 }
1631
1632 #[tokio::test]
1636 async fn project_diff_same_profile_refuses_before_any_export() {
1637 let rig = ProjectsRig::default();
1638 let err = super::project_diff(&rig, &rig, "p", "dev", "dev")
1639 .await
1640 .expect_err("the same-profile refusal");
1641 assert_eq!(err.exit_code(), 2);
1642 assert_eq!(err.code(), "invalid_input");
1643 assert!(
1644 rig.exports.lock().unwrap().is_empty(),
1645 "zero exports — the refusal leads"
1646 );
1647 }
1648
1649 #[tokio::test]
1652 async fn project_sync_selection_less_refuses_before_any_export() {
1653 let rig = ProjectsRig::default();
1654 let err = super::project_sync(
1655 &rig,
1656 &rig,
1657 "p",
1658 &super::SyncSelection::default(),
1659 false,
1660 "a",
1661 "b",
1662 )
1663 .await
1664 .expect_err("the selection-less refusal");
1665 assert_eq!(err.exit_code(), 2);
1666 assert_eq!(err.code(), "invalid_input");
1667 assert!(rig.exports.lock().unwrap().is_empty());
1668 }
1669
1670 fn script_bearing_zip() -> Vec<u8> {
1677 use std::io::Write as _;
1678 let view = br#"{
1679 "scope": "G",
1680 "children": [
1681 {
1682 "type": "ia.display.label",
1683 "eventScripts": {
1684 "actionPerformed": {
1685 "config": {
1686 "script": "\tprint \u0027clicked\u0027\n\tprint \u0027done\u0027"
1687 }
1688 }
1689 }
1690 },
1691 {
1692 "type": "ia.chart",
1693 "transform": {
1694 "script": "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint \u0027end\u0027"
1695 },
1696 "props": {
1697 "expression": "toStr({view.args.x} * 2)"
1698 }
1699 }
1700 ]
1701}"#;
1702 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1703 let options = zip::write::SimpleFileOptions::default();
1704 writer.start_file("project.json", options).expect("starts");
1705 writer.write_all(br#"{"title":"T"}"#).expect("writes");
1706 writer
1707 .start_file("c/resources/views/Dash/view.json", options)
1708 .expect("starts");
1709 writer.write_all(view).expect("writes");
1710 writer
1711 .start_file("c/resources/views/Dash/resource.json", options)
1712 .expect("starts");
1713 writer
1714 .write_all(br#"{"scope":"G","version":1,"files":["view.json"]}"#)
1715 .expect("writes");
1716 writer
1717 .start_file("ignition/resources/scratch", options)
1718 .expect("starts");
1719 writer.write_all(b"print('plain')").expect("writes");
1720 writer.finish().expect("finalize").into_inner()
1721 }
1722
1723 #[tokio::test]
1727 async fn project_export_decoded_writes_the_tree() {
1728 let rig = ProjectsRig {
1729 export_body: Some(script_bearing_zip()),
1730 ..Default::default()
1731 };
1732 let dir = tempfile::tempdir().expect("tempdir");
1733 let result = super::project_export_decoded(&rig, "p", Some(dir.path()))
1734 .await
1735 .expect("decode export");
1736 assert_eq!(result.members, 4);
1737 assert_eq!(result.scripts_decoded, 2);
1738 assert_eq!(result.dir, dir.path().display().to_string());
1739 assert!(
1740 dir.path()
1741 .join("c/resources/views/Dash/view.json.1.py")
1742 .is_file()
1743 );
1744 assert!(
1745 dir.path()
1746 .join("c/resources/views/Dash/view.json.2.py")
1747 .is_file()
1748 );
1749 assert!(
1750 dir.path()
1751 .join(crate::client::scripts_codec::MANIFEST_NAME)
1752 .is_file()
1753 );
1754 assert_eq!(
1757 std::fs::read(dir.path().join("ignition/resources/scratch")).expect("scratch member"),
1758 b"print('plain')"
1759 );
1760 let json = serde_json::to_value(&result).expect("serialize");
1763 let mut keys: Vec<&str> = json
1764 .as_object()
1765 .unwrap()
1766 .keys()
1767 .map(String::as_str)
1768 .collect();
1769 keys.sort_unstable();
1770 assert_eq!(
1771 keys,
1772 [
1773 "bytes",
1774 "dir",
1775 "members",
1776 "project",
1777 "scope",
1778 "scripts_decoded"
1779 ]
1780 );
1781 }
1782}