Skip to main content

agent_sim/cli/
commands.rs

1use crate::cli::args::{
2    CanCommand, CliArgs, Command, InstanceCommand, SetArgs, SharedCommand, TimeCommand,
3};
4use crate::cli::error::CliError;
5use crate::protocol::{InstanceAction, Request, RequestAction};
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use uuid::Uuid;
9
10pub fn to_request(args: &CliArgs) -> Result<Request, CliError> {
11    let command = args.command.as_ref().ok_or(CliError::MissingCommand)?;
12    let action = match command {
13        Command::Info => InstanceAction::Info,
14        Command::Signals => InstanceAction::Signals,
15        Command::Shared(shared) => match &shared.command {
16            SharedCommand::List => InstanceAction::SharedList,
17            SharedCommand::Get { channel } => InstanceAction::SharedGet {
18                channel_name: parse_shared_channel_selector(channel)?,
19            },
20        },
21        Command::Can(can) => match &can.command {
22            CanCommand::Buses => InstanceAction::CanBuses,
23            CanCommand::Attach { bus, vcan_iface } => InstanceAction::CanAttach {
24                bus_name: bus.clone(),
25                vcan_iface: vcan_iface.clone(),
26            },
27            CanCommand::Detach { bus } => InstanceAction::CanDetach {
28                bus_name: bus.clone(),
29            },
30            CanCommand::LoadDbc { bus, path } => InstanceAction::CanLoadDbc {
31                bus_name: bus.clone(),
32                path: canonicalize_cli_path(path)?,
33            },
34            CanCommand::Send {
35                bus,
36                arb_id,
37                data_hex,
38                flags,
39            } => InstanceAction::CanSend {
40                bus_name: bus.clone(),
41                arb_id: parse_arb_id(arb_id)?,
42                data_hex: data_hex.clone(),
43                flags: *flags,
44            },
45        },
46        Command::Reset => InstanceAction::Reset,
47        Command::Get(get) => InstanceAction::Get {
48            selectors: get.selectors.clone(),
49        },
50        Command::Set(set) => InstanceAction::Set {
51            writes: parse_set_entries(set)?,
52        },
53        Command::Close(close) if !close.all && close.env.is_none() => InstanceAction::Close,
54        Command::Instance(instance) => match instance.command {
55            Some(InstanceCommand::List) => InstanceAction::InstanceList,
56            None => InstanceAction::InstanceStatus,
57        },
58        Command::Time(time) => match &time.command {
59            TimeCommand::Start => InstanceAction::TimeStart,
60            TimeCommand::Pause => InstanceAction::TimePause,
61            TimeCommand::Step { duration } => InstanceAction::TimeStep {
62                duration: duration.clone(),
63            },
64            TimeCommand::Speed { multiplier } => InstanceAction::TimeSpeed {
65                multiplier: *multiplier,
66            },
67            TimeCommand::Status => InstanceAction::TimeStatus,
68        },
69        Command::Load(_)
70        | Command::Watch(_)
71        | Command::Run(_)
72        | Command::Env(_)
73        | Command::Close(_) => {
74            return Err(CliError::CommandFailed(
75                "command is handled by the CLI executor".to_string(),
76            ));
77        }
78    };
79    Ok(Request {
80        id: Uuid::new_v4(),
81        action: RequestAction::Instance(action),
82    })
83}
84
85fn canonicalize_cli_path(raw_path: &str) -> Result<String, CliError> {
86    let path = Path::new(raw_path);
87    let candidate: PathBuf = if path.is_absolute() {
88        path.to_path_buf()
89    } else {
90        std::env::current_dir()
91            .map_err(|e| {
92                CliError::CommandFailed(format!(
93                    "failed to determine current working directory while resolving DBC path '{raw_path}': {e}"
94                ))
95            })?
96            .join(path)
97    };
98    let canonical = std::fs::canonicalize(&candidate).map_err(|e| {
99        CliError::CommandFailed(format!(
100            "failed to resolve DBC path '{raw_path}' to an absolute path (candidate '{}'): {e}",
101            candidate.display()
102        ))
103    })?;
104    Ok(canonical.to_string_lossy().into_owned())
105}
106
107pub(crate) fn parse_arb_id(value: &str) -> Result<u32, CliError> {
108    let trimmed = value.trim();
109    if let Some(hex) = trimmed
110        .strip_prefix("0x")
111        .or_else(|| trimmed.strip_prefix("0X"))
112    {
113        u32::from_str_radix(hex, 16)
114            .map_err(|_| CliError::CommandFailed(format!("invalid arbitration id '{value}'")))
115    } else {
116        trimmed
117            .parse::<u32>()
118            .map_err(|_| CliError::CommandFailed(format!("invalid arbitration id '{value}'")))
119    }
120}
121
122fn parse_shared_channel_selector(value: &str) -> Result<String, CliError> {
123    let trimmed = value.trim();
124    if trimmed.is_empty() {
125        return Err(CliError::CommandFailed(
126            "shared get requires a channel selector".to_string(),
127        ));
128    }
129    if let Some(name) = trimmed.strip_suffix(".*") {
130        if name.is_empty() {
131            return Err(CliError::CommandFailed(format!(
132                "invalid shared selector '{value}'"
133            )));
134        }
135        return Ok(name.to_string());
136    }
137    Ok(trimmed.to_string())
138}
139
140fn parse_set_entries(args: &SetArgs) -> Result<BTreeMap<String, String>, CliError> {
141    if args.entries.len() == 2 && !args.entries[0].contains('=') && !args.entries[1].contains('=') {
142        let mut map = BTreeMap::new();
143        map.insert(args.entries[0].clone(), args.entries[1].clone());
144        return Ok(map);
145    }
146
147    let mut out = BTreeMap::new();
148    for entry in &args.entries {
149        let Some((k, v)) = entry.split_once('=') else {
150            return Err(CliError::InvalidSetSyntax);
151        };
152        if k.trim().is_empty() {
153            return Err(CliError::InvalidSetSyntax);
154        }
155        out.insert(k.trim().to_string(), v.trim().to_string());
156    }
157    if out.is_empty() {
158        return Err(CliError::InvalidSetSyntax);
159    }
160    Ok(out)
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::cli::args::{CanArgs, CliArgs, Command, LoadArgs};
167
168    #[test]
169    fn set_parser_accepts_single_pair() {
170        let set = SetArgs {
171            entries: vec!["sig".to_string(), "1".to_string()],
172        };
173        let writes = parse_set_entries(&set).expect("single pair set syntax should parse");
174        assert_eq!(writes.get("sig"), Some(&"1".to_string()));
175    }
176
177    #[test]
178    fn set_parser_accepts_equals_pairs() {
179        let set = SetArgs {
180            entries: vec!["a=1".to_string(), "b=true".to_string()],
181        };
182        let writes = parse_set_entries(&set).expect("equals-pairs set syntax should parse");
183        assert_eq!(writes.len(), 2);
184    }
185
186    #[test]
187    fn set_parser_rejects_mixed_syntax() {
188        let set = SetArgs {
189            entries: vec!["a=1".to_string(), "b".to_string()],
190        };
191        assert!(parse_set_entries(&set).is_err());
192    }
193
194    #[test]
195    fn arb_id_parser_accepts_hex_and_decimal() {
196        assert_eq!(
197            parse_arb_id("0x7FF").expect("hex arb id should parse"),
198            0x7FF
199        );
200        assert_eq!(
201            parse_arb_id("2048").expect("decimal arb id should parse"),
202            2048
203        );
204        assert!(parse_arb_id("xyz").is_err());
205    }
206
207    #[test]
208    fn shared_selector_parser_accepts_wildcard_suffix() {
209        assert_eq!(
210            parse_shared_channel_selector("sensor_feed.*").expect("shared selector should parse"),
211            "sensor_feed"
212        );
213        assert_eq!(
214            parse_shared_channel_selector("sensor_feed").expect("plain selector should parse"),
215            "sensor_feed"
216        );
217        assert!(parse_shared_channel_selector(".*").is_err());
218    }
219
220    #[test]
221    fn can_load_dbc_request_resolves_relative_path_to_absolute() {
222        let cwd = std::env::current_dir().expect("current directory should be readable");
223        let dbc = tempfile::Builder::new()
224            .prefix("can-load-dbc-")
225            .suffix(".dbc")
226            .tempfile_in(&cwd)
227            .expect("temp dbc should be creatable");
228        std::fs::write(dbc.path(), "VERSION \"\"").expect("temp dbc should be writable");
229        let relative = dbc
230            .path()
231            .file_name()
232            .and_then(|name| name.to_str())
233            .expect("temp dbc filename should be utf8")
234            .to_string();
235        let expected = std::fs::canonicalize(dbc.path()).expect("temp dbc should canonicalize");
236        let args = CliArgs {
237            json: false,
238            instance: "default".to_string(),
239            config: None,
240            command: Some(Command::Can(CanArgs {
241                command: CanCommand::LoadDbc {
242                    bus: "internal".to_string(),
243                    path: relative,
244                },
245            })),
246        };
247        let request = to_request(&args).expect("can load-dbc request should build");
248        let RequestAction::Instance(InstanceAction::CanLoadDbc { path, .. }) = request.action
249        else {
250            panic!("expected can load-dbc action");
251        };
252        assert_eq!(Path::new(&path), expected.as_path());
253    }
254
255    #[test]
256    fn can_load_dbc_request_rejects_missing_path() {
257        let args = CliArgs {
258            json: false,
259            instance: "default".to_string(),
260            config: None,
261            command: Some(Command::Can(CanArgs {
262                command: CanCommand::LoadDbc {
263                    bus: "internal".to_string(),
264                    path: "__missing_dbc_for_test__.dbc".to_string(),
265                },
266            })),
267        };
268        let err = to_request(&args).expect_err("missing DBC should fail early");
269        let CliError::CommandFailed(message) = err else {
270            panic!("expected command failure");
271        };
272        assert!(
273            message.contains("failed to resolve DBC path"),
274            "unexpected error: {message}"
275        );
276    }
277
278    #[test]
279    fn load_request_is_handled_by_cli_executor() {
280        let args = CliArgs {
281            json: false,
282            instance: "default".to_string(),
283            config: None,
284            command: Some(Command::Load(LoadArgs {
285                libpath: Some("/tmp/libsim.dylib".to_string()),
286                flash: Vec::new(),
287            })),
288        };
289        let err = to_request(&args).expect_err("load request should be rejected");
290        assert!(matches!(err, CliError::CommandFailed(_)));
291    }
292}