rustant_core/nodes/
macos.rs1use super::{
7 Node,
8 types::{Capability, NodeHealth, NodeId, NodeInfo, NodeResult, NodeTask, Platform},
9};
10use crate::error::{NodeError, RustantError};
11use async_trait::async_trait;
12use chrono::Utc;
13
14#[async_trait]
16pub trait LocalExecutor: Send + Sync {
17 async fn execute_command(
18 &self,
19 command: &str,
20 args: &[String],
21 timeout_secs: u64,
22 ) -> Result<(String, i32), String>;
23}
24
25pub struct MacOsNode {
27 node_id: NodeId,
28 info: NodeInfo,
29 capabilities: Vec<Capability>,
30 executor: Box<dyn LocalExecutor>,
31 health: NodeHealth,
32}
33
34impl MacOsNode {
35 pub fn new(executor: Box<dyn LocalExecutor>) -> Self {
36 let hostname = std::env::var("HOSTNAME")
37 .or_else(|_| std::env::var("HOST"))
38 .unwrap_or_else(|_| "macos-local".to_string());
39 let node_id = NodeId::new(format!("macos-{}", hostname));
40 let info = NodeInfo {
41 node_id: node_id.clone(),
42 name: format!("macOS ({})", hostname),
43 platform: Platform::MacOS,
44 hostname,
45 registered_at: Utc::now(),
46 os_version: None,
47 agent_version: env!("CARGO_PKG_VERSION").to_string(),
48 uptime_secs: 0,
49 };
50 Self {
51 node_id,
52 info,
53 capabilities: vec![
54 Capability::Shell,
55 Capability::FileSystem,
56 Capability::AppleScript,
57 Capability::Screenshot,
58 Capability::Clipboard,
59 Capability::Notifications,
60 ],
61 executor,
62 health: NodeHealth::Healthy,
63 }
64 }
65}
66
67#[async_trait]
68impl Node for MacOsNode {
69 fn node_id(&self) -> &NodeId {
70 &self.node_id
71 }
72
73 fn info(&self) -> &NodeInfo {
74 &self.info
75 }
76
77 fn capabilities(&self) -> &[Capability] {
78 &self.capabilities
79 }
80
81 async fn execute(&self, task: NodeTask) -> Result<NodeResult, RustantError> {
82 if !self.capabilities.contains(&task.capability) {
83 return Err(RustantError::Node(NodeError::ExecutionFailed {
84 node_id: self.node_id.to_string(),
85 message: format!("Capability {} not supported", task.capability),
86 }));
87 }
88
89 let start = std::time::Instant::now();
90 let (output, exit_code) = self
91 .executor
92 .execute_command(&task.command, &task.args, task.timeout_secs)
93 .await
94 .map_err(|e| {
95 RustantError::Node(NodeError::ExecutionFailed {
96 node_id: self.node_id.to_string(),
97 message: e,
98 })
99 })?;
100 let duration_ms = start.elapsed().as_millis() as u64;
101
102 Ok(NodeResult {
103 task_id: task.task_id,
104 success: exit_code == 0,
105 output,
106 exit_code: Some(exit_code),
107 duration_ms,
108 })
109 }
110
111 fn health(&self) -> NodeHealth {
112 self.health
113 }
114
115 async fn heartbeat(&self) -> Result<NodeHealth, RustantError> {
116 match self
118 .executor
119 .execute_command("echo", &["ok".into()], 5)
120 .await
121 {
122 Ok((output, 0)) if output.contains("ok") => Ok(NodeHealth::Healthy),
123 Ok(_) => Ok(NodeHealth::Degraded),
124 Err(_) => Ok(NodeHealth::Unreachable),
125 }
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 struct MockExecutor {
134 output: String,
135 exit_code: i32,
136 }
137
138 impl MockExecutor {
139 fn ok(output: &str) -> Self {
140 Self {
141 output: output.to_string(),
142 exit_code: 0,
143 }
144 }
145
146 fn fail(message: &str) -> Self {
147 Self {
148 output: message.to_string(),
149 exit_code: 1,
150 }
151 }
152 }
153
154 #[async_trait]
155 impl LocalExecutor for MockExecutor {
156 async fn execute_command(
157 &self,
158 _cmd: &str,
159 _args: &[String],
160 _timeout: u64,
161 ) -> Result<(String, i32), String> {
162 Ok((self.output.clone(), self.exit_code))
163 }
164 }
165
166 #[test]
167 fn test_macos_node_capabilities() {
168 let node = MacOsNode::new(Box::new(MockExecutor::ok("ok")));
169 let caps = node.capabilities();
170 assert!(caps.contains(&Capability::Shell));
171 assert!(caps.contains(&Capability::AppleScript));
172 assert!(caps.contains(&Capability::Screenshot));
173 }
174
175 #[tokio::test]
176 async fn test_macos_execute_success() {
177 let node = MacOsNode::new(Box::new(MockExecutor::ok("file1\nfile2\n")));
178 let task = NodeTask::new(Capability::Shell, "ls");
179 let result = node.execute(task).await.unwrap();
180 assert!(result.success);
181 assert_eq!(result.output, "file1\nfile2\n");
182 assert_eq!(result.exit_code, Some(0));
183 }
184
185 #[tokio::test]
186 async fn test_macos_execute_failure() {
187 let node = MacOsNode::new(Box::new(MockExecutor::fail("error")));
188 let task = NodeTask::new(Capability::Shell, "bad-cmd");
189 let result = node.execute(task).await.unwrap();
190 assert!(!result.success);
191 assert_eq!(result.exit_code, Some(1));
192 }
193
194 #[tokio::test]
195 async fn test_macos_heartbeat() {
196 let node = MacOsNode::new(Box::new(MockExecutor::ok("ok")));
197 let health = node.heartbeat().await.unwrap();
198 assert_eq!(health, NodeHealth::Healthy);
199 }
200}