Skip to main content

twinleaf_tools/cli/
log.rs

1use clap::{Args, Subcommand, ValueEnum, ValueHint};
2use std::time::Duration;
3use twinleaf::device::DeviceRoute;
4
5use crate::{parse_device_route, TioOpts};
6
7#[derive(Args, Debug)]
8#[command(args_conflicts_with_subcommands = true)]
9pub struct LogCli {
10    #[command(flatten)]
11    pub tio: TioOpts,
12
13    #[command(subcommand)]
14    pub subcommands: Option<LogSubcommands>,
15
16    /// Output log file path
17    #[arg(short = 'f', default_value_t = default_log_path())]
18    pub file: String,
19
20    /// Unbuffered output (flush every packet)
21    #[arg(short = 'u')]
22    pub unbuffered: bool,
23
24    /// Raw mode: skip metadata request and dump all packets
25    #[arg(long)]
26    pub raw: bool,
27
28    /// Routing depth (only used in --raw mode)
29    #[arg(long = "depth")]
30    pub depth: Option<usize>,
31
32    /// Stop after this wall-clock duration (e.g. 30s, 5m, 2h)
33    #[arg(long, value_parser = humantime::parse_duration)]
34    pub duration: Option<Duration>,
35}
36
37#[derive(Subcommand, Debug)]
38pub enum LogSubcommands {
39    /// Log metadata to a file. See "tio log meta --help" for more options
40    #[command(args_conflicts_with_subcommands = true)]
41    Meta {
42        #[command(flatten)]
43        tio: TioOpts,
44
45        #[command(subcommand)]
46        subcommands: Option<MetaSubcommands>,
47
48        /// Output metadata file path
49        #[arg(short = 'f', default_value = "meta.tio")]
50        file: String,
51    },
52
53    /// Dump data from binary log file(s)
54    Dump {
55        /// Input log file(s)
56        #[arg(value_hint = ValueHint::FilePath, required = true, num_args = 1..)]
57        files: Vec<String>,
58
59        /// Show parsed data samples
60        #[arg(short = 'd', long = "data")]
61        data: bool,
62
63        /// Show metadata on boundaries
64        #[arg(short = 'm', long = "meta")]
65        meta: bool,
66
67        /// Sensor path in the sensor tree (e.g., /, /0, /0/1)
68        #[arg(short = 's', long = "sensor", default_value = "/", value_parser = parse_device_route)]
69        sensor: DeviceRoute,
70
71        /// Routing depth limit (default: unlimited)
72        #[arg(long = "depth")]
73        depth: Option<usize>,
74    },
75
76    /// Summarize the contents of binary log file(s)
77    Inspect {
78        /// Input log file(s)
79        #[arg(value_hint = ValueHint::FilePath, required = true, num_args = 1..)]
80        files: Vec<String>,
81    },
82
83    /// Convert binary log data to CSV
84    Csv {
85        /// Stream selector (name or id, optionally with a route prefix like
86        /// /0/field) and input .tio files (order-independent)
87        #[arg(value_hint = ValueHint::FilePath)]
88        args: Vec<String>,
89
90        /// Sensor route in the device tree (default: /); overridden by a route
91        /// prefix in the selector
92        #[arg(short = 's', value_parser = parse_device_route)]
93        sensor: Option<DeviceRoute>,
94
95        /// Output filename prefix
96        #[arg(short = 'o')]
97        output: Option<String>,
98
99        /// Overwrite the output file if it already exists
100        #[arg(short = 'f', long)]
101        force: bool,
102    },
103
104    /// Convert binary log files to HDF5 format
105    #[command(alias = "hdf5")]
106    Hdf {
107        /// Input log file(s)
108        #[arg(value_hint = ValueHint::FilePath, required = true, num_args = 1..)]
109        files: Vec<String>,
110
111        /// Output file path (defaults to input filename with .h5 extension)
112        #[arg(short = 'o')]
113        output: Option<String>,
114
115        /// Filter streams using a glob pattern (e.g. "/*/vector")
116        #[arg(short = 'g', long = "glob")]
117        filter: Option<String>,
118
119        /// Enable deflate compression (saves space, slows down write significantly)
120        #[arg(short = 'c', long = "compress")]
121        compress: bool,
122
123        /// Enable debug output for glob matching
124        #[arg(short = 'd', long)]
125        debug: bool,
126
127        /// How to organize runs in the output (none=flat, stream=per-stream, device=per-device, global=all-shared)
128        #[arg(short = 'l', long = "split", default_value = "none")]
129        split_level: SplitLevel,
130
131        /// When to detect discontinuities (continuous=any gap, monotonic=only time backward)
132        #[arg(short = 'p', long = "policy", default_value = "continuous")]
133        split_policy: SplitPolicy,
134    },
135}
136
137#[derive(Subcommand, Debug)]
138pub enum MetaSubcommands {
139    /// Reroute metadata packets in a metadata file
140    Reroute {
141        /// Input metadata file path
142        #[arg(value_hint = ValueHint::FilePath)]
143        input: String,
144
145        /// New device route (e.g., /0/1)
146        #[arg(short = 's', long = "sensor", value_parser = parse_device_route)]
147        route: DeviceRoute,
148
149        /// Output metadata file path (defaults to <input>_rerouted.tio)
150        #[arg(short = 'o', long = "output")]
151        output: Option<String>,
152    },
153}
154
155/// How the user referred to a stream on the `log csv` command line.
156#[derive(Clone, Debug)]
157pub enum StreamSel {
158    /// A numeric stream id, matched against `stream_id`.
159    Id(u8),
160    /// A stream name, matched against `name`.
161    Name(String),
162}
163
164/// A parsed `log csv` stream selector: an optional route prefix plus the stream.
165///
166/// The last `/`-separated segment is the stream (id or name); anything before it
167/// is the route. So `field` and `1` carry no route, while `/0/field` and `/0/1`
168/// pin the route to `/0`.
169#[derive(Clone, Debug)]
170pub struct CsvTarget {
171    pub route: Option<DeviceRoute>,
172    pub stream: StreamSel,
173}
174
175pub fn parse_csv_target(s: &str) -> Result<CsvTarget, String> {
176    if s.is_empty() {
177        return Err("empty stream selector".into());
178    }
179    // The stream is always the final path segment; the rest (if any) is route.
180    let last = s.rsplit('/').next().unwrap_or(s);
181    if last.is_empty() {
182        return Err(format!("missing stream name or id in {s:?}"));
183    }
184    let stream = match last.parse::<u8>() {
185        Ok(id) => StreamSel::Id(id),
186        Err(_) => StreamSel::Name(last.to_string()),
187    };
188    let route = if s.contains('/') {
189        // Strip the trailing stream segment (and its separator) to get the route.
190        let prefix = s[..s.len() - last.len()].trim_end_matches('/');
191        Some(parse_device_route(prefix)?)
192    } else {
193        None
194    };
195    Ok(CsvTarget { route, stream })
196}
197
198fn default_log_path() -> String {
199    chrono::Local::now()
200        .format("log.%Y%m%d-%H%M%S.tio")
201        .to_string()
202}
203
204/// Controls when discontinuities trigger run splits
205#[derive(ValueEnum, Clone, Debug, Default)]
206pub enum SplitPolicy {
207    /// Split on any discontinuity (gaps, rate changes, etc.)
208    #[default]
209    Continuous,
210    /// Only split when time goes backward (allows gaps)
211    Monotonic,
212}
213
214#[cfg(feature = "hdf5")]
215impl From<SplitPolicy> for twinleaf::data::export::SplitPolicy {
216    fn from(policy: SplitPolicy) -> Self {
217        match policy {
218            SplitPolicy::Continuous => Self::Continuous,
219            SplitPolicy::Monotonic => Self::Monotonic,
220        }
221    }
222}
223
224/// Controls how runs are organized in the HDF5 output
225#[derive(ValueEnum, Clone, Debug, Default)]
226pub enum SplitLevel {
227    /// No run splitting - one table per stream: /{route}/{stream}
228    #[default]
229    None,
230    /// Each stream has independent run counter (separate table per run)
231    Stream,
232    /// All streams on a device share run counter
233    Device,
234    /// All streams globally share run counter
235    Global,
236}
237
238#[cfg(feature = "hdf5")]
239impl From<SplitLevel> for twinleaf::data::export::RunSplitLevel {
240    fn from(level: SplitLevel) -> Self {
241        match level {
242            SplitLevel::None => Self::None,
243            SplitLevel::Stream => Self::PerStream,
244            SplitLevel::Device => Self::PerDevice,
245            SplitLevel::Global => Self::Global,
246        }
247    }
248}