cyme 3.0.1

List system USB buses and devices. A modern cross-platform lsusb
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
//! Config for cyme binary
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};

use crate::colour;
use crate::display;
use crate::display::Block;
use crate::error::{Error, ErrorKind, Result};
use crate::icon;
use crate::profiler::FilterEntry;
use crate::types::VidPid;
use crate::usb::BaseClass;

const CONF_DIR: &str = "cyme";
const CONF_NAME: &str = "cyme.json";

/// Allows user supplied icons to replace or add to `DEFAULT_ICONS` and `DEFAULT_TREE`
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
pub struct Config {
    #[serde(skip)]
    filepath: Option<PathBuf>,
    /// User supplied [`crate::icon::IconTheme`] - will merge with default
    pub icons: icon::IconTheme,
    /// User supplied [`crate::colour::ColourTheme`] - overrides default
    #[serde(alias = "colors")]
    pub colours: colour::ColourTheme,
    /// Default [`crate::display::DeviceBlocks`] to use for displaying devices
    pub blocks: Option<Vec<display::DeviceBlocks>>,
    /// Default [`crate::display::BusBlocks`] to use for displaying buses
    pub bus_blocks: Option<Vec<display::BusBlocks>>,
    /// Default [`crate::display::ConfigurationBlocks`] to use for device configurations
    pub config_blocks: Option<Vec<display::ConfigurationBlocks>>,
    /// Default [`crate::display::InterfaceBlocks`] to use for device interfaces
    pub interface_blocks: Option<Vec<display::InterfaceBlocks>>,
    /// Default [`crate::display::EndpointBlocks`] to use for device endpoints
    pub endpoint_blocks: Option<Vec<display::EndpointBlocks>>,
    /// Default block [`crate::display::BlockOperation`] to use for changing blocks
    pub block_operation: Option<display::BlockOperation>,
    /// Whether to hide device serial numbers by default
    pub mask_serials: Option<display::MaskSerial>,
    /// How to group devices during display
    pub group_devices: Option<display::Group>,
    /// Encoding to use for output text
    pub encoding: Option<display::Encoding>,
    /// When to show icons
    pub icon_when: Option<display::IconWhen>,
    /// When to use color
    pub color_when: Option<display::ColorWhen>,
    /// How to sort devices when listing
    pub sort_devices: Option<display::Sort>,
    /// Sort devices by bus number - irrelevant unless sort_devices is NoSort
    pub sort_buses: bool,
    /// Max variable string length to display before truncating - descriptors and classes for example
    pub max_variable_string_len: Option<usize>,
    /// Disable auto generation of max_variable_string_len based on terminal width
    pub no_auto_width: bool,
    // non-Options copied from Args
    /// Attempt to maintain compatibility with lsusb output
    pub lsusb: bool,
    /// Dump USB device hierarchy as a tree
    pub tree: bool,
    /// Verbosity level: 1 prints device configurations; 2 prints interfaces; 3 prints interface endpoints; 4 prints everything and all blocks
    pub verbose: u8,
    /// Print more blocks by default at each verbosity
    pub more: bool,
    /// Hide empty buses when printing tree; those with no devices.
    pub hide_buses: bool,
    /// Hide empty hubs when printing tree; those with no devices. When listing will hide hubs regardless of whether empty of not
    pub hide_hubs: bool,
    /// Show root hubs when listing; Linux only
    pub list_root_hubs: bool,
    /// Show base16 values as base10 decimal instead
    pub decimal: bool,
    /// Disable padding to align blocks
    pub no_padding: bool,
    /// Disable color - depreciated use color_when
    #[serde(skip_serializing)]
    pub no_color: bool,
    /// Disables icons and utf-8 characters - depreciated use encoding
    #[serde(skip_serializing)]
    pub ascii: bool,
    /// Disables all [`display::Block`] icons - depreciated use icon_when
    #[serde(skip_serializing)]
    pub no_icons: bool,
    /// Show block headings
    pub headings: bool,
    /// Force nusb/libusb profiler on macOS rather than using/combining system_profiler output
    pub force_libusb: bool,
    /// Output in JSON format
    pub json: bool,
    /// Print non-critical errors (normally due to permissions) during USB profiler to stderr
    pub print_non_critical_profiler_stderr: bool,
    /// Inclusion filters, OR'd together. Each entry is a [`crate::profiler::FilterEntry`];
    /// fields within one entry are AND'd, multiple entries are OR'd.
    pub filter_include: Vec<crate::profiler::FilterEntry>,
    /// Exclusion filters, OR'd together. Devices matching any entry are hidden even if
    /// they passed an inclusion filter.
    pub filter_exclude: Vec<crate::profiler::FilterEntry>,
    /// Apply muted colour to hub device lines instead of per-block colours
    pub mute_hubs: bool,
}

