1use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6
7pub const PROTOCOL_VERSION: u32 = 1;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum ToAgent {
14 Hello {
15 protocol: u32,
16 },
17 RunBatch {
19 id: u64,
20 tasks: Vec<Task>,
21 },
22 Cancel {
23 id: u64,
24 },
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum FromAgent {
31 Ready {
32 protocol: u32,
33 version: String,
34 arch: String,
35 },
36 TaskResult {
37 batch: u64,
38 index: usize,
39 result: TaskResult,
40 },
41 BatchDone {
42 batch: u64,
43 outcome: BatchOutcome,
44 },
45 Log {
46 level: LogLevel,
47 message: String,
48 },
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum LogLevel {
54 Debug,
55 Info,
56 Warn,
57 Error,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(tag = "kind", rename_all = "snake_case")]
62pub enum BatchOutcome {
63 Completed,
64 Failed {
66 at: usize,
67 },
68 Cancelled {
70 at: usize,
71 },
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct Task {
77 pub module: String,
79 #[serde(default)]
80 pub args: Map<String, Value>,
81 #[serde(default)]
82 pub ignore_errors: bool,
83}
84
85#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
87#[serde(transparent)]
88pub struct TaskResult(pub Map<String, Value>);
89
90impl TaskResult {
91 pub fn flag(&self, key: &str) -> bool {
92 matches!(self.0.get(key), Some(Value::Bool(true)))
93 }
94
95 pub fn changed(&self) -> bool {
96 self.flag("changed")
97 }
98
99 pub fn skipped(&self) -> bool {
100 self.flag("skipped")
101 }
102
103 pub fn failed(&self) -> bool {
105 self.flag("failed")
106 || matches!(self.0.get("rc"), Some(Value::Number(n)) if n.as_i64() != Some(0))
107 }
108
109 pub fn failed_with(msg: impl Into<String>) -> Self {
110 let mut map = Map::new();
111 map.insert("failed".into(), Value::Bool(true));
112 map.insert("msg".into(), Value::String(msg.into()));
113 Self(map)
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use serde_json::json;
121
122 #[test]
123 fn hello_has_a_snake_case_type_tag() {
124 let text = serde_json::to_string(&ToAgent::Hello {
125 protocol: PROTOCOL_VERSION,
126 })
127 .unwrap();
128 assert_eq!(text, r#"{"type":"hello","protocol":1}"#);
129 }
130
131 #[test]
132 fn run_batch_round_trips() {
133 let msg = ToAgent::RunBatch {
134 id: 7,
135 tasks: vec![Task {
136 module: "ansible.builtin.command".into(),
137 args: json!({"_raw_params": "echo hi"})
138 .as_object()
139 .unwrap()
140 .clone(),
141 ignore_errors: true,
142 }],
143 };
144 let back: ToAgent = serde_json::from_slice(&serde_json::to_vec(&msg).unwrap()).unwrap();
145 assert_eq!(back, msg);
146 }
147
148 #[test]
149 fn task_args_and_ignore_errors_default() {
150 let task: Task = serde_json::from_str(r#"{"module":"raw"}"#).unwrap();
151 assert!(task.args.is_empty());
152 assert!(!task.ignore_errors);
153 }
154
155 #[test]
156 fn a_non_zero_rc_counts_as_failed() {
157 let ok = TaskResult(
158 json!({"rc": 0, "changed": true})
159 .as_object()
160 .unwrap()
161 .clone(),
162 );
163 let bad = TaskResult(json!({"rc": 2}).as_object().unwrap().clone());
164 let flagged = TaskResult(json!({"failed": true}).as_object().unwrap().clone());
165 assert!(!ok.failed() && ok.changed());
166 assert!(bad.failed() && !bad.changed());
167 assert!(flagged.failed());
168 }
169
170 #[test]
171 fn batch_outcome_carries_the_index() {
172 let text = serde_json::to_string(&FromAgent::BatchDone {
173 batch: 1,
174 outcome: BatchOutcome::Failed { at: 3 },
175 })
176 .unwrap();
177 assert_eq!(
178 text,
179 r#"{"type":"batch_done","batch":1,"outcome":{"kind":"failed","at":3}}"#
180 );
181 }
182
183 #[test]
184 fn failed_with_builds_the_ansible_shape() {
185 let r = TaskResult::failed_with("boom");
186 assert!(r.failed());
187 assert_eq!(r.0["msg"], "boom");
188 }
189}