use ff_rdp_core::TabInfo;
use crate::error::AppError;
use crate::port_owner;
#[cfg(test)]
pub fn resolve_tab<'a>(
tabs: &'a [TabInfo],
tab: Option<&str>,
tab_id: Option<&str>,
) -> Result<&'a TabInfo, AppError> {
resolve_tab_with_context(tabs, tab, tab_id, "localhost", 0)
}
pub fn resolve_tab_with_context<'a>(
tabs: &'a [TabInfo],
tab: Option<&str>,
tab_id: Option<&str>,
host: &str,
port: u16,
) -> Result<&'a TabInfo, AppError> {
if let Some(id) = tab_id {
return tabs.iter().find(|t| t.actor.as_ref() == id).ok_or_else(|| {
AppError::User(format!(
"no tab with actor ID '{id}'; use `ff-rdp tabs` to list available tabs.\n\
hint: run `ff-rdp doctor` if the actor ID was issued by a stale connection."
))
});
}
if let Some(selector) = tab {
if let Ok(n) = selector.parse::<usize>() {
let count = tabs.len();
return if count == 0 {
Err(AppError::User(no_tabs_message(host, port)))
} else if n == 0 || n > count {
Err(AppError::User(format!(
"tab index {n} out of range (1–{count} tabs available); use `ff-rdp tabs` to list available tabs.\n\
hint: run `ff-rdp doctor` if the tab list looks wrong."
)))
} else {
Ok(&tabs[n - 1])
};
}
let lower = selector.to_lowercase();
return tabs
.iter()
.find(|t| t.url.to_lowercase().contains(&lower))
.ok_or_else(|| {
AppError::User(format!(
"no tab matching URL pattern '{selector}'; use `ff-rdp tabs` to list available tabs.\n\
hint: run `ff-rdp doctor` if the tab list looks wrong."
))
});
}
if tabs.is_empty() {
return Err(AppError::User(no_tabs_message(host, port)));
}
Ok(tabs.iter().find(|t| t.selected).unwrap_or(&tabs[0]))
}
fn no_tabs_message(host: &str, port: u16) -> String {
if port == 0 {
return "no tabs available — is a page open in Firefox? Use `ff-rdp launch --headless --temp-profile` to start one.\n\
hint: run `ff-rdp doctor` for a full diagnostic."
.to_owned();
}
if !crate::connection_meta::is_loopback(host) {
return format!(
"no tabs available — connected {host}:{port} exposes 0 debuggable tabs. \
Open a tab in Firefox or relaunch with `ff-rdp launch --temp-profile` for a clean session.\n\
hint: run `ff-rdp doctor` for a full diagnostic."
);
}
let in_use = port_owner::is_port_in_use(port);
if !in_use {
return format!(
"no tabs available — nothing is listening on {host}:{port}. \
Use `ff-rdp launch --headless --temp-profile` to start Firefox.\n\
hint: run `ff-rdp doctor` for a full diagnostic."
);
}
let owner = port_owner::find_listener(port).ok().flatten();
let detail = match owner {
Some(o) if !o.process_name.is_empty() => {
let uptime = match o.uptime_s {
Some(s) => format!(" (uptime {})", format_uptime_short(s)),
None => String::new(),
};
format!(" ({} PID {}){}", o.process_name, o.pid, uptime)
}
Some(o) => format!(" (PID {})", o.pid),
None => String::new(),
};
format!(
"no tabs available — connected Firefox{detail} exposes 0 debuggable tabs. \
Open a tab manually or relaunch with `ff-rdp launch --temp-profile` for a clean session.\n\
hint: run `ff-rdp doctor` to see why this connection has no tabs."
)
}
pub(crate) fn format_uptime_short(secs: u64) -> String {
let days = secs / 86400;
let hours = (secs % 86400) / 3600;
let mins = (secs % 3600) / 60;
if days > 0 {
format!("{days}d{hours}h")
} else if hours > 0 {
format!("{hours}h{mins}m")
} else {
format!("{mins}m")
}
}
#[cfg(test)]
mod tests {
use ff_rdp_core::types::ActorId;
use super::*;
fn make_tab(actor: &str, url: &str, selected: bool) -> TabInfo {
TabInfo {
actor: ActorId::from(actor),
title: String::new(),
url: url.to_owned(),
selected,
browsing_context_id: None,
}
}
fn tabs() -> Vec<TabInfo> {
vec![
make_tab("server1.conn0.tab1", "https://github.com/rust-lang", false),
make_tab("server1.conn0.tab2", "https://crates.io", true),
make_tab("server1.conn0.tab3", "https://docs.rs/tokio", false),
]
}
#[test]
fn tab_id_exact_match() {
let ts = tabs();
let result = resolve_tab(&ts, None, Some("server1.conn0.tab2")).unwrap();
assert_eq!(result.actor.as_ref(), "server1.conn0.tab2");
}
#[test]
fn tab_id_not_found_returns_error() {
let ts = tabs();
let err = resolve_tab(&ts, None, Some("server1.conn0.tab99")).unwrap_err();
match err {
AppError::User(msg) => {
assert!(msg.contains("server1.conn0.tab99"), "message: {msg}");
}
other => panic!("expected AppError::User, got: {other}"),
}
}
#[test]
fn tab_index_first() {
let ts = tabs();
let result = resolve_tab(&ts, Some("1"), None).unwrap();
assert_eq!(result.actor.as_ref(), "server1.conn0.tab1");
}
#[test]
fn tab_index_last() {
let ts = tabs();
let result = resolve_tab(&ts, Some("3"), None).unwrap();
assert_eq!(result.actor.as_ref(), "server1.conn0.tab3");
}
#[test]
fn tab_index_out_of_range() {
let ts = tabs();
let err = resolve_tab(&ts, Some("5"), None).unwrap_err();
match err {
AppError::User(msg) => {
assert!(msg.contains('5'), "message: {msg}");
assert!(msg.contains('3'), "message: {msg}");
}
other => panic!("expected AppError::User, got: {other}"),
}
}
#[test]
fn tab_index_zero_is_out_of_range() {
let ts = tabs();
let err = resolve_tab(&ts, Some("0"), None).unwrap_err();
match err {
AppError::User(msg) => assert!(msg.contains('0'), "message: {msg}"),
other => panic!("expected AppError::User, got: {other}"),
}
}
#[test]
fn tab_url_substring_match() {
let ts = tabs();
let result = resolve_tab(&ts, Some("crates"), None).unwrap();
assert_eq!(result.actor.as_ref(), "server1.conn0.tab2");
}
#[test]
fn tab_url_substring_case_insensitive() {
let ts = tabs();
let result = resolve_tab(&ts, Some("GitHub"), None).unwrap();
assert_eq!(result.actor.as_ref(), "server1.conn0.tab1");
}
#[test]
fn tab_url_substring_no_match_returns_error() {
let ts = tabs();
let err = resolve_tab(&ts, Some("wikipedia"), None).unwrap_err();
match err {
AppError::User(msg) => assert!(msg.contains("wikipedia"), "message: {msg}"),
other => panic!("expected AppError::User, got: {other}"),
}
}
#[test]
fn default_returns_selected_tab() {
let ts = tabs();
let result = resolve_tab(&ts, None, None).unwrap();
assert_eq!(result.actor.as_ref(), "server1.conn0.tab2");
}
#[test]
fn default_falls_back_to_first_when_none_selected() {
let ts = vec![
make_tab("server1.conn0.tab1", "https://example.com", false),
make_tab("server1.conn0.tab2", "https://rust-lang.org", false),
];
let result = resolve_tab(&ts, None, None).unwrap();
assert_eq!(result.actor.as_ref(), "server1.conn0.tab1");
}
#[test]
fn default_empty_tabs_returns_error() {
let err = resolve_tab(&[], None, None).unwrap_err();
match err {
AppError::User(msg) => assert!(msg.contains("Firefox"), "message: {msg}"),
other => panic!("expected AppError::User, got: {other}"),
}
}
#[test]
fn tab_id_takes_precedence_over_tab() {
let ts = tabs();
let result = resolve_tab(&ts, Some("1"), Some("server1.conn0.tab3")).unwrap();
assert_eq!(result.actor.as_ref(), "server1.conn0.tab3");
}
#[test]
fn format_uptime_short_handles_minutes() {
assert_eq!(format_uptime_short(120), "2m");
}
#[test]
fn format_uptime_short_handles_hours() {
assert_eq!(format_uptime_short(3700), "1h1m");
}
#[test]
fn format_uptime_short_handles_days() {
assert_eq!(format_uptime_short(90_000), "1d1h");
}
}