use std::time::{SystemTime, UNIX_EPOCH};
use crate::picker::fzf::{fzf_available, FzfOpts};
#[derive(Debug, Clone)]
pub struct StaleProfileData {
pub session_pct: Option<i64>,
pub week_all_pct: Option<i64>,
pub resets: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AccountRow {
pub profile: String,
pub display: String,
}
impl AccountRow {
pub fn to_tsv(&self) -> String {
format!("{}\t{}", self.profile, self.display)
}
pub fn build(profile: &str, data: &StaleProfileData, cache_mtime_secs: Option<u64>) -> Self {
let stale_annotation = cache_mtime_secs.map(|mtime| {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let age_secs = now.saturating_sub(mtime);
format_stale_age(age_secs)
});
let usage = render_display(data, stale_annotation.as_deref());
let display = format!("{:<10} {usage}", profile);
AccountRow {
profile: profile.to_string(),
display,
}
}
}
fn render_display(data: &StaleProfileData, stale: Option<&str>) -> String {
let stale_suffix = stale.map(|s| format!(" ({s})")).unwrap_or_default();
if let Some(ref err) = data.error {
return format!("[error: {err}]{stale_suffix}");
}
let mut parts = Vec::new();
if let Some(pct) = data.session_pct {
parts.push(format!("session {pct}%"))
}
if let Some(pct) = data.week_all_pct {
parts.push(format!("week {pct}%"))
}
if let Some(ref resets) = data.resets {
parts.push(format!("resets {resets}"));
}
if parts.is_empty() {
format!("(no usage data){stale_suffix}")
} else {
format!("{} {}", parts.join(" "), stale_suffix.trim_start())
.trim_end()
.to_string()
}
}
pub fn format_stale_age(age_secs: u64) -> String {
if age_secs < 60 * 60 {
let minutes = age_secs.div_ceil(60).max(1);
format!("stale {minutes}m ago")
} else if age_secs < 60 * 60 * 24 {
let hours = age_secs / 3600;
format!("stale {hours}h ago")
} else {
let days = age_secs / 86400;
format!("stale {days}d ago")
}
}
pub struct AccountPicker {
rows: Vec<AccountRow>,
}
impl AccountPicker {
pub fn new(rows: Vec<AccountRow>) -> Self {
Self { rows }
}
pub fn pick(&self) -> Option<String> {
if self.rows.is_empty() {
return None;
}
if !fzf_available() {
eprintln!(
"csm: hub usage fetch failed and fzf not available — keeping current profile"
);
return None;
}
let lines = self.build_fzf_input();
crate::picker::fzf::run_fzf(&lines, &Self::fzf_opts())
}
pub fn build_fzf_input(&self) -> Vec<String> {
self.rows.iter().map(AccountRow::to_tsv).collect()
}
pub fn fzf_opts() -> FzfOpts {
FzfOpts {
prompt: "account > ".to_string(),
with_nth: "2..".to_string(),
delimiter: "\t".to_string(),
height: "40%".to_string(),
extra_args: vec!["--reverse".to_string(), "--no-multi".to_string()],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_stale_age_under_one_hour() {
assert_eq!(format_stale_age(0), "stale 1m ago");
assert_eq!(format_stale_age(60), "stale 1m ago");
assert_eq!(format_stale_age(61), "stale 2m ago");
assert_eq!(format_stale_age(119), "stale 2m ago");
assert_eq!(format_stale_age(120), "stale 2m ago");
assert_eq!(format_stale_age(3540), "stale 59m ago");
assert_eq!(format_stale_age(3599), "stale 60m ago");
}
#[test]
fn format_stale_age_hours() {
assert_eq!(format_stale_age(3600), "stale 1h ago");
assert_eq!(format_stale_age(7200), "stale 2h ago");
assert_eq!(format_stale_age(86399), "stale 23h ago");
}
#[test]
fn format_stale_age_days() {
assert_eq!(format_stale_age(86400), "stale 1d ago");
assert_eq!(format_stale_age(86400 * 2), "stale 2d ago");
}
#[test]
fn account_row_to_tsv_col1_is_profile() {
let row = AccountRow {
profile: "home".to_string(),
display: "session 3% week 32%".to_string(),
};
let tsv = row.to_tsv();
let col1 = tsv.split('\t').next().unwrap();
assert_eq!(col1, "home");
}
#[test]
fn render_display_error() {
let data = StaleProfileData {
session_pct: None,
week_all_pct: None,
resets: None,
error: Some("no credentials".to_string()),
};
let d = render_display(&data, Some("stale 4m ago"));
assert!(d.starts_with("[error: no credentials]"), "got: {d}");
assert!(d.contains("stale 4m ago"), "got: {d}");
}
#[test]
fn render_display_no_data() {
let data = StaleProfileData {
session_pct: None,
week_all_pct: None,
resets: None,
error: None,
};
let d = render_display(&data, None);
assert_eq!(d, "(no usage data)");
}
#[test]
fn render_display_full() {
let data = StaleProfileData {
session_pct: Some(3),
week_all_pct: Some(32),
resets: Some("Jun 18 9pm".to_string()),
error: None,
};
let d = render_display(&data, Some("stale 4m ago"));
assert!(d.contains("session 3%"), "got: {d}");
assert!(d.contains("week 32%"), "got: {d}");
assert!(d.contains("resets Jun 18 9pm"), "got: {d}");
assert!(d.contains("stale 4m ago"), "got: {d}");
}
#[test]
fn render_display_partial_no_resets() {
let data = StaleProfileData {
session_pct: Some(50),
week_all_pct: None,
resets: None,
error: None,
};
let d = render_display(&data, None);
assert!(d.contains("session 50%"), "got: {d}");
assert!(!d.contains("week"), "should not include week: {d}");
assert!(!d.contains("resets"), "should not include resets: {d}");
}
#[test]
fn account_row_build_stale_annotation() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let old_mtime = now.saturating_sub(300); let data = StaleProfileData {
session_pct: Some(10),
week_all_pct: Some(20),
resets: None,
error: None,
};
let row = AccountRow::build("home", &data, Some(old_mtime));
assert_eq!(row.profile, "home");
assert!(row.display.contains("stale"), "got: {}", row.display);
assert!(
row.display.starts_with("home"),
"display must start with profile name, got: {}",
row.display
);
assert!(row.display.contains("session 10%"), "got: {}", row.display);
}
#[test]
fn build_display_leads_with_profile_name_even_on_error() {
let data = StaleProfileData {
session_pct: None,
week_all_pct: None,
resets: None,
error: Some("no credentials".to_string()),
};
let row = AccountRow::build("work", &data, None);
assert!(row.display.starts_with("work"), "got: {}", row.display);
assert!(
row.display.contains("[error: no credentials]"),
"got: {}",
row.display
);
assert_eq!(row.profile, "work");
let tsv = row.to_tsv();
assert_eq!(tsv.split('\t').next().unwrap(), "work");
}
#[test]
fn fzf_opts_account_picker() {
let opts = AccountPicker::fzf_opts();
assert_eq!(opts.prompt, "account > ");
assert_eq!(opts.with_nth, "2..");
assert_eq!(opts.delimiter, "\t");
}
#[test]
fn build_fzf_input_col1_is_profile() {
let rows = vec![
AccountRow {
profile: "home".to_string(),
display: "session 5%".to_string(),
},
AccountRow {
profile: "work".to_string(),
display: "session 80%".to_string(),
},
];
let picker = AccountPicker::new(rows);
let lines = picker.build_fzf_input();
assert_eq!(lines.len(), 2);
let profiles: Vec<&str> = lines
.iter()
.map(|l| l.split('\t').next().unwrap())
.collect();
assert_eq!(profiles[0], "home");
assert_eq!(profiles[1], "work");
}
}