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