cmd-usage 0.7.25

Live Command Code (commandcode.ai) usage dashboard: credits, 5-hour and weekly windows, plan limits. Watch mode or one-shot.
Documentation
use super::config::{config_path, default_for, set, Config};
use crate::cli::{Binary, ConfigSet};

fn cs(interval: Option<u64>, width: Option<usize>) -> ConfigSet {
    ConfigSet {
        interval,
        width,
        ..Default::default()
    }
}

fn temp_dir(name: &str) -> std::path::PathBuf {
    let d = std::env::temp_dir().join(format!("cmduse-test-{name}-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&d);
    std::fs::create_dir_all(&d).unwrap();
    d
}

#[test]
fn config_defaults_when_missing() {
    // load() falls back to defaults when file absent — hard to inject path
    // without env var, so test the Default impl + parse round-trip instead
    let d = Config::default();
    assert_eq!(d.interval_secs, 5);
    assert_eq!(d.bar_width, 20);
    assert!(d.burst_enabled, "spend-bursts default ON");
    assert_eq!(d.burst_samples, 40);
    assert!(d.check_updates, "update check default ON");
    assert!(d.dismissed_update.is_none());
}

#[test]
fn every_binary_keeps_the_same_defaults_but_its_own_statusline() {
    // One family, one cadence: a per-binary refresh interval was drift, not a
    // decision (the user asked for 5s everywhere).
    let c = default_for(Binary::Cmduse);
    for binary in [Binary::Ocuse, Binary::Arkuse] {
        let o = default_for(binary);
        assert_ne!(c.statusline_template, o.statusline_template, "{binary:?}");
        assert_eq!(o.interval_secs, 5, "{binary:?}");
        // every other field matches cmduse's defaults
        let c2 = Config {
            statusline_template: o.statusline_template.clone(),
            ..c.clone()
        };
        assert_eq!(c2.interval_secs, o.interval_secs, "{binary:?}");
        assert_eq!(c2.bar_width, o.bar_width, "{binary:?}");
        assert_eq!(c2.burst_enabled, o.burst_enabled, "{binary:?}");
        assert_eq!(c2.burst_samples, o.burst_samples, "{binary:?}");
        assert_eq!(c2.notify_on_cap, o.notify_on_cap, "{binary:?}");
        assert_eq!(c2.statusline_colors, o.statusline_colors, "{binary:?}");
        assert_eq!(c2.statusline_ascii, o.statusline_ascii, "{binary:?}");
        assert_eq!(c2.check_updates, o.check_updates, "{binary:?}");
        assert_eq!(c2.dismissed_update, o.dismissed_update, "{binary:?}");
    }
}

#[test]
fn config_paths_differ_per_binary() {
    // suffix assertions hold whether or not XDG_CONFIG_HOME is set
    let c = config_path(Binary::Cmduse);
    let o = config_path(Binary::Ocuse);
    assert_ne!(c, o);
    assert!(c.ends_with("cmd-usage/config.json"));
    assert!(o.ends_with("ocuse/config.json"));
}

#[test]
fn config_parse_valid() {
    let c: Config = serde_json::from_str(r#"{"interval_secs": 30, "bar_width": 40}"#).unwrap();
    assert_eq!(c.interval_secs, 30);
    assert_eq!(c.bar_width, 40);
}

#[test]
fn config_parse_partial_uses_defaults() {
    let c: Config = serde_json::from_str(r#"{"interval_secs": 30}"#).unwrap();
    assert_eq!(c.interval_secs, 30);
    assert_eq!(c.bar_width, 20); // serde(default) fills
    assert!(c.burst_enabled);
    let c: Config =
        serde_json::from_str(r#"{"burst_enabled": false, "burst_samples": 20}"#).unwrap();
    assert!(!c.burst_enabled);
    assert_eq!(c.burst_samples, 20);
    assert_eq!(c.interval_secs, 5);
}

#[test]
fn config_parse_garbage_rejected() {
    assert!(serde_json::from_str::<Config>("not json").is_err());
    assert!(serde_json::from_str::<Config>("{\"interval_secs\": \"abc\"}").is_err());
}

#[test]
fn config_set_validates_and_persists() {
    let dir = temp_dir("set");
    let path = dir.join("cmd-usage/config.json");
    std::env::set_var("XDG_CONFIG_HOME", &dir);

    // valid set
    set(Binary::Cmduse, &cs(Some(15), Some(30))).unwrap();
    let c: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    assert_eq!(c.interval_secs, 15);
    assert_eq!(c.bar_width, 30);
    assert!(c.burst_enabled);

    // partial set keeps other key
    set(Binary::Cmduse, &cs(Some(60), None)).unwrap();
    let c: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    assert_eq!(c.interval_secs, 60);
    assert_eq!(c.bar_width, 30);

    // burst toggle off keeps samples; notify toggle off persists
    set(
        Binary::Cmduse,
        &ConfigSet {
            burst_on: Some(false),
            notify: Some(false),
            ..Default::default()
        },
    )
    .unwrap();
    let c: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    assert!(!c.burst_enabled);
    assert!(!c.notify_on_cap);

    // update toggle + dismissed version persist
    set(
        Binary::Cmduse,
        &ConfigSet {
            update_check: Some(false),
            dismissed_update: Some("0.6.8".into()),
            ..Default::default()
        },
    )
    .unwrap();
    let c: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    assert!(!c.check_updates);
    assert_eq!(c.dismissed_update.as_deref(), Some("0.6.8"));

    // set_dismissed keeps unrelated settings
    super::config::set_dismissed(Binary::Cmduse, "9.9.9").unwrap();
    let c: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    assert_eq!(c.dismissed_update.as_deref(), Some("9.9.9"));
    assert!(!c.check_updates);

    // the ocuse binary writes its own file, leaving cmduse's untouched
    let opath = dir.join("ocuse/config.json");
    set(Binary::Ocuse, &cs(Some(20), None)).unwrap();
    let oc: Config = serde_json::from_str(&std::fs::read_to_string(&opath).unwrap()).unwrap();
    assert_eq!(oc.interval_secs, 20);
    let c: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    assert_eq!(c.interval_secs, 60, "cmduse config untouched by ocuse set");

    // validation errors
    assert!(set(Binary::Cmduse, &cs(Some(0), None)).is_err());
    assert!(set(Binary::Cmduse, &cs(Some(86_401), None)).is_err());
    assert!(set(Binary::Cmduse, &cs(None, Some(4))).is_err()); // < 5
    assert!(set(Binary::Cmduse, &cs(None, Some(201))).is_err()); // > 200
    assert!(set(
        Binary::Cmduse,
        &ConfigSet {
            bursts: Some(3),
            ..Default::default()
        }
    )
    .is_err()); // < 5
    assert!(set(
        Binary::Cmduse,
        &ConfigSet {
            bursts: Some(241),
            ..Default::default()
        }
    )
    .is_err()); // > 240
    assert!(set(Binary::Cmduse, &ConfigSet::default()).is_err()); // nothing to set

    // file unchanged after failed set
    let c: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    assert_eq!(c.interval_secs, 60);
    assert!(!c.burst_enabled);
    assert!(!c.notify_on_cap);

    let _ = std::fs::remove_dir_all(&dir);
    std::env::remove_var("XDG_CONFIG_HOME");
}