use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::error::{BosunError, Result};
use crate::tmux::session::TmuxSession;
pub const LIST_SESSIONS_FORMAT: &str = "#{session_name}|||#{session_windows}|||#{session_attached}|||#{session_created}|||#{session_activity}|||#{session_path}|||#{@bosun_display}|||#{@bosun_agent}|||#{@bosun_path}|||#{@bosun_container_id}";
const FIELD_SEP: &str = "|||";
pub fn parse_list_sessions(input: &str) -> Result<Vec<TmuxSession>> {
let mut out = Vec::new();
for (idx, line) in input.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
out.push(parse_session_line(line).map_err(|e| {
BosunError::Parse(format!("line {}: {} — raw: {:?}", idx + 1, e, line))
})?);
}
Ok(out)
}
fn parse_session_line(line: &str) -> std::result::Result<TmuxSession, String> {
let mut parts = line.split(FIELD_SEP);
let name = parts
.next()
.ok_or_else(|| "missing session name".to_string())?
.to_string();
let windows_raw = parts
.next()
.ok_or_else(|| "missing session_windows".to_string())?;
let attached_raw = parts
.next()
.ok_or_else(|| "missing session_attached".to_string())?;
let created_raw = parts
.next()
.ok_or_else(|| "missing session_created".to_string())?;
let activity_raw = parts
.next()
.ok_or_else(|| "missing session_activity".to_string())?;
let path = parts.next().map(|s| s.to_string());
let display_raw = parts.next().map(|s| s.to_string());
let agent_raw = parts.next().map(|s| s.to_string());
let spec_path_raw = parts.next().map(|s| s.to_string());
let container_id_raw = parts.next().map(|s| s.to_string());
if parts.next().is_some() {
return Err("unexpected extra field".into());
}
let windows: u32 = windows_raw
.parse()
.map_err(|e| format!("session_windows '{}': {}", windows_raw, e))?;
let attached = match attached_raw {
"0" => false,
"1" => true,
other => other.parse::<u32>().map(|n| n > 0).unwrap_or(false),
};
let created = parse_epoch(created_raw);
let last_activity = parse_epoch(activity_raw);
Ok(TmuxSession {
name,
windows,
attached,
created,
last_activity,
current_path: path.filter(|p| !p.is_empty()),
display_name: display_raw.filter(|s| !s.is_empty()),
agent: agent_raw.filter(|s| !s.is_empty()),
spec_path: spec_path_raw.filter(|s| !s.is_empty()),
container_id: container_id_raw.filter(|s| !s.is_empty()),
})
}
fn parse_epoch(s: &str) -> Option<SystemTime> {
if s.is_empty() {
return None;
}
let secs: u64 = s.parse().ok()?;
Some(UNIX_EPOCH + Duration::from_secs(secs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_empty_input() {
assert!(parse_list_sessions("").unwrap().is_empty());
}
#[test]
fn parses_single_session() {
let line = "main|||3|||1|||1712000000|||1712003600|||/tmp/code";
let sessions = parse_list_sessions(line).unwrap();
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!(s.name, "main");
assert_eq!(s.windows, 3);
assert!(s.attached);
assert_eq!(s.current_path.as_deref(), Some("/tmp/code"));
assert!(s.created.is_some());
assert!(s.last_activity.is_some());
}
#[test]
fn parses_multiple_sessions() {
let input = concat!(
"alpha|||1|||0|||1700000000|||1700000100|||/tmp\n",
"beta|||2|||1|||1700001000|||1700002000|||/home/rhuk\n",
"gamma|||5|||0|||1700003000|||1700004000|||\n",
);
let sessions = parse_list_sessions(input).unwrap();
assert_eq!(sessions.len(), 3);
assert_eq!(sessions[0].name, "alpha");
assert!(!sessions[0].attached);
assert_eq!(sessions[1].name, "beta");
assert!(sessions[1].attached);
assert_eq!(sessions[2].name, "gamma");
assert!(sessions[2].current_path.is_none());
}
#[test]
fn names_with_special_chars_survive_separator() {
let line = "work: proj | v2|||1|||1|||1700000000|||1700000100|||/srv";
let sessions = parse_list_sessions(line).unwrap();
assert_eq!(sessions[0].name, "work: proj | v2");
}
#[test]
fn unicode_name_preserved() {
let line = "日本語セッション|||1|||0|||1700000000|||1700000100|||/tmp";
let sessions = parse_list_sessions(line).unwrap();
assert_eq!(sessions[0].name, "日本語セッション");
}
#[test]
fn attached_client_count_treated_as_bool() {
let line = "multi|||1|||3|||1700000000|||1700000100|||/tmp";
let sessions = parse_list_sessions(line).unwrap();
assert!(sessions[0].attached);
}
#[test]
fn empty_lines_skipped() {
let input = "\nalpha|||1|||0|||1700000000|||1700000100|||/tmp\n\n";
let sessions = parse_list_sessions(input).unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].name, "alpha");
}
#[test]
fn malformed_line_errors_with_line_number() {
let input = "alpha|||1|||0\nbroken_but_no_seps";
let err = parse_list_sessions(input).unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("line 1") || msg.contains("line 2"),
"msg was: {}",
msg
);
}
#[test]
fn missing_activity_ok_but_none() {
let line = "alpha|||1|||0|||1700000000||||||/tmp";
let sessions = parse_list_sessions(line).unwrap();
assert!(sessions[0].created.is_some());
assert!(sessions[0].last_activity.is_none());
}
#[test]
fn bosun_user_options_parse_when_present() {
let line = "bosun-foo|||1|||0|||1700000000|||1700000100|||/srv|||foo|||claude|||~/proj";
let sessions = parse_list_sessions(line).unwrap();
assert_eq!(sessions[0].display_name.as_deref(), Some("foo"));
assert_eq!(sessions[0].agent.as_deref(), Some("claude"));
assert_eq!(sessions[0].spec_path.as_deref(), Some("~/proj"));
}
#[test]
fn missing_bosun_options_are_none_not_error() {
let line = "plain|||1|||0|||1700000000|||1700000100|||/srv|||||||||";
let sessions = parse_list_sessions(line).unwrap();
assert!(sessions[0].display_name.is_none());
assert!(sessions[0].agent.is_none());
assert!(sessions[0].spec_path.is_none());
}
}