use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::client::projects::encode_segment;
pub(crate) const EAM_BASE: &str = "/data/eam/api/v1";
pub(crate) const EAM_HISTORY_PATH: &str = "/data/eam/api/v1/eam-tasks/history";
pub(crate) const EAM_HISTORY_DEFAULT_LIMIT: i64 = 200;
pub(crate) fn eam_force_path(owner: &str, name: &str) -> String {
format!("{EAM_BASE}/eam-tasks/force/{owner}/{name}")
}
pub(crate) const EAM_TASKS_RESOURCE: &str = "com.inductiveautomation.eam/eam-tasks";
pub(crate) fn eam_tasks_list_path() -> String {
format!("/data/api/v1/resources/list/{EAM_TASKS_RESOURCE}")
}
pub(crate) fn eam_task_find_path(name: &str) -> String {
format!(
"/data/api/v1/resources/find/{EAM_TASKS_RESOURCE}/{}",
encode_segment(name)
)
}
pub(crate) fn eam_tasks_create_path() -> String {
format!("/data/api/v1/resources/{EAM_TASKS_RESOURCE}")
}
pub(crate) fn eam_tasks_modify_path() -> String {
eam_tasks_create_path()
}
pub(crate) fn eam_task_delete_path(name: &str, signature: &str) -> String {
format!(
"/data/api/v1/resources/{EAM_TASKS_RESOURCE}/{}/{}",
encode_segment(name),
encode_segment(signature)
)
}
pub(crate) fn eam_task_suspend_path(name: &str) -> String {
format!("{EAM_BASE}/eam-tasks/suspend/{name}")
}
pub(crate) fn eam_task_resume_path(name: &str) -> String {
format!("{EAM_BASE}/eam-tasks/resume/{name}")
}
pub(crate) fn eam_task_cancel_path(name: &str) -> String {
format!("{EAM_BASE}/eam-tasks/cancel/{name}")
}
pub(crate) fn eam_tasks_scheduled_path(running: bool) -> String {
format!("{EAM_BASE}/eam-tasks/scheduled/{running}")
}
pub const EAM_TASK_STATES: &[&str] = &[
"Scheduled",
"Suspended",
];
pub const EAM_CURRENT_STATES: &[&str] = &[
"Stopped",
"Errored",
"Suspended",
];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EamHistoryItem {
#[serde(rename = "taskId", default)]
pub task_id: String,
#[serde(rename = "taskName", default)]
pub task_name: String,
#[serde(rename = "taskStart", default)]
pub task_start: i64,
#[serde(rename = "taskEnd", default)]
pub task_end: Option<i64>,
#[serde(rename = "target", default)]
pub target: Option<String>,
#[serde(rename = "level", default)]
pub level: Option<String>,
#[serde(rename = "detail", default)]
pub detail: Option<String>,
#[serde(rename = "taskType", default)]
pub task_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EamTaskRecord {
#[serde(default)]
pub name: String,
#[serde(default)]
pub config: serde_json::Value,
#[serde(default)]
pub signature: Option<String>,
#[serde(rename = "scheduledTaskState", default)]
pub scheduled_task_state: Option<serde_json::Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EamScheduledTask {
#[serde(default)]
pub name: String,
#[serde(default)]
pub owner: String,
#[serde(rename = "type", default)]
pub task_type: Option<String>,
#[serde(rename = "execStart", default)]
pub exec_start: Option<i64>,
#[serde(default)]
pub message: String,
#[serde(default)]
pub repeats: bool,
#[serde(rename = "canPause", default)]
pub can_pause: bool,
#[serde(rename = "canResume", default)]
pub can_resume: bool,
#[serde(rename = "canCancel", default)]
pub can_cancel: bool,
#[serde(rename = "taskState", default)]
pub task_state: String,
#[serde(rename = "isForced", default)]
pub is_forced: bool,
#[serde(rename = "isRunning", default)]
pub is_running: bool,
#[serde(default)]
pub progress: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResourceChange {
#[serde(default)]
pub name: String,
#[serde(rename = "type", default)]
pub resource_type: String,
#[serde(default)]
pub collection: String,
#[serde(rename = "newSignature", default)]
pub new_signature: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MutationProblem {
#[serde(default)]
pub message: String,
#[serde(default)]
pub stacktrace: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModifyOutcome {
#[serde(default)]
pub success: bool,
#[serde(default)]
pub changes: Vec<ResourceChange>,
#[serde(default)]
pub problem: Option<MutationProblem>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DeleteOutcome {
#[serde(default)]
pub success: bool,
#[serde(default)]
pub changes: Vec<ResourceChange>,
#[serde(default)]
pub problem: Option<MutationProblem>,
#[serde(default)]
pub references: Option<Vec<serde_json::Value>>,
}
#[cfg(test)]
mod tests {
use super::{EamHistoryItem, EamTaskRecord};
#[test]
fn history_item_parses_the_live_shape() {
let item: EamHistoryItem = serde_json::from_value(serde_json::json!({
"taskId": "a2f4dab1-9a8f-4feb-9306-29e261f60453",
"taskName": "nightly-backup (forced)",
"taskStart": 1787930000000_i64,
"taskEnd": 1787930009000_i64,
"target": "_controller",
"level": "Failed",
"detail": "Gateway network for agent '_controller' is currently not connected",
"taskType": "eam_backup"
}))
.expect("live-captured shape parses");
assert_eq!(item.task_id, "a2f4dab1-9a8f-4feb-9306-29e261f60453");
assert_eq!(item.task_name, "nightly-backup (forced)");
assert_eq!(item.level.as_deref(), Some("Failed"));
assert!(
item.detail
.as_deref()
.is_some_and(|d| d.contains("not connected"))
);
let running: EamHistoryItem = serde_json::from_value(serde_json::json!({
"taskId": "b3c5ebc2-0b90-40fc-8417-3af372071546",
"taskName": "nightly-backup",
"taskStart": 1787930000000_i64
}))
.expect("sparse shape parses (tolerant defaults)");
assert_eq!(running.task_end, None);
assert_eq!(running.detail, None);
}
#[test]
fn task_record_parses_list_and_find_shapes() {
let listed: EamTaskRecord = serde_json::from_value(serde_json::json!({
"name": "nightly-backup",
"config": {
"profile": {
"type": "eam_backup",
"scheduleMode": "OnDemand",
"settings": {"targetGateways": [], "targetGroups": [], "concurrentBackups": 0, "forceBackups": false}
}
},
"collection": "eam-tasks",
"type": "com.inductiveautomation.eam"
}))
.expect("list shape parses");
assert_eq!(
listed.config["profile"]["type"],
serde_json::json!("eam_backup")
);
assert_eq!(listed.signature, None, "list records carry no signature");
assert_eq!(
listed.extra.get("collection"),
Some(&serde_json::json!("eam-tasks")),
"resource keys round-trip"
);
let found: EamTaskRecord = serde_json::from_value(serde_json::json!({
"name": "nightly-backup",
"config": {"profile": {"type": "eam_backup", "scheduleMode": "OnDemand"}},
"signature": "abc123",
"scheduledTaskState": {
"currentState": "IDLE",
"details": {"owner": "eam", "nextScheduled": None::<String>}
}
}))
.expect("find shape parses");
assert_eq!(found.signature.as_deref(), Some("abc123"));
let state = found.scheduled_task_state.expect("state present");
assert_eq!(state["currentState"], serde_json::json!("IDLE"));
assert_eq!(state["details"]["owner"], serde_json::json!("eam"));
}
#[test]
fn force_path_is_the_module_scoped_shape() {
assert_eq!(
super::eam_force_path("eam", "nightly-backup"),
"/data/eam/api/v1/eam-tasks/force/eam/nightly-backup"
);
}
#[test]
fn lifecycle_paths_are_the_captured_shapes() {
assert_eq!(
super::eam_task_suspend_path("nightly-backup"),
"/data/eam/api/v1/eam-tasks/suspend/nightly-backup"
);
assert_eq!(
super::eam_task_resume_path("nightly-backup"),
"/data/eam/api/v1/eam-tasks/resume/nightly-backup"
);
assert_eq!(
super::eam_task_cancel_path("nightly-backup"),
"/data/eam/api/v1/eam-tasks/cancel/nightly-backup"
);
}
#[test]
fn scheduled_path_takes_the_literal_bool_word() {
assert_eq!(
super::eam_tasks_scheduled_path(true),
"/data/eam/api/v1/eam-tasks/scheduled/true"
);
assert_eq!(
super::eam_tasks_scheduled_path(false),
"/data/eam/api/v1/eam-tasks/scheduled/false"
);
}
#[test]
fn mutation_paths_are_the_config_resource_shapes() {
assert_eq!(
super::eam_tasks_modify_path(),
"/data/api/v1/resources/com.inductiveautomation.eam/eam-tasks"
);
assert_eq!(
super::eam_tasks_create_path(),
super::eam_tasks_modify_path(),
"modify and create share ONE resource path"
);
assert_eq!(
super::eam_task_delete_path("nightly-backup", "sig-abc123"),
"/data/api/v1/resources/com.inductiveautomation.eam/eam-tasks/nightly%2Dbackup/sig%2Dabc123"
);
}
#[test]
fn captured_vocabularies_are_the_string_const_sets() {
assert_eq!(super::EAM_TASK_STATES, &["Scheduled", "Suspended"]);
assert_eq!(
super::EAM_CURRENT_STATES,
&["Stopped", "Errored", "Suspended"]
);
}
#[test]
fn scheduled_task_parses_the_verbatim_captured_row() {
let captured: super::EamScheduledTask = serde_json::from_value(serde_json::json!({
"name": "ign-p10-scratch-sched",
"owner": "eam",
"type": "Collect Backup",
"execStart": null,
"message": "",
"repeats": true,
"canPause": true,
"canResume": false,
"canCancel": true,
"taskState": "Scheduled",
"isForced": false,
"isRunning": false,
"progress": 0.0
}))
.expect("the captured row parses");
assert_eq!(captured.name, "ign-p10-scratch-sched");
assert_eq!(captured.owner, "eam");
assert_eq!(
captured.task_type.as_deref(),
Some("Collect Backup"),
"the human label rides verbatim — never conflated with profile.type"
);
assert_eq!(captured.exec_start, None, "execStart null while scheduled");
assert_eq!(captured.message, "");
assert!(captured.repeats);
assert!(captured.can_pause && !captured.can_resume && captured.can_cancel);
assert_eq!(captured.task_state, "Scheduled");
assert!(!captured.is_forced && !captured.is_running);
assert_eq!(captured.progress, 0.0);
let running: super::EamScheduledTask = serde_json::from_value(serde_json::json!({
"name": "t", "owner": "eam", "type": "Collect Backup",
"execStart": 1788947896010_i64, "message": "executing",
"repeats": false, "canPause": true, "canResume": true,
"canCancel": true, "taskState": "Running",
"isForced": true, "isRunning": true, "progress": 0.5
}))
.expect("unobserved Running/Pending rows MUST parse (spec-shaped cells)");
assert_eq!(running.task_state, "Running");
assert_eq!(running.progress, 0.5);
}
#[test]
fn modify_outcome_parses_the_verbatim_captured_body() {
let outcome: super::ModifyOutcome = serde_json::from_value(serde_json::json!({
"success": true,
"changes": [
{
"name": "ign-p10-scratch-sched",
"type": "com.inductiveautomation.eam/eam-tasks",
"collection": "core",
"newSignature": "0d0dfea2919abb1f02fc86baea73d99696626524169a9ac36526044f89ac16e0"
}
],
"problem": null
}))
.expect("the captured modify body parses");
assert!(outcome.success);
assert_eq!(outcome.changes.len(), 1);
let change = &outcome.changes[0];
assert_eq!(change.name, "ign-p10-scratch-sched");
assert_eq!(
change.resource_type,
"com.inductiveautomation.eam/eam-tasks"
);
assert_eq!(change.collection, "core");
assert_eq!(
change.new_signature.as_deref(),
Some("0d0dfea2919abb1f02fc86baea73d99696626524169a9ac36526044f89ac16e0")
);
assert_eq!(outcome.problem, None, "problem null on every success");
}
#[test]
fn delete_outcome_parses_success_and_mismatch_shapes() {
let success: super::DeleteOutcome = serde_json::from_value(serde_json::json!({
"success": true,
"changes": [
{
"name": "ign-p10-scratch-sched",
"type": "com.inductiveautomation.eam/eam-tasks",
"collection": "core",
"newSignature": "ec961ee921c63b18013094870ed2664331e965c4770fdf84bfe136e0b4164244"
}
],
"problem": null,
"references": []
}))
.expect("the captured delete body parses");
assert!(success.success);
assert_eq!(
success.references,
Some(Vec::new()),
"references [] on success, honestly empty"
);
let mismatch: super::DeleteOutcome = serde_json::from_value(serde_json::json!({
"success": false,
"changes": [],
"problem": {
"message": "DELETE illegal: signature mismatch for 'ResourceId{resourcePath=com.inductiveautomation.eam/eam-tasks/ign-p10-scratch-sched, collectionName=core}'",
"stacktrace": [
"com.inductiveautomation.ignition.common.resourcecollection.PushException: DELETE illegal: signature mismatch for …",
"\tat com.inductiveautomation.ignition.gateway.resourcecollection.ChangeOperationValidationHandler$AtomicPushValidationHandler.throwIfInvalid(ChangeOperationValidationHandler.java:61)"
]
},
"references": null
}))
.expect("the mismatch body parses");
assert!(!mismatch.success);
assert_eq!(mismatch.changes, Vec::<super::ResourceChange>::new());
let problem = mismatch.problem.expect("the problem rides");
assert!(
problem.message.contains("signature mismatch"),
"the stable substring survives drift"
);
assert_eq!(problem.stacktrace.len(), 2);
assert_eq!(
mismatch.references, None,
"references null on the failure shape"
);
}
}