use std::time::{SystemTime, UNIX_EPOCH};
use crate::picker::engine::{self, PickerOpts, PickerOutcome};
#[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>,
}
pub const RECOMMENDED_MARKER: &str = "★ ";
pub const PLAIN_MARKER: &str = " ";
#[derive(Debug, Clone)]
pub struct AccountRow {
pub profile: String,
pub display: String,
pub recommended: bool,
}
impl AccountRow {
pub fn to_tsv(&self) -> String {
let marker = if self.recommended {
RECOMMENDED_MARKER
} else {
PLAIN_MARKER
};
format!("{}\t{}{}", self.profile, marker, self.display)
}
pub fn build(
profile: &str,
data: &StaleProfileData,
cache_mtime_secs: Option<u64>,
recommended: bool,
) -> 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,
recommended,
}
}
}
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) -> PickerOutcome {
if self.rows.is_empty() {
return PickerOutcome::Unavailable;
}
if !engine::terminal_available() {
eprintln!(
"csm: hub usage fetch failed and no interactive terminal — keeping current profile"
);
return PickerOutcome::Unavailable;
}
let lines = self.build_picker_input();
engine::run_picker(&lines, &Self::picker_opts())
}
pub fn build_picker_input(&self) -> Vec<String> {
self.rows.iter().map(AccountRow::to_tsv).collect()
}
pub fn picker_opts() -> PickerOpts {
PickerOpts {
prompt: "account > ".to_string(),
display_from: 2,
delimiter: '\t',
}
}
}
#[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(),
recommended: false,
};
let tsv = row.to_tsv();
let col1 = tsv.split('\t').next().unwrap();
assert_eq!(col1, "home");
}
#[test]
fn to_tsv_recommended_row_gets_star_marker() {
let row = AccountRow {
profile: "home".to_string(),
display: "session 3% week 32%".to_string(),
recommended: true,
};
let tsv = row.to_tsv();
assert_eq!(tsv.split('\t').next().unwrap(), "home");
let col2 = tsv.split('\t').nth(1).unwrap();
assert!(col2.starts_with(RECOMMENDED_MARKER), "got: {col2}");
assert!(col2.contains("session 3%"), "got: {col2}");
}
#[test]
fn to_tsv_plain_row_gets_blank_marker_same_width() {
let row = AccountRow {
profile: "home".to_string(),
display: "session 3%".to_string(),
recommended: false,
};
let col2 = row.to_tsv().split('\t').nth(1).unwrap().to_string();
assert!(col2.starts_with(PLAIN_MARKER), "got: {col2}");
assert!(
!col2.contains('★'),
"plain row must not have a star: {col2}"
);
assert_eq!(
RECOMMENDED_MARKER.chars().count(),
PLAIN_MARKER.chars().count()
);
}
#[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), false);
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, false);
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 picker_opts_account_picker() {
let opts = AccountPicker::picker_opts();
assert_eq!(opts.prompt, "account > ");
assert_eq!(opts.display_from, 2);
assert_eq!(opts.delimiter, '\t');
}
#[test]
fn build_picker_input_col1_is_profile() {
let rows = vec![
AccountRow {
profile: "home".to_string(),
display: "session 5%".to_string(),
recommended: true,
},
AccountRow {
profile: "work".to_string(),
display: "session 80%".to_string(),
recommended: false,
},
];
let picker = AccountPicker::new(rows);
let lines = picker.build_picker_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");
}
}