1use crate::context::CliContext;
6use crate::error::{CliError, CliResult};
7use crate::output::{DeviceSummary, OutputFormat, StatusType, TableBuilder};
8use crate::runner::{Command, CommandOutput};
9use async_trait::async_trait;
10use mabi_core::prelude::*;
11use serde::Serialize;
12
13#[derive(Debug, Clone, Copy, Default)]
15pub enum ListResource {
16 #[default]
17 Devices,
18 Protocols,
19 Points,
20 Scenarios,
21}
22
23impl ListResource {
24 pub fn from_str(s: &str) -> Option<Self> {
25 match s.to_lowercase().as_str() {
26 "devices" | "device" | "d" => Some(Self::Devices),
27 "protocols" | "protocol" | "p" => Some(Self::Protocols),
28 "points" | "point" => Some(Self::Points),
29 "scenarios" | "scenario" | "s" => Some(Self::Scenarios),
30 _ => None,
31 }
32 }
33}
34
35pub struct ListCommand {
37 resource: ListResource,
39 protocol: Option<Protocol>,
41 device_filter: Option<String>,
43 limit: Option<usize>,
45}
46
47impl ListCommand {
48 pub fn new(resource: ListResource) -> Self {
50 Self {
51 resource,
52 protocol: None,
53 device_filter: None,
54 limit: None,
55 }
56 }
57
58 pub fn with_protocol(mut self, protocol: Protocol) -> Self {
60 self.protocol = Some(protocol);
61 self
62 }
63
64 pub fn with_device_filter(mut self, pattern: impl Into<String>) -> Self {
66 self.device_filter = Some(pattern.into());
67 self
68 }
69
70 pub fn with_limit(mut self, limit: usize) -> Self {
72 self.limit = Some(limit);
73 self
74 }
75
76 async fn list_devices(&self, ctx: &mut CliContext) -> CliResult<CommandOutput> {
78 let output = ctx.output();
79
80 let devices: Vec<DeviceSummary> = {
82 let engine_guard = ctx.engine_ref().read().await;
83 if let Some(engine) = engine_guard.as_ref() {
84 engine
85 .list_devices()
86 .into_iter()
87 .filter(|info| {
88 if let Some(ref proto) = self.protocol {
90 if info.protocol != *proto {
91 return false;
92 }
93 }
94 if let Some(ref pattern) = self.device_filter {
96 if !info.id.contains(pattern) {
97 return false;
98 }
99 }
100 true
101 })
102 .take(self.limit.unwrap_or(usize::MAX))
103 .map(|info| {
104 DeviceSummary {
105 id: info.id.clone(),
106 name: info.name.clone(),
107 protocol: format!("{:?}", info.protocol),
108 status: format!("{:?}", info.state),
109 points: info.point_count,
110 last_update: info.updated_at.to_rfc3339(),
111 }
112 })
113 .collect()
114 } else {
115 Vec::new()
116 }
117 };
118
119 if matches!(output.format(), OutputFormat::Json | OutputFormat::Yaml) {
120 output.write(&devices)?;
121 return Ok(CommandOutput::quiet_success());
122 }
123
124 output.header("Devices");
125
126 if devices.is_empty() {
127 output.info("No devices found");
128 if self.protocol.is_some() || self.device_filter.is_some() {
129 output.kv("Tip", "Try removing filters to see all devices");
130 }
131 } else {
132 let mut table = TableBuilder::new(output.colors_enabled())
133 .header(["ID", "Name", "Protocol", "Points", "Status"]);
134
135 for device in &devices {
136 let status_type = match device.status.as_str() {
137 "Online" => StatusType::Success,
138 "Offline" | "Error" => StatusType::Error,
139 _ => StatusType::Neutral,
140 };
141 table = table.status_row(
142 [
143 &device.id,
144 &device.name,
145 &device.protocol,
146 &device.points.to_string(),
147 &device.status,
148 ],
149 status_type,
150 );
151 }
152 table.print();
153 output.kv("Total", devices.len());
154 }
155
156 Ok(CommandOutput::quiet_success())
157 }
158
159 async fn list_protocols(&self, ctx: &mut CliContext) -> CliResult<CommandOutput> {
161 let output = ctx.output();
162
163 let protocols = vec![
164 ProtocolInfo {
165 name: "Modbus TCP".into(),
166 protocol: "modbus_tcp".into(),
167 default_port: 502,
168 description: "Modbus TCP/IP protocol".into(),
169 features: vec!["Read/Write Coils", "Read/Write Registers", "Multi-unit support"],
170 },
171 ProtocolInfo {
172 name: "Modbus RTU".into(),
173 protocol: "modbus_rtu".into(),
174 default_port: 0,
175 description: "Modbus RTU serial protocol".into(),
176 features: vec!["Serial communication", "CRC validation", "Multi-device bus"],
177 },
178 ProtocolInfo {
179 name: "OPC UA".into(),
180 protocol: "opcua".into(),
181 default_port: 4840,
182 description: "OPC Unified Architecture".into(),
183 features: vec!["Subscriptions", "History", "Security", "Address space"],
184 },
185 ProtocolInfo {
186 name: "BACnet/IP".into(),
187 protocol: "bacnet".into(),
188 default_port: 47808,
189 description: "BACnet over IP".into(),
190 features: vec!["Read/Write Properties", "COV Subscriptions", "BBMD", "Device discovery"],
191 },
192 ProtocolInfo {
193 name: "KNXnet/IP".into(),
194 protocol: "knx".into(),
195 default_port: 3671,
196 description: "KNX over IP".into(),
197 features: vec!["Tunneling", "Group addressing", "DPT support"],
198 },
199 ];
200
201 if matches!(output.format(), OutputFormat::Json | OutputFormat::Yaml) {
202 output.write(&protocols)?;
203 return Ok(CommandOutput::quiet_success());
204 }
205
206 output.header("Supported Protocols");
207
208 let mut table = TableBuilder::new(output.colors_enabled())
209 .header(["Protocol", "Name", "Port", "Features"]);
210
211 for proto in &protocols {
212 table = table.row([
213 &proto.protocol,
214 &proto.name,
215 &proto.default_port.to_string(),
216 &proto.features.join(", "),
217 ]);
218 }
219 table.print();
220
221 Ok(CommandOutput::quiet_success())
222 }
223
224 async fn list_points(&self, ctx: &mut CliContext) -> CliResult<CommandOutput> {
226 let output = ctx.output();
227
228 let points: Vec<PointInfo> = {
232 let engine_guard = ctx.engine_ref().read().await;
233 if let Some(engine) = engine_guard.as_ref() {
234 engine
235 .list_devices()
236 .into_iter()
237 .filter(|info| {
238 if let Some(ref pattern) = self.device_filter {
239 info.id.contains(pattern)
240 } else {
241 true
242 }
243 })
244 .flat_map(|info| {
245 (0..info.point_count).map(move |i| PointInfo {
248 device_id: info.id.clone(),
249 point_id: format!("point_{}", i),
250 data_type: "Unknown".into(),
251 access: "ReadWrite".into(),
252 description: String::new(),
253 })
254 })
255 .take(self.limit.unwrap_or(usize::MAX))
256 .collect()
257 } else {
258 Vec::new()
259 }
260 };
261
262 if matches!(output.format(), OutputFormat::Json | OutputFormat::Yaml) {
263 output.write(&points)?;
264 return Ok(CommandOutput::quiet_success());
265 }
266
267 output.header("Data Points");
268
269 if points.is_empty() {
270 output.info("No data points found");
271 } else {
272 let mut table = TableBuilder::new(output.colors_enabled())
273 .header(["Device", "Point ID", "Type", "Access", "Description"]);
274
275 for point in &points {
276 table = table.row([
277 &point.device_id,
278 &point.point_id,
279 &point.data_type,
280 &point.access,
281 &point.description,
282 ]);
283 }
284 table.print();
285 output.kv("Total", points.len());
286 }
287
288 Ok(CommandOutput::quiet_success())
289 }
290
291 async fn list_scenarios(&self, ctx: &mut CliContext) -> CliResult<CommandOutput> {
293 let output = ctx.output();
294 let working_dir = ctx.working_dir().clone();
295
296 let scenarios_dir = working_dir.join("scenarios");
298 let mut scenarios = Vec::new();
299
300 if scenarios_dir.exists() {
301 let mut entries = tokio::fs::read_dir(&scenarios_dir).await?;
302 while let Some(entry) = entries.next_entry().await? {
303 let path = entry.path();
304 if path.extension().map_or(false, |e| e == "yaml" || e == "yml") {
305 if let Ok(content) = tokio::fs::read_to_string(&path).await {
306 if let Ok(scenario) = serde_yaml::from_str::<super::run::ScenarioConfig>(&content) {
307 scenarios.push(ScenarioInfo {
308 name: scenario.name,
309 file: path.file_name().unwrap_or_default().to_string_lossy().into(),
310 devices: scenario.devices.len(),
311 events: scenario.events.len(),
312 description: scenario.description,
313 });
314 }
315 }
316 }
317
318 if let Some(limit) = self.limit {
319 if scenarios.len() >= limit {
320 break;
321 }
322 }
323 }
324 }
325
326 if matches!(output.format(), OutputFormat::Json | OutputFormat::Yaml) {
327 output.write(&scenarios)?;
328 return Ok(CommandOutput::quiet_success());
329 }
330
331 output.header("Scenarios");
332
333 if scenarios.is_empty() {
334 output.info("No scenarios found");
335 output.kv("Search path", scenarios_dir.display());
336 } else {
337 let mut table = TableBuilder::new(output.colors_enabled())
338 .header(["Name", "File", "Devices", "Events"]);
339
340 for scenario in &scenarios {
341 table = table.row([
342 &scenario.name,
343 &scenario.file,
344 &scenario.devices.to_string(),
345 &scenario.events.to_string(),
346 ]);
347 }
348 table.print();
349 output.kv("Total", scenarios.len());
350 }
351
352 Ok(CommandOutput::quiet_success())
353 }
354}
355
356#[async_trait]
357impl Command for ListCommand {
358 fn name(&self) -> &str {
359 "list"
360 }
361
362 fn description(&self) -> &str {
363 "List devices, protocols, points, or scenarios"
364 }
365
366 async fn execute(&self, ctx: &mut CliContext) -> CliResult<CommandOutput> {
367 match self.resource {
368 ListResource::Devices => self.list_devices(ctx).await,
369 ListResource::Protocols => self.list_protocols(ctx).await,
370 ListResource::Points => self.list_points(ctx).await,
371 ListResource::Scenarios => self.list_scenarios(ctx).await,
372 }
373 }
374}
375
376#[derive(Debug, Clone, Serialize)]
377struct ProtocolInfo {
378 name: String,
379 protocol: String,
380 default_port: u16,
381 description: String,
382 features: Vec<&'static str>,
383}
384
385#[derive(Debug, Clone, Serialize)]
386struct PointInfo {
387 device_id: String,
388 point_id: String,
389 data_type: String,
390 access: String,
391 description: String,
392}
393
394#[derive(Debug, Clone, Serialize)]
395struct ScenarioInfo {
396 name: String,
397 file: String,
398 devices: usize,
399 events: usize,
400 description: String,
401}