Skip to main content

agent_can/
trace.rs

1use crate::frame::{CAN_FLAG_EXTENDED, CAN_FLAG_FD, CanFrame};
2use crate::protocol::EventDirection;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::time::{Instant, SystemTime, UNIX_EPOCH};
6
7pub struct TraceWriter {
8    path: PathBuf,
9    started_at: Instant,
10    writer: std::io::BufWriter<std::fs::File>,
11}
12
13impl TraceWriter {
14    pub fn start(path: &Path) -> Result<Self, String> {
15        Self::start_with_clock(path, SystemTime::now(), Instant::now())
16    }
17
18    fn start_with_clock(
19        path: &Path,
20        started_at_wall_clock: SystemTime,
21        started_at_monotonic: Instant,
22    ) -> Result<Self, String> {
23        let path = normalize_trace_path(path)?;
24        let file = std::fs::OpenOptions::new()
25            .write(true)
26            .create_new(true)
27            .open(&path)
28            .map_err(|err| format!("failed to create trace '{}': {err}", path.display()))?;
29        let mut writer = std::io::BufWriter::new(file);
30        writeln!(
31            writer,
32            "date {}",
33            format_trace_header_date(started_at_wall_clock)?
34        )
35        .map_err(|err| format!("failed to initialize trace '{}': {err}", path.display()))?;
36        writeln!(writer, "base hex timestamps absolute")
37            .map_err(|err| format!("failed to initialize trace '{}': {err}", path.display()))?;
38        Ok(Self {
39            path,
40            started_at: started_at_monotonic,
41            writer,
42        })
43    }
44
45    pub fn path(&self) -> &Path {
46        &self.path
47    }
48
49    pub fn write_event(
50        &mut self,
51        direction: EventDirection,
52        frame: &CanFrame,
53    ) -> Result<(), String> {
54        let seconds = self.started_at.elapsed().as_secs_f64();
55        let direction = match direction {
56            EventDirection::Rx => "Rx",
57            EventDirection::Tx => "Tx",
58        };
59        let id = if (frame.flags & CAN_FLAG_EXTENDED) != 0 {
60            format!("{:08X}x", frame.arb_id)
61        } else {
62            format!("{:03X}", frame.arb_id)
63        };
64        let payload = crate::protocol::payload_to_hex(frame.payload());
65        let fd_suffix = if (frame.flags & CAN_FLAG_FD) != 0 {
66            " FD"
67        } else {
68            ""
69        };
70        writeln!(
71            self.writer,
72            "{seconds:>12.6} {direction} {id} {payload}{fd_suffix}"
73        )
74        .map_err(|err| format!("failed to write trace '{}': {err}", self.path.display()))
75    }
76
77    pub fn finish(mut self) -> Result<PathBuf, String> {
78        self.writer
79            .flush()
80            .map_err(|err| format!("failed to flush trace '{}': {err}", self.path.display()))?;
81        Ok(self.path)
82    }
83}
84
85fn normalize_trace_path(raw_path: &Path) -> Result<PathBuf, String> {
86    if !raw_path.is_absolute() {
87        return Err(format!(
88            "trace path '{}' must be absolute",
89            raw_path.display()
90        ));
91    }
92    let file_name = raw_path.file_name().ok_or_else(|| {
93        format!(
94            "trace path '{}' must include a file name",
95            raw_path.display()
96        )
97    })?;
98    let parent = raw_path.parent().ok_or_else(|| {
99        format!(
100            "trace path '{}' must include a parent directory",
101            raw_path.display()
102        )
103    })?;
104    std::fs::create_dir_all(parent).map_err(|err| {
105        format!(
106            "failed to create trace directory '{}': {err}",
107            parent.display()
108        )
109    })?;
110    let canonical_parent = std::fs::canonicalize(parent).map_err(|err| {
111        format!(
112            "failed to resolve trace directory '{}': {err}",
113            parent.display()
114        )
115    })?;
116    let normalized = canonical_parent.join(file_name);
117    if let Ok(metadata) = std::fs::symlink_metadata(&normalized) {
118        if metadata.file_type().is_symlink() {
119            return Err(format!(
120                "refusing to write trace through symlink '{}'",
121                normalized.display()
122            ));
123        }
124        if metadata.is_dir() {
125            return Err(format!(
126                "trace path '{}' points to a directory",
127                normalized.display()
128            ));
129        }
130    }
131    Ok(normalized)
132}
133
134fn format_trace_header_date(at: SystemTime) -> Result<String, String> {
135    let duration = at
136        .duration_since(UNIX_EPOCH)
137        .map_err(|err| format!("trace clock is before unix epoch: {err}"))?;
138    let total_seconds = duration.as_secs();
139    let millis = duration.subsec_millis();
140    let days = i64::try_from(total_seconds / 86_400)
141        .map_err(|_| "trace timestamp day count overflowed".to_string())?;
142    let seconds_of_day = total_seconds % 86_400;
143    let (year, month, day) = civil_from_days(days);
144    let hour = seconds_of_day / 3_600;
145    let minute = (seconds_of_day % 3_600) / 60;
146    let second = seconds_of_day % 60;
147    Ok(format!(
148        "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}.{millis:03}"
149    ))
150}
151
152fn civil_from_days(days_since_unix_epoch: i64) -> (i32, u32, u32) {
153    let z = days_since_unix_epoch + 719_468;
154    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
155    let doe = z - era * 146_097;
156    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
157    let y = yoe + era * 400;
158    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
159    let mp = (5 * doy + 2) / 153;
160    let day = doy - (153 * mp + 2) / 5 + 1;
161    let month = mp + if mp < 10 { 3 } else { -9 };
162    let year = y + if month <= 2 { 1 } else { 0 };
163    (year as i32, month as u32, day as u32)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::{TraceWriter, format_trace_header_date};
169    use crate::frame::CanFrame;
170    use crate::protocol::EventDirection;
171    use std::path::Path;
172    use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
173
174    #[test]
175    fn trace_header_date_formats_real_timestamp() {
176        let at = UNIX_EPOCH + Duration::from_millis(1_712_925_296_789);
177        assert_eq!(
178            format_trace_header_date(at).expect("format"),
179            "2024-04-12 12:34:56.789"
180        );
181    }
182
183    #[test]
184    fn trace_start_writes_header_with_resolved_absolute_path() {
185        let tempdir = tempfile::tempdir().expect("tempdir");
186        let path = tempdir.path().join("subdir").join("session.asc");
187        let started_at = UNIX_EPOCH + Duration::from_millis(1_712_925_296_789);
188
189        let writer =
190            TraceWriter::start_with_clock(&path, started_at, Instant::now()).expect("start trace");
191        let path = writer.path().to_path_buf();
192        let finished_path = writer.finish().expect("finish trace");
193
194        assert_eq!(finished_path, path);
195        assert!(path.is_absolute());
196
197        let contents = std::fs::read_to_string(&path).expect("read trace");
198        let lines = contents.lines().collect::<Vec<_>>();
199        assert_eq!(lines[0], "date 2024-04-12 12:34:56.789");
200        assert_eq!(lines[1], "base hex timestamps absolute");
201    }
202
203    #[test]
204    fn trace_start_rejects_relative_paths() {
205        let err = match TraceWriter::start(Path::new("relative.asc")) {
206            Ok(_) => panic!("relative path must fail"),
207            Err(err) => err,
208        };
209        assert!(err.contains("must be absolute"));
210    }
211
212    #[cfg(unix)]
213    #[test]
214    fn trace_start_rejects_symlink_targets() {
215        let tempdir = tempfile::tempdir().expect("tempdir");
216        let real_target = tempdir.path().join("real.asc");
217        std::fs::write(&real_target, "").expect("seed target");
218        let symlink_path = tempdir.path().join("link.asc");
219        std::os::unix::fs::symlink(&real_target, &symlink_path).expect("symlink");
220
221        let err = match TraceWriter::start(&symlink_path) {
222            Ok(_) => panic!("symlink must fail"),
223            Err(err) => err,
224        };
225        assert!(err.contains("refusing to write trace through symlink"));
226    }
227
228    #[test]
229    fn trace_writer_records_ascii_event_lines() {
230        let tempdir = tempfile::tempdir().expect("tempdir");
231        let path = tempdir.path().join("events.asc");
232        let started_at = SystemTime::now();
233        let mut writer =
234            TraceWriter::start_with_clock(&path, started_at, Instant::now()).expect("start");
235        writer
236            .write_event(
237                EventDirection::Tx,
238                &CanFrame {
239                    arb_id: 0x123,
240                    len: 2,
241                    flags: 0,
242                    data: [
243                        0xAB, 0xCD, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
244                        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
245                        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
246                    ],
247                },
248            )
249            .expect("write event");
250        writer.finish().expect("finish");
251
252        let contents = std::fs::read_to_string(&path).expect("read");
253        assert!(
254            contents
255                .lines()
256                .nth(2)
257                .expect("event line")
258                .contains("Tx 123 ABCD")
259        );
260    }
261}