Skip to main content

rustant_core/nodes/
linux.rs

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