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}