Skip to main content

rustant_core/nodes/
macos.rs

1//! macOS node — local execution via shell, AppleScript, notifications.
2//!
3//! Uses `tokio::process::Command` for executing local commands.
4//! In tests, a trait abstraction allows mocking without real execution.
5
6use 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/// Trait for executing local commands, allowing test mocking.
15#[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
25/// macOS node using local command execution.
26pub 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        // Simple check: try running 'echo ok'
117        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}