1use rmcp::schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6use uuid::Uuid;
7
8pub const CLI_ABOUT: &str = "Agent-first CAN session frontend";
9pub const ADAPTERS_LIST_SUMMARY: &str = "List supported adapter names for `connect`.";
10pub const CONNECT_SUMMARY: &str = "Start or attach to the one live CAN session. DBCs are validated and fixed for the session lifetime.";
11pub const DISCONNECT_SUMMARY: &str =
12 "Stop periodic sends, finalize trace export, and tear down the current session.";
13pub const STATUS_SUMMARY: &str = "Show the detailed operational status for the live session.";
14pub const SELECTOR_RULES: &str = "Selector rules: `0x...` selects raw arbitration IDs; any other value uses glob matching over `alias.message`.";
15pub const SCHEMA_SUMMARY: &str = "Semantic discovery for the connect-time DBC set. This is what the session can interpret or construct, not what traffic has been observed.";
16pub const MESSAGE_LIST_SUMMARY: &str =
17 "Observed-traffic inventory. Returns compact message entries, not decoded signal values.";
18pub const MESSAGE_READ_SUMMARY: &str = "Detailed inspection for one selector. Raw selectors return raw frames; semantic selectors decode through the selected `alias.message` definition.";
19pub const MESSAGE_SEND_SUMMARY: &str = "Send one message by target shape. Raw `0x...` targets use hex payload strings; named `alias.message` targets use JSON signal maps that are DBC-encoded before transmission.";
20pub const MESSAGE_STOP_SUMMARY: &str =
21 "Stop the periodic schedule for a raw or semantic target identity.";
22pub const TRACE_START_SUMMARY: &str = "Start one raw ASCII trace export for the active session.";
23pub const TRACE_STOP_SUMMARY: &str = "Stop the current raw trace export.";
24
25#[derive(Debug)]
26pub struct PathNormalizationError {
27 message: String,
28}
29
30impl PathNormalizationError {
31 fn new(message: impl Into<String>) -> Self {
32 Self {
33 message: message.into(),
34 }
35 }
36}
37
38impl fmt::Display for PathNormalizationError {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 f.write_str(&self.message)
41 }
42}
43
44impl std::error::Error for PathNormalizationError {}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Request {
48 pub id: Uuid,
49 pub action: RequestAction,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
54pub enum RequestAction {
55 AdaptersList,
56 Connect(ConnectRequest),
57 Disconnect,
58 Status,
59 Schema(SchemaRequest),
60 MessageList(MessageListRequest),
61 MessageRead(MessageReadRequest),
62 MessageSend(MessageSendRequest),
63 MessageStop(MessageStopRequest),
64 TraceStart(TraceStartRequest),
65 TraceStop,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
69pub struct ConnectRequest {
70 pub adapter: String,
71 pub bitrate: u32,
72 pub bitrate_data: Option<u32>,
73 pub fd: bool,
74 #[serde(default)]
75 pub dbcs: Vec<DbcSpec>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
79pub struct DbcSpec {
80 pub alias: String,
81 pub path: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
85pub struct SchemaRequest {
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub filter: Option<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
91pub struct MessageListRequest {
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub filter: Option<String>,
94 #[serde(default)]
95 pub allow_raw: bool,
96 #[serde(default)]
97 pub include_tx: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
101pub struct MessageReadRequest {
102 pub select: String,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub count: Option<usize>,
105 #[serde(default)]
106 pub include_tx: bool,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
110pub struct MessageSendRequest {
111 pub target: String,
112 pub data: MessagePayload,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub periodicity_ms: Option<u64>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
118#[serde(untagged)]
119pub enum MessagePayload {
120 RawHex(String),
121 Signals(BTreeMap<String, f64>),
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
125pub struct MessageStopRequest {
126 pub target: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
130pub struct TraceStartRequest {
131 pub path: String,
132}
133
134pub fn normalize_connect_request_paths(
135 request: ConnectRequest,
136) -> Result<ConnectRequest, PathNormalizationError> {
137 let mut dbcs = request
138 .dbcs
139 .into_iter()
140 .map(|dbc| {
141 let raw_path = dbc.path;
142 let path = normalize_existing_path(&raw_path, "DBC")?;
143 Ok(DbcSpec {
144 alias: dbc.alias,
145 path,
146 })
147 })
148 .collect::<Result<Vec<_>, _>>()?;
149 dbcs.sort();
150 Ok(ConnectRequest { dbcs, ..request })
151}
152
153pub fn normalize_trace_start_request_path(
154 request: TraceStartRequest,
155) -> Result<TraceStartRequest, PathNormalizationError> {
156 Ok(TraceStartRequest {
157 path: normalize_output_path(&request.path, "trace")?,
158 })
159}
160
161fn normalize_existing_path(raw_path: &str, label: &str) -> Result<String, PathNormalizationError> {
162 let candidate = Path::new(raw_path);
163 if !candidate.is_absolute() {
164 return Err(PathNormalizationError::new(format!(
165 "{label} path '{raw_path}' must be absolute"
166 )));
167 }
168 let canonical = std::fs::canonicalize(candidate).map_err(|err| {
169 PathNormalizationError::new(format!(
170 "failed to resolve {label} path '{raw_path}' to an absolute path (candidate '{}'): {err}",
171 candidate.display()
172 ))
173 })?;
174 Ok(canonical.to_string_lossy().into_owned())
175}
176
177fn normalize_output_path(raw_path: &str, label: &str) -> Result<String, PathNormalizationError> {
178 let candidate = Path::new(raw_path);
179 if !candidate.is_absolute() {
180 return Err(PathNormalizationError::new(format!(
181 "{label} path '{raw_path}' must be absolute"
182 )));
183 }
184 let (existing_ancestor, suffix) = split_existing_ancestor(candidate)?;
185 let mut normalized = std::fs::canonicalize(&existing_ancestor).map_err(|err| {
186 PathNormalizationError::new(format!(
187 "failed to resolve {label} path '{raw_path}' via existing ancestor '{}': {err}",
188 existing_ancestor.display()
189 ))
190 })?;
191 for component in suffix {
192 normalized.push(component);
193 }
194 Ok(normalized.to_string_lossy().into_owned())
195}
196
197fn split_existing_ancestor(
198 path: &Path,
199) -> Result<(PathBuf, Vec<std::ffi::OsString>), PathNormalizationError> {
200 let mut current = path.to_path_buf();
201 let mut suffix = Vec::new();
202 while !current.exists() {
203 let Some(name) = current.file_name() else {
204 return Err(PathNormalizationError::new(format!(
205 "failed to resolve path '{}': no existing ancestor found",
206 path.display()
207 )));
208 };
209 suffix.push(name.to_os_string());
210 let Some(parent) = current.parent() else {
211 return Err(PathNormalizationError::new(format!(
212 "failed to resolve path '{}': no existing ancestor found",
213 path.display()
214 )));
215 };
216 current = parent.to_path_buf();
217 }
218 suffix.reverse();
219 Ok((current, suffix))
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct Response {
224 pub id: Uuid,
225 pub success: bool,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub data: Option<ResponseData>,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub error: Option<String>,
230}
231
232impl Response {
233 pub fn ok(id: Uuid, data: ResponseData) -> Self {
234 Self {
235 id,
236 success: true,
237 data: Some(data),
238 error: None,
239 }
240 }
241
242 pub fn err(id: Uuid, message: impl Into<String>) -> Self {
243 Self {
244 id,
245 success: false,
246 data: None,
247 error: Some(message.into()),
248 }
249 }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
254pub enum ResponseData {
255 AdaptersList { adapters: Vec<String> },
256 Connected(ConnectResult),
257 Disconnected,
258 Status(SessionStatus),
259 Schema { messages: Vec<SchemaMessage> },
260 MessageList { messages: Vec<MessageListEntry> },
261 MessageRead(MessageReadResult),
262 MessageSent(MessageSendResult),
263 MessageStopped { target: String, stopped: bool },
264 TraceStarted { path: String },
265 TraceStopped { path: Option<String> },
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
269pub struct ConnectResult {
270 pub created: bool,
271 pub already_connected: bool,
272 pub status: SessionStatus,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
276pub struct SessionStatus {
277 pub connection_state: String,
278 pub adapter: String,
279 pub bitrate: u32,
280 pub bitrate_data: Option<u32>,
281 pub fd: bool,
282 pub dbcs: Vec<LoadedDbc>,
283 pub trace_path: Option<String>,
284 pub periodic_schedules: Vec<PeriodicSchedule>,
285 pub backend_error: Option<String>,
286 pub retention_window_secs: u64,
287 pub retention_event_cap: usize,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
291pub struct LoadedDbc {
292 pub alias: String,
293 pub path: String,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
297pub struct PeriodicSchedule {
298 pub target: String,
299 pub arb_id: u32,
300 pub extended: bool,
301 pub fd: bool,
302 pub len: u8,
303 pub periodicity_ms: u64,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
307pub struct SchemaMessage {
308 pub qualified_name: String,
309 pub alias: String,
310 pub message: String,
311 pub arb_id: u32,
312 pub extended: bool,
313 pub fd: bool,
314 pub len: u8,
315 pub signals: Vec<SchemaSignal>,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
319pub struct SchemaSignal {
320 pub name: String,
321 pub value_type: String,
322 pub unit: Option<String>,
323 pub min: Option<f64>,
324 pub max: Option<f64>,
325 pub factor: f64,
326 pub offset: f64,
327 pub start_bit: u64,
328 pub bit_len: u64,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
332pub struct MessageListEntry {
333 pub label: String,
334 pub kind: MessageEntryKind,
335 pub arb_id: u32,
336 pub extended: bool,
337 pub fd: bool,
338 pub len: u8,
339 pub last_seen_unix_ms: u128,
340 pub has_rx: bool,
341 pub has_tx: bool,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
345#[serde(rename_all = "snake_case")]
346pub enum MessageEntryKind {
347 Raw,
348 Semantic,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
352pub struct MessageReadResult {
353 pub selector: String,
354 pub count: usize,
355 pub observations: Vec<MessageObservation>,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
359#[serde(tag = "kind", rename_all = "snake_case")]
360pub enum MessageObservation {
361 Raw {
362 seq: u64,
363 direction: EventDirection,
364 unix_ms: u128,
365 arb_id: u32,
366 extended: bool,
367 fd: bool,
368 len: u8,
369 payload_hex: String,
370 },
371 Semantic {
372 seq: u64,
373 direction: EventDirection,
374 unix_ms: u128,
375 qualified_name: String,
376 arb_id: u32,
377 extended: bool,
378 fd: bool,
379 len: u8,
380 payload_hex: String,
381 signals: Vec<DecodedSignalValue>,
382 },
383}
384
385#[cfg(test)]
386mod path_tests {
387 use super::{
388 ConnectRequest, DbcSpec, TraceStartRequest, normalize_connect_request_paths,
389 normalize_trace_start_request_path,
390 };
391 use std::fs;
392
393 #[cfg(unix)]
394 #[test]
395 fn normalize_connect_request_paths_requires_absolute_inputs() {
396 let temp = tempfile::tempdir().expect("tempdir");
397 let dbc_path = temp.path().join("bus.dbc");
398 fs::write(&dbc_path, "VERSION \"\"\n").expect("write dbc");
399
400 let request = ConnectRequest {
401 adapter: "pcan".to_string(),
402 bitrate: 500_000,
403 bitrate_data: None,
404 fd: false,
405 dbcs: vec![DbcSpec {
406 alias: "main".to_string(),
407 path: "bus.dbc".to_string(),
408 }],
409 };
410
411 let err =
412 normalize_connect_request_paths(request).expect_err("relative DBC path must fail");
413 assert!(err.to_string().contains("must be absolute"));
414 }
415
416 #[cfg(unix)]
417 #[test]
418 fn normalize_connect_request_paths_canonicalizes_absolute_dbc_inputs() {
419 let temp = tempfile::tempdir().expect("tempdir");
420 let dbc_dir = temp.path().join("linked");
421 fs::create_dir_all(temp.path().join("real")).expect("real dir");
422 std::os::unix::fs::symlink(temp.path().join("real"), &dbc_dir).expect("symlink dir");
423 let dbc_path = temp.path().join("real").join("bus.dbc");
424 fs::write(&dbc_path, "VERSION \"\"\n").expect("write dbc");
425 let expected_dbc_path = std::fs::canonicalize(&dbc_path)
426 .expect("canonical dbc path")
427 .display()
428 .to_string();
429
430 let request = ConnectRequest {
431 adapter: "pcan".to_string(),
432 bitrate: 500_000,
433 bitrate_data: None,
434 fd: false,
435 dbcs: vec![DbcSpec {
436 alias: "main".to_string(),
437 path: temp
438 .path()
439 .join("linked")
440 .join("..")
441 .join("linked")
442 .join("bus.dbc")
443 .display()
444 .to_string(),
445 }],
446 };
447
448 let normalized =
449 normalize_connect_request_paths(request).expect("normalize connect request");
450 assert_eq!(normalized.dbcs[0].path, expected_dbc_path);
451 }
452
453 #[cfg(unix)]
454 #[test]
455 fn normalize_trace_start_request_path_canonicalizes_existing_ancestor() {
456 let temp = tempfile::tempdir().expect("tempdir");
457 let real_dir = temp.path().join("real");
458 fs::create_dir_all(&real_dir).expect("real dir");
459 let link_dir = temp.path().join("link");
460 std::os::unix::fs::symlink(&real_dir, &link_dir).expect("symlink dir");
461 let expected_parent = std::fs::canonicalize(&real_dir)
462 .expect("canonical real dir")
463 .join("captures")
464 .join("run.asc")
465 .display()
466 .to_string();
467
468 let request = TraceStartRequest {
469 path: link_dir
470 .join("captures")
471 .join("run.asc")
472 .display()
473 .to_string(),
474 };
475
476 let normalized =
477 normalize_trace_start_request_path(request).expect("normalize trace request");
478 assert_eq!(normalized.path, expected_parent);
479 }
480
481 #[cfg(unix)]
482 #[test]
483 fn normalize_trace_start_request_path_requires_absolute_inputs() {
484 let request = TraceStartRequest {
485 path: "captures/run.asc".to_string(),
486 };
487
488 let err =
489 normalize_trace_start_request_path(request).expect_err("relative trace path must fail");
490 assert!(err.to_string().contains("must be absolute"));
491 }
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
495pub struct DecodedSignalValue {
496 pub name: String,
497 pub value: f64,
498 pub unit: Option<String>,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
502#[serde(rename_all = "snake_case")]
503pub enum EventDirection {
504 Rx,
505 Tx,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
509pub struct MessageSendResult {
510 pub target: String,
511 pub arb_id: u32,
512 pub extended: bool,
513 pub fd: bool,
514 pub len: u8,
515 pub periodicity_ms: Option<u64>,
516}
517
518#[derive(Debug, Clone, PartialEq, Eq)]
519pub enum Selector {
520 ArbId(u32),
521 SemanticPattern(String),
522}
523
524impl Selector {
525 pub fn parse(raw: &str) -> Result<Self, String> {
526 let trimmed = raw.trim();
527 if trimmed.is_empty() {
528 return Err("selector must not be empty".to_string());
529 }
530 if let Some(hex) = trimmed
531 .strip_prefix("0x")
532 .or_else(|| trimmed.strip_prefix("0X"))
533 {
534 let arb_id = u32::from_str_radix(hex, 16)
535 .map_err(|_| format!("invalid raw arbitration selector '{raw}'"))?;
536 return Ok(Self::ArbId(arb_id));
537 }
538 Ok(Self::SemanticPattern(trimmed.to_string()))
539 }
540
541 pub fn matches_qualified_name(&self, qualified_name: &str) -> bool {
542 match self {
543 Self::ArbId(_) => false,
544 Self::SemanticPattern(pattern) => glob_match(pattern, qualified_name),
545 }
546 }
547
548 pub fn matches_arb_id(&self, arb_id: u32) -> bool {
549 matches!(self, Self::ArbId(candidate) if *candidate == arb_id)
550 }
551}
552
553fn glob_match(pattern: &str, value: &str) -> bool {
554 let pattern_chars = pattern.chars().collect::<Vec<_>>();
555 let value_chars = value.chars().collect::<Vec<_>>();
556 let mut dp = vec![vec![false; value_chars.len() + 1]; pattern_chars.len() + 1];
557 dp[0][0] = true;
558 for idx in 0..pattern_chars.len() {
559 if pattern_chars[idx] == '*' {
560 dp[idx + 1][0] = dp[idx][0];
561 }
562 }
563 for p_idx in 0..pattern_chars.len() {
564 for v_idx in 0..value_chars.len() {
565 dp[p_idx + 1][v_idx + 1] = match pattern_chars[p_idx] {
566 '*' => dp[p_idx][v_idx + 1] || dp[p_idx + 1][v_idx],
567 '?' => dp[p_idx][v_idx],
568 literal => dp[p_idx][v_idx] && literal == value_chars[v_idx],
569 };
570 }
571 }
572 dp[pattern_chars.len()][value_chars.len()]
573}
574
575pub fn payload_to_hex(data: &[u8]) -> String {
576 data.iter()
577 .map(|value| format!("{value:02X}"))
578 .collect::<Vec<_>>()
579 .join("")
580}
581
582#[cfg(test)]
583mod tests {
584 use super::{MessagePayload, RequestAction, Selector, glob_match};
585 use std::collections::BTreeMap;
586
587 #[test]
588 fn selector_parses_raw_and_semantic_values() {
589 assert_eq!(
590 Selector::parse("0x123").expect("raw"),
591 Selector::ArbId(0x123)
592 );
593 assert_eq!(
594 Selector::parse("powertrain.*").expect("semantic"),
595 Selector::SemanticPattern("powertrain.*".to_string())
596 );
597 }
598
599 #[test]
600 fn glob_matching_uses_simple_wildcards() {
601 assert!(glob_match("foo.*", "foo.bar"));
602 assert!(glob_match("foo.?ar", "foo.bar"));
603 assert!(!glob_match("foo.?az", "foo.bar"));
604 }
605
606 #[test]
607 fn request_action_round_trip_serializes() {
608 let request = RequestAction::TraceStop;
609 let encoded = serde_json::to_string(&request).expect("serialize");
610 let decoded = serde_json::from_str::<RequestAction>(&encoded).expect("deserialize");
611 assert!(matches!(decoded, RequestAction::TraceStop));
612 }
613
614 #[test]
615 fn message_payload_round_trip_supports_raw_strings_and_signal_maps() {
616 let raw = serde_json::from_str::<MessagePayload>("\"DEADBEEF\"").expect("raw payload");
617 assert_eq!(raw, MessagePayload::RawHex("DEADBEEF".to_string()));
618
619 let semantic = serde_json::from_str::<MessagePayload>(r#"{"enable":1,"torque":12.5}"#)
620 .expect("signal map");
621 assert_eq!(
622 semantic,
623 MessagePayload::Signals(BTreeMap::from([
624 ("enable".to_string(), 1.0),
625 ("torque".to_string(), 12.5),
626 ]))
627 );
628 }
629}