whatcable 0.3.1

Tells you what each USB cable / device on Linux can actually do. Rust port of WhatCable.
Documentation
//! Text and JSON rendering for [`DeviceManager`].

use std::io::{self, Write};

use serde_json::{json, Map, Value};
use whatcable::pd::{cable_current_label, cable_speed_label};
use whatcable::summary::{Category, DeviceSummary};
use whatcable::usbclass;
use whatcable::DeviceManager;

const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const BLUE: &str = "\x1b[34m";
const CYAN: &str = "\x1b[36m";

/// Render the manager's current snapshot as colorized text.
pub fn print_text<W: Write>(w: &mut W, mgr: &DeviceManager, show_raw: bool) -> io::Result<()> {
    print_text_iter(w, mgr.devices(), show_raw)
}

fn print_text_iter<W: Write>(
    w: &mut W,
    devices: &[DeviceSummary],
    show_raw: bool,
) -> io::Result<()> {
    if devices.is_empty() {
        return writeln!(w, "No USB devices found.");
    }
    for dev in devices {
        let color = match dev.category {
            Category::TypeCPort => CYAN,
            Category::Hub => BLUE,
            Category::UsbDevice => GREEN,
        };
        writeln!(w, "{BOLD}{color}{}{RESET}", dev.headline)?;
        if !dev.subtitle.is_empty() {
            writeln!(w, "  {}", dev.subtitle)?;
        }
        for b in &dev.bullets {
            writeln!(w, "  {DIM}{RESET}{b}")?;
        }
        if let Some(diag) = &dev.charging_diag {
            if diag.is_warning {
                writeln!(w, "  {YELLOW}{}{RESET}", diag.summary)?;
            } else {
                writeln!(w, "  {GREEN}{}{RESET}", diag.summary)?;
            }
            if !diag.detail.is_empty() {
                writeln!(w, "    {DIM}{}{RESET}", diag.detail)?;
            }
        }
        if let Some(pd) = &dev.power_delivery {
            if !pd.source_capabilities.is_empty() {
                writeln!(w, "  {BOLD}Charger profiles:{RESET}")?;
                for pdo in &pd.source_capabilities {
                    let marker = if pdo.is_active {
                        format!("{GREEN} ◀ active{RESET}")
                    } else {
                        String::new()
                    };
                    writeln!(
                        w,
                        "    {} @ {}{}{}",
                        pdo.voltage_label(),
                        pdo.current_label(),
                        pdo.power_label(),
                        marker
                    )?;
                }
            }
        }
        if show_raw {
            let attrs = dev
                .usb_device
                .as_ref()
                .map(|u| &u.raw_attributes)
                .or_else(|| dev.typec_port.as_ref().map(|t| &t.raw_attributes));
            if let Some(attrs) = attrs {
                if !attrs.is_empty() {
                    writeln!(w, "  {DIM}Raw sysfs attributes:{RESET}")?;
                    for (k, v) in attrs.iter() {
                        writeln!(w, "    {k} = {v}")?;
                    }
                }
            }
        }
        writeln!(w)?;
    }
    Ok(())
}

/// Render the manager's snapshot as pretty-printed JSON.
pub fn print_json<W: Write>(w: &mut W, mgr: &DeviceManager, show_raw: bool) -> io::Result<()> {
    let arr: Vec<Value> = mgr
        .devices()
        .iter()
        .map(|d| device_json(d, show_raw))
        .collect();
    let s = serde_json::to_string_pretty(&arr).unwrap();
    writeln!(w, "{s}")
}

fn hex_vidpid(v: u16) -> String {
    format!("0x{v:04x}")
}

fn raw_to_value(map: &std::collections::BTreeMap<String, String>) -> Value {
    let mut out = Map::new();
    for (k, v) in map {
        out.insert(k.clone(), Value::String(v.clone()));
    }
    Value::Object(out)
}