impl Config {
    /// New based on defaults
    pub fn new() -> Self {
        Default::default()
    }

    /// From system config if exists else default
    #[cfg(not(debug_assertions))]
    pub fn sys() -> Result<Self> {
        if let Some(p) = Self::config_file_path() {
            let path = p.join(CONF_NAME);
            log::info!("Looking for system config {:?}", &path);
            return match Self::from_file(&path) {
                Ok(c) => {
                    log::info!("Loaded system config {:?}", c);
                    Ok(c)
                }
                Err(e) => {
                    // if parsing error, print issue but use default
                    // IO error (unable to read) will raise as error
                    if e.kind() == ErrorKind::Parsing {
                        log::warn!("{}", e);
                        Err(e)
                    } else {
                        Ok(Self::new())
                    }
                }
            };
        } else {
            Ok(Self::new())
        }
    }

    /// Use default if running in debug since the integration tests use this
    #[cfg(debug_assertions)]
    pub fn sys() -> Result<Self> {
        log::warn!("Running in debug, not checking for system config");
        Ok(Self::new())
    }

    /// Get example [`Config`]
    pub fn example() -> Self {
        Config {
            icons: icon::example_theme(),
            blocks: Some(display::DeviceBlocks::example_blocks()),
            bus_blocks: Some(display::BusBlocks::example_blocks()),
            config_blocks: Some(display::ConfigurationBlocks::example_blocks()),
            interface_blocks: Some(display::InterfaceBlocks::example_blocks()),
            endpoint_blocks: Some(display::EndpointBlocks::example_blocks()),
            mask_serials: None,
            group_devices: Some(display::Group::default()),
            encoding: Some(display::Encoding::default()),
            icon_when: Some(display::IconWhen::default()),
            color_when: Some(display::ColorWhen::default()),
            sort_devices: Some(display::Sort::default()),
            ..Default::default()
        }
    }

    /// Get an example [`Config`] demonstrating the filter string syntax
    ///
    /// Intended for generating `cyme_example_filter_config.json` (see `--gen`).
    /// Unlike [`Config::example`] this is not intended as a base config to copy verbatim.
    pub fn example_with_filter() -> Self {
        Config {
            filter_include: vec![
                // Match a specific device by vid:pid
                FilterEntry {
                    vidpid: Some(VidPid(Some(0x1d50), Some(0x6018))),
                    ..Default::default()
                },
                // Match any device from a vendor (vid only, pid wildcard)
                FilterEntry {
                    vidpid: Some(VidPid(Some(0x05ac), None)),
                    ..Default::default()
                },
                // Match by name substring (all-lowercase = case-insensitive)
                FilterEntry {
                    name: Some("black magic".into()),
                    ..Default::default()
                },
                // Match by USB class
                FilterEntry {
                    class: Some(BaseClass::Hid),
                    ..Default::default()
                },
                // AND within one entry: vid AND name must both match (capitalization = case-sensitive)
                FilterEntry {
                    vidpid: Some(VidPid(Some(0x1366), None)),
                    name: Some("J-Link".into()),
                    ..Default::default()
                },
                // Match by physical location
                FilterEntry {
                    bus: Some(1),
                    number: Some(3),
                    ..Default::default()
                },
            ],
            filter_exclude: vec![
                // Hide devices whose name contains "Hub" (capital H = case-sensitive)
                FilterEntry {
                    name: Some("Hub".into()),
                    ..Default::default()
                },
                // Exclude device with serial number containing "zf3ds2" (case-sensitive)
                FilterEntry {
                    serial: Some("zf3ds2".into()),
                    case_sensitive: true,
                    ..Default::default()
                },
                // Hide a specific vid:pid
                FilterEntry {
                    vidpid: Some(VidPid(Some(0x1d6b), Some(0x0002))),
                    ..Default::default()
                },
            ],
            ..Default::default()
        }
    }

    /// Attempt to read from .json format config at `file_path`
    pub fn from_file<P: AsRef<Path>>(file_path: P) -> Result<Self> {
        let f = File::open(&file_path)?;
        let mut config: Self = serde_json::from_reader(BufReader::new(f)).map_err(|e| {
            Error::new(
                ErrorKind::Parsing,
                &format!(
                    "Failed to parse config at {:?}; Error({})",
                    file_path.as_ref(),
                    e
                ),
            )
        })?;
        // set the file path we loaded from for saving
        config.filepath = Some(file_path.as_ref().to_path_buf());
        Ok(config)
    }

