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.extension().is_some_and(|e| e == "yaml" || e == "yml") {
318 if let Ok(content) = tokio::fs::read_to_string(&path).await {
319 if let Ok(scenario) = serde_yaml::from_str::<Scenario>(&content) {
320 scenarios.push(ScenarioInfo {
321 name: scenario.name,
322 file: path
323 .file_name()
324 .unwrap_or_default()
325 .to_string_lossy()
326 .into(),
327 devices: scenario.devices.len(),
328 events: scenario.events.len(),
329 description: scenario.description,
330 });
331 }
332 }
333 }
334
335 if let Some(limit) = self.limit {
336 if scenarios.len() >= limit {
337 break;
338 }
339 }
340 }
341 }
342
343 if matches!(output.format(), OutputFormat::Json | OutputFormat::Yaml) {
344 output.write(&scenarios)?;
345 return Ok(CommandOutput::quiet_success());
346 }
347
348 output.header("Scenarios");
349
350 if scenarios.is_empty() {
351 output.info("No scenarios found");
352 output.kv("Search path", scenarios_dir.display());
353 } else {
354 let mut table = TableBuilder::new(output.colors_enabled())
355 .header(["Name", "File", "Devices", "Events"]);
356
357 for scenario in &scenarios {
358 table = table.row([
359 &scenario.name,
360 &scenario.file,
361 &scenario.devices.to_string(),
362 &scenario.events.to_string(),
363 ]);
364 }
365 table.print();
366 output.kv("Total", scenarios.len());
367 }
368
369 Ok(CommandOutput::quiet_success())
370 }
371}
372
373#[async_trait]
374impl Command for ListCommand {
375 fn name(&self) -> &str {
376 "list"
377 }
378
379 fn description(&self) -> &str {
380 "List devices, protocols, points, or scenarios"
381 }
382
383 async fn execute(&self, ctx: &mut CliContext) -> CliResult<CommandOutput> {
384 match self.resource {
385 ListResource::Devices => self.list_devices(ctx).await,
386 ListResource::Protocols => self.list_protocols(ctx).await,
387 ListResource::Points => self.list_points(ctx).await,
388 ListResource::Scenarios => self.list_scenarios(ctx).await,
389 }
390 }
391}
392
393#[derive(Debug, Clone, Serialize)]
394struct ProtocolInfo {
395 name: String,
396 protocol: String,
397 default_port: u16,
398 description: String,
399 features: Vec<&'static str>,
400}
401
402#[derive(Debug, Clone, Serialize)]
403struct PointInfo {
404 device_id: String,
405 point_id: String,
406 data_type: String,
407 access: String,
408 description: String,
409}
410
411#[derive(Debug, Clone, Serialize)]
412struct ScenarioInfo {
413 name: String,
414 file: String,
415 devices: usize,
416 events: usize,
417 description: String,
418}