pub(crate) fn device_json(dev: &DeviceSummary, show_raw: bool) -> Value {
    let category = match dev.category {
        Category::TypeCPort => "typec",
        Category::Hub => "hub",
        Category::UsbDevice => "usb",
    };
    let mut obj = Map::new();
    obj.insert("category".into(), Value::String(category.into()));
    obj.insert("headline".into(), Value::String(dev.headline.clone()));
    obj.insert("subtitle".into(), Value::String(dev.subtitle.clone()));
    obj.insert("icon".into(), Value::String(dev.icon.clone()));
    obj.insert(
        "bullets".into(),
        Value::Array(dev.bullets.iter().cloned().map(Value::String).collect()),
    );

    if let Some(u) = &dev.usb_device {
        let interfaces: Vec<Value> = u
            .interfaces
            .iter()
            .map(|i| {
                json!({
                    "class": usbclass::class_name(i.class_code),
                    "driver": i.driver,
                })
            })
            .collect();
        let mut usb = json!({
            "vendorId": hex_vidpid(u.vendor_id),
            "productId": hex_vidpid(u.product_id),
            "manufacturer": u.manufacturer,
            "product": u.product,
            "speed": u.speed,
            "speedLabel": u.speed_label(),
            "version": u.version,
            "maxPowerMA": u.max_power_ma,
            "serial": u.serial,
            "removable": u.removable,
            "bus": u.bus_num,
            "device": u.dev_num,
            "isHub": u.is_hub,
            "interfaces": interfaces,
        });
        if show_raw {
            usb.as_object_mut()
                .unwrap()
                .insert("raw".into(), raw_to_value(&u.raw_attributes));
        }
        obj.insert("usb".into(), usb);
    }

    if let Some(tc) = &dev.typec_port {
        let mut t = json!({
            "port": tc.port_number,
            "dataRole": tc.current_data_role(),
            "powerRole": tc.current_power_role(),
            "portType": tc.port_type,
            "powerOpMode": tc.power_op_mode,
            "connected": tc.is_connected(),
        });
        if let Some(psy) = &tc.power_supply {
            let mut ps = Map::new();
            ps.insert("name".into(), Value::String(psy.name.clone()));
            ps.insert("online".into(), Value::Bool(psy.online));
            for (k, v) in [
                ("voltageNowUV", psy.voltage_now_uv),
                ("currentNowUA", psy.current_now_ua),
                ("currentMaxUA", psy.current_max_ua),
                ("voltageMinUV", psy.voltage_min_uv),
                ("voltageMaxUV", psy.voltage_max_uv),
            ] {
                if let Some(val) = v {
                    ps.insert(k.into(), Value::Number(val.into()));
                }
            }
            if let Some(mw) = psy.negotiated_power_mw() {
                ps.insert("negotiatedPowerMW".into(), Value::Number(mw.into()));
            }
            ps.insert("chargeType".into(), Value::String(psy.charge_type.clone()));
            ps.insert("usbType".into(), Value::String(psy.usb_type.clone()));
            t.as_object_mut()
                .unwrap()
                .insert("powerSupply".into(), Value::Object(ps));
        }
        if show_raw {
            t.as_object_mut()
                .unwrap()
                .insert("raw".into(), raw_to_value(&tc.raw_attributes));
        }
        obj.insert("typec".into(), t);
    }

    if let Some(c) = &dev.cable {
        let mut cab = json!({
            "type": c.cable_type,
            "maxWatts": c.max_watts,
            "vendorId": hex_vidpid(c.vendor_id),
            "vendorName": c.vendor_name,
        });
        if let Some(s) = c.speed {
            cab.as_object_mut()
                .unwrap()
                .insert("speed".into(), Value::String(cable_speed_label(s).into()));
        }
        if let Some(curr) = c.current_rating {
            cab.as_object_mut().unwrap().insert(
                "current".into(),
                Value::String(cable_current_label(curr).into()),
            );
        }
        obj.insert("cable".into(), cab);
    }

    if let Some(pd) = &dev.power_delivery {
        let pdos: Vec<Value> = pd
            .source_capabilities
            .iter()
            .map(|p| {
                json!({
                    "type": p.r#type.label(),
                    "voltageMV": p.voltage_mv,
                    "currentMA": p.current_ma,
                    "powerMW": p.power_mw,
                    "active": p.is_active,
                })
            })
            .collect();
        obj.insert(
            "powerDelivery".into(),
            json!({
                "sourceCapabilities": pdos,
                "maxPowerMW": pd.max_source_power_mw,
            }),
        );
    }

    if let Some(d) = &dev.charging_diag {
        obj.insert(
            "charging".into(),
            json!({
                "summary": d.summary,
                "detail": d.detail,
                "isWarning": d.is_warning,
            }),
        );
    }

    Value::Object(obj)
}

#[cfg(test)]
mod tests {
    use super::*;
    use whatcable::summary::Status;
    use whatcable::usb::UsbDevice;

    #[test]
    fn empty_text_says_no_devices() {
        let mut buf = Vec::new();
        print_text_iter(&mut buf, &[], false).unwrap();
        assert!(String::from_utf8_lossy(&buf).contains("No USB devices found."));
    }

    #[test]
    fn json_for_usb_summary_has_expected_keys() {
        let dev = UsbDevice {
            vendor_id: 0x05AC,
            product_id: 0x12A8,
            product: "iPhone".into(),
            version: "2.10".into(),
            speed: 480,
            ..Default::default()
        };
        let summary = DeviceSummary::from_usb_device(&dev);
        let v = device_json(&summary, false);
        let obj = v.as_object().unwrap();
        assert_eq!(obj["category"], "usb");
        assert_eq!(obj["headline"], "iPhone");
        let usb = obj["usb"].as_object().unwrap();
        assert_eq!(usb["vendorId"], "0x05ac");
        assert_eq!(usb["productId"], "0x12a8");
        assert_eq!(usb["speedLabel"], "High Speed 480 Mbps");
    }

    #[test]
    fn typec_summary_serializes() {
        let port = whatcable::typec::TypeCPort {
            port_number: 0,
            data_role: "host [device]".into(),
            ..Default::default()
        };
        let s = DeviceSummary::from_typec_port(&port, None, None);
        assert_eq!(s.status, Status::Empty);
        let v = device_json(&s, false);
        assert_eq!(v["category"], "typec");
        assert_eq!(v["typec"]["dataRole"], "device");
    }

    #[test]
    fn text_output_renders_charger_profiles() {
        use whatcable::power::{PdoType, PowerDataObject, PowerDeliveryPort};
        use whatcable::typec::{TypeCPartner, TypeCPort};
        let port = TypeCPort {
            port_number: 0,
            partner: Some(TypeCPartner::default()),
            ..Default::default()
        };
        let pd = PowerDeliveryPort {
            source_capabilities: vec![PowerDataObject {
                r#type: PdoType::FixedSupply,
                voltage_mv: 20_000,
                current_ma: 5_000,
                power_mw: 100_000,
                is_active: true,
                ..Default::default()
            }],
            max_source_power_mw: 100_000,
            ..Default::default()
        };
        let s = DeviceSummary::from_typec_port(&port, Some(pd), None);
        let mut buf = Vec::new();
        print_text_iter(&mut buf, &[s], false).unwrap();
        let out = String::from_utf8_lossy(&buf);
        assert!(out.contains("Charger profiles:"));
        assert!(out.contains("20.0V @ 5.00A"));
        assert!(out.contains("◀ active"));
    }
}