    /// This provides the path for a configuration file, specific to OS
    /// return None if error like PermissionDenied
    pub fn config_file_path() -> Option<PathBuf> {
        dirs::config_dir().map(|x| x.join(CONF_DIR))
    }

    /// Get the file path for the config
    pub fn filepath(&self) -> Option<&Path> {
        self.filepath.as_deref()
    }

    /// Save the current config to a file
    pub fn save_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
        log::info!("Saving config to {:?}", path.as_ref().display());
        // create parent folders
        if let Some(parent) = path.as_ref().parent() {
            log::debug!("Creating parent folders for {:?}", parent.display());
            std::fs::create_dir_all(parent)?;
        }
        let f = File::create(&path)?;
        serde_json::to_writer_pretty(f, self)
            .map_err(|e| Error::new(ErrorKind::Io, &format!("Failed to save config: Error({e})")))
    }

    /// Save the current config to the file it was loaded from or default location if None
    pub fn save(&self) -> Result<()> {
        if let Some(p) = self.filepath() {
            self.save_file(p)
        } else if let Some(p) = Self::config_file_path() {
            self.save_file(p.join(CONF_NAME))
        } else {
            Err(Error::new(
                ErrorKind::Io,
                "Unable to determine config file path",
            ))
        }
    }

    /// Merge the settings from a [`display::PrintSettings`] into the config
    ///
    /// Dynamic settings and those loaded from config such as [`icon::IconTheme`] and [`color::ColourTheme`] are not merged
    pub fn merge_print_settings(&mut self, settings: &display::PrintSettings) {
        self.blocks = settings.device_blocks.clone();
        self.bus_blocks = settings.bus_blocks.clone();
        self.config_blocks = settings.config_blocks.clone();
        self.interface_blocks = settings.interface_blocks.clone();
        self.endpoint_blocks = settings.endpoint_blocks.clone();
        self.more = settings.more;
        self.decimal = settings.decimal;
        self.mask_serials = settings.mask_serials;
        self.group_devices = Some(settings.group_devices);
        self.encoding = Some(settings.encoding);
        self.icon_when = Some(settings.icon_when);
        self.color_when = Some(settings.color_when);
        self.sort_devices = Some(settings.sort_devices);
        self.sort_buses = settings.sort_buses;
        self.no_color = settings.colours.is_none();
        self.no_padding = settings.no_padding;
        self.headings = settings.headings;
        self.tree = settings.tree;
        self.max_variable_string_len = settings.max_variable_string_len;
        self.no_auto_width = !settings.auto_width;
        self.no_icons = matches!(settings.icon_when, display::IconWhen::Never)
            || !matches!(settings.encoding, display::Encoding::Glyphs);
        self.ascii = matches!(settings.encoding, display::Encoding::Ascii);
        self.verbose = settings.verbosity;
        self.json = settings.json;
        self.mute_hubs = settings.mute_hubs;
    }

    /// Returns a [`display::PrintSettings`] based on the config
    pub fn print_settings(&self) -> display::PrintSettings {
        let colours = if self.no_color {
            None
        } else {
            Some(self.colours.clone())
        };
        let icons = if self.no_icons {
            None
        } else {
            Some(self.icons.clone())
        };
        let encoding = self.encoding.unwrap_or({
            if self.ascii {
                display::Encoding::Ascii
            } else if self.no_icons {
                display::Encoding::Utf8
            } else {
                display::Encoding::Glyphs
            }
        });
        let group_devices = if self.group_devices == Some(display::Group::Bus) && self.tree {
            log::warn!("--group-devices with --tree is ignored; will print as tree");
            display::Group::NoGroup
        } else {
            self.group_devices.unwrap_or(display::Group::NoGroup)
        };
        display::PrintSettings {
            device_blocks: self.blocks.clone(),
            bus_blocks: self.bus_blocks.clone(),
            config_blocks: self.config_blocks.clone(),
            interface_blocks: self.interface_blocks.clone(),
            endpoint_blocks: self.endpoint_blocks.clone(),
            more: self.more,
            decimal: self.decimal,
            mask_serials: self.mask_serials,
            group_devices,
            sort_devices: self.sort_devices.unwrap_or_else(|| {
                if self.tree {
                    display::Sort::BranchPosition
                } else {
                    display::Sort::default()
                }
            }),
            sort_buses: self.sort_buses,
            no_padding: self.no_padding,
            headings: self.headings,
            tree: self.tree,
            max_variable_string_len: self.max_variable_string_len,
            auto_width: !self.no_auto_width,
            icon_when: self.icon_when.unwrap_or_default(),
            color_when: self.color_when.unwrap_or_default(),
            encoding,
            icons,
            colours,
            verbosity: self.verbose,
            json: self.json,
            mute_hubs: self.mute_hubs,
            ..Default::default()
        }
    }
}

impl From<&display::PrintSettings> for Config {
    fn from(settings: &display::PrintSettings) -> Self {
        let mut c = Config::new();
        c.merge_print_settings(settings);
        c
    }
}

impl From<&Config> for display::PrintSettings {
    fn from(c: &Config) -> Self {
        c.print_settings()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use colored::*;

    #[test]
    #[cfg(feature = "regex_icon")]
    fn test_deserialize_example_file() {
        let path = PathBuf::from("./doc").join("cyme_example_config.json");
        assert!(Config::from_file(path).is_ok());
    }

    #[test]
    #[cfg(feature = "regex_icon")]
    fn test_deserialize_example_filter_file() {
        let path = PathBuf::from("./doc").join("cyme_example_filter_config.json");
        let c = Config::from_file(path);
        assert!(
            c.is_ok(),
            "Failed to deserialize example filter config: {:?}",
            c.err()
        );
    }

    #[test]
    fn test_deserialize_config_no_theme() {
        let path = PathBuf::from("./tests/data").join("config_no_theme.json");
        assert!(Config::from_file(path).is_ok());
    }

    #[test]
    fn test_deserialize_config_missing_args() {
        let path = PathBuf::from("./tests/data").join("config_missing_args.json");
        assert!(Config::from_file(path).is_ok());
    }

    #[test]
    fn test_save_config() {
        // save to temp file
        let path = PathBuf::from("./tests/data").join("config_save.json");
        let c = Config::new();
        assert!(c.save_file(&path).is_ok());
        assert!(Config::from_file(path).is_ok());
    }

    #[test]
    fn test_filter_serialize_deserialize() {
        let config = Config {
            filter_include: vec![
                FilterEntry {
                    vidpid: Some(VidPid(Some(0x1d50), Some(0x6018))),
                    ..Default::default()
                },
                FilterEntry {
                    name: Some("black magic".into()),
                    ..Default::default()
                },
            ],
            filter_exclude: vec![FilterEntry {
                name: Some("Keyboard".into()),
                ..Default::default()
            }],
            ..Default::default()
        };

        // round-trip through JSON
        let json = serde_json::to_string(&config).unwrap();
        let restored: Config = serde_json::from_str(&json).unwrap();
        assert_eq!(config.filter_include, restored.filter_include);
        assert_eq!(config.filter_exclude, restored.filter_exclude);

        // deserialize from raw JSON — vidpid as hex string, other fields as plain strings
        let raw = r#"{"filter-include": [{"vidpid": "1d50:6018"}, {"name": "black magic"}], "filter-exclude": [{"name": "Keyboard"}]}"#;
        let from_raw: Config = serde_json::from_str(raw).unwrap();
        assert_eq!(
            from_raw.filter_include[0].vidpid,
            Some(VidPid(Some(0x1d50), Some(0x6018)))
        );
        assert_eq!(
            from_raw.filter_include[1].name.as_deref(),
            Some("black magic")
        );
        assert_eq!(from_raw.filter_exclude[0].name.as_deref(), Some("Keyboard"));

        // vid-only and AND within one entry
        let raw = r#"{"filter-include": [{"vidpid": "05ac", "name": "Keyboard"}]}"#;
        let from_raw: Config = serde_json::from_str(raw).unwrap();
        assert_eq!(
            from_raw.filter_include[0].vidpid,
            Some(VidPid(Some(0x05ac), None))
        );
        assert_eq!(from_raw.filter_include[0].name.as_deref(), Some("Keyboard"));

        // invalid vidpid string should fail
        assert!(
            serde_json::from_str::<Config>(r#"{"filter-include": [{"vidpid": "gggg"}]}"#).is_err()
        );
    }

    #[test]
    fn test_deserialize_colors_alias() {
        let raw = r#"{"colors": {"name": "red", "muted": "blue"}}"#;
        let c: Config = serde_json::from_str(raw).unwrap();
        assert_eq!(c.colours.name, Some(Color::Red));
        assert_eq!(c.colours.muted, Some(Color::Blue));
    }
}