use crate::error::{ComposeError, Result};
pub(super) fn select_replica(
mut names: Vec<String>,
service_name: &str,
index: Option<u32>,
) -> Result<String> {
names.sort_by_key(|n| {
n.rsplit_once('-')
.and_then(|(_, suffix)| suffix.parse::<u64>().ok())
.unwrap_or(0)
});
match index {
Some(i) => {
let idx = (i as usize).checked_sub(1).ok_or_else(|| {
ComposeError::ServiceNotFound(format!(
"{service_name} (replica index {i}: indexes are 1-based)"
))
})?;
names.get(idx).cloned().ok_or_else(|| {
ComposeError::ServiceNotFound(format!("{service_name} (replica index {i})"))
})
}
None => names
.into_iter()
.next()
.ok_or_else(|| ComposeError::ServiceNotFound(service_name.into())),
}
}
pub(super) fn parse_port_proto(
private_port: &str,
proto_flag: &str,
) -> Result<(u16, &'static str)> {
let (port_str, proto_str) = match private_port.split_once('/') {
Some((p, pr)) => (p, pr),
None => (private_port, proto_flag),
};
let port = parse_strict_port(port_str, private_port)?;
let proto = normalise_proto(proto_str)?;
Ok((port, proto))
}
fn parse_strict_port(port_str: &str, private_port: &str) -> Result<u16> {
let invalid = || {
ComposeError::InvalidPort(format!(
"port '{private_port}' is not a valid PORT or PORT/proto"
))
};
if port_str.is_empty() || !port_str.bytes().all(|b| b.is_ascii_digit()) {
return Err(invalid());
}
if port_str.len() > 1 && port_str.starts_with('0') {
return Err(invalid());
}
let port: u16 = port_str.parse().map_err(|_| invalid())?;
if port == 0 {
return Err(invalid());
}
Ok(port)
}
fn normalise_proto(proto: &str) -> Result<&'static str> {
if proto.eq_ignore_ascii_case("tcp") {
Ok("tcp")
} else if proto.eq_ignore_ascii_case("udp") {
Ok("udp")
} else {
Err(ComposeError::InvalidPort(format!(
"protocol '{proto}' is not valid (expected 'tcp' or 'udp')"
)))
}
}
pub(super) fn dedup_preserving_order(items: &[String]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
items
.iter()
.filter(|s| seen.insert(s.as_str()))
.cloned()
.collect()
}
pub(super) fn is_running_status(status: &str) -> bool {
status.eq_ignore_ascii_case("running")
}
pub(super) fn split_repo_tag(image_ref: &str) -> (String, String) {
if let Some((repo, _digest)) = image_ref.split_once('@') {
return (repo.to_string(), "<none>".to_string());
}
match image_ref.rsplit_once(':') {
Some((repo, tag)) if !tag.contains('/') => (repo.to_string(), tag.to_string()),
_ => (image_ref.to_string(), "latest".to_string()),
}
}
const TOP_SCAFFOLDING: [&str; 6] = ["UID", "PPID", "C", "STIME", "TTY", "TIME"];
pub(super) fn top_dim_columns(titles: &[String]) -> Vec<usize> {
titles
.iter()
.enumerate()
.filter(|(_, t)| TOP_SCAFFOLDING.contains(&t.as_str()))
.map(|(i, _)| i)
.collect()
}
pub(super) fn process_table(
titles: &[String],
processes: &[Vec<String>],
) -> Option<crate::ui::Table> {
if titles.is_empty() {
return None;
}
let headers: Vec<&str> = titles.iter().map(String::as_str).collect();
let mut table = crate::ui::Table::new(&headers).dim_cols(&top_dim_columns(titles));
for row in processes {
table.push(row.clone());
}
Some(table)
}
#[cfg(test)]
mod tests {
#[test]
fn top_escapes_control_characters_from_process_argv() {
let titles = vec!["PID".to_string(), "COMMAND".to_string()];
let processes = vec![vec!["1".to_string(), "\u{1b}[31mevil".to_string()]];
let out = super::process_table(&titles, &processes).unwrap().render();
assert!(
!out.iter().any(|line| line.contains('\u{1b}')),
"no raw escape may reach the terminal: {out:?}"
);
assert!(
out[1].contains("\\u{1b}") || out[1].contains("\\x1b") || out[1].contains("\\e"),
"the sequence must survive as visible text, not vanish: {out:?}"
);
}
use super::{
dedup_preserving_order, is_running_status, parse_port_proto, select_replica, split_repo_tag,
};
#[test]
fn select_replica_none_picks_first_by_suffix() {
let names = vec![
"proj-web-3".into(),
"proj-web-1".into(),
"proj-web-2".into(),
];
assert_eq!(select_replica(names, "web", None).unwrap(), "proj-web-1");
}
#[test]
fn select_replica_orders_suffix_numerically() {
let names = vec![
"proj-web-10".into(),
"proj-web-2".into(),
"proj-web-1".into(),
];
assert_eq!(
select_replica(names, "web", Some(3)).unwrap(),
"proj-web-10"
);
}
#[test]
fn select_replica_index_targets_nth() {
let names = vec!["proj-web-1".into(), "proj-web-2".into()];
assert_eq!(
select_replica(names.clone(), "web", Some(2)).unwrap(),
"proj-web-2"
);
}
#[test]
fn select_replica_unsuffixed_single() {
let names = vec!["proj-web".into()];
assert_eq!(select_replica(names, "web", None).unwrap(), "proj-web");
}
#[test]
fn select_replica_rejects_index_zero_and_out_of_range() {
let names = vec!["proj-web-1".into(), "proj-web-2".into()];
assert!(select_replica(names.clone(), "web", Some(0)).is_err());
assert!(select_replica(names, "web", Some(5)).is_err());
}
#[test]
fn select_replica_empty_is_not_found() {
assert!(select_replica(vec![], "web", None).is_err());
}
#[test]
fn split_repo_tag_plain_name_and_tag() {
assert_eq!(
split_repo_tag("nginx:1.25"),
("nginx".into(), "1.25".into())
);
assert_eq!(split_repo_tag("nginx"), ("nginx".into(), "latest".into()));
}
#[test]
fn split_repo_tag_registry_with_port_is_not_a_tag() {
assert_eq!(
split_repo_tag("registry:5000/team/app"),
("registry:5000/team/app".into(), "latest".into())
);
assert_eq!(
split_repo_tag("registry:5000/team/app:v2"),
("registry:5000/team/app".into(), "v2".into())
);
}
#[test]
fn split_repo_tag_digest_has_no_tag() {
let (repo, tag) = split_repo_tag(
"docker.io/library/alpine@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
);
assert_eq!(repo, "docker.io/library/alpine");
assert_eq!(tag, "<none>");
}
#[test]
fn dedup_preserving_order_keeps_first_occurrence() {
let out =
dedup_preserving_order(&["web".into(), "db".into(), "web".into(), "cache".into()]);
assert_eq!(out, vec!["web", "db", "cache"]);
}
#[test]
fn top_pads_columns_to_the_widest_cell() {
let titles = vec!["PID".to_string(), "CMD".to_string()];
let processes = vec![
vec!["1".to_string(), "bash".to_string()],
vec!["12345".to_string(), "node".to_string()],
];
let lines = super::process_table(&titles, &processes).unwrap().render();
assert_eq!(lines.len(), 3);
let second_col = |line: &str| {
line.find(|c: char| !c.is_whitespace()).map(|_| {
let first = line.split_whitespace().next().unwrap_or("");
line.find(first).unwrap_or(0)
+ first.len() + line[line.find(first).unwrap_or(0) + first.len()..]
.chars()
.take_while(|c| *c == ' ')
.count()
})
};
assert_eq!(second_col(&lines[0]), second_col(&lines[1]));
assert_eq!(second_col(&lines[0]), second_col(&lines[2]));
assert_eq!(second_col(&lines[0]), Some("12345".len() + 1));
assert!(lines.iter().all(|l| !l.contains('\t')));
}
#[test]
fn top_dims_the_bookkeeping_columns_only() {
let titles: Vec<String> = ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]
.iter()
.map(|s| (*s).to_string())
.collect();
let dim = super::top_dim_columns(&titles);
assert_eq!(dim, vec![0, 2, 3, 4, 5, 6]);
}
#[test]
fn top_leaves_an_unknown_column_alone() {
let titles = vec!["PID".to_string(), "RSS".to_string(), "CMD".to_string()];
assert!(super::top_dim_columns(&titles).is_empty());
}
#[test]
fn bare_port_uses_flag_proto() {
assert_eq!(parse_port_proto("80", "tcp").unwrap(), (80, "tcp"));
}
#[test]
fn suffix_overrides_flag_proto() {
assert_eq!(parse_port_proto("53/udp", "tcp").unwrap(), (53, "udp"));
}
#[test]
fn non_numeric_port_is_rejected() {
assert!(parse_port_proto("http", "tcp").is_err());
assert!(parse_port_proto("abc/tcp", "tcp").is_err());
}
#[test]
fn protocol_flag_is_case_insensitive() {
assert_eq!(parse_port_proto("80", "TCP").unwrap(), (80, "tcp"));
assert_eq!(parse_port_proto("80", "Udp").unwrap(), (80, "udp"));
assert_eq!(parse_port_proto("53/UDP", "tcp").unwrap(), (53, "udp"));
}
#[test]
fn unknown_or_empty_protocol_is_rejected() {
assert!(parse_port_proto("80", "sctp").is_err());
assert!(parse_port_proto("80", "").is_err());
assert!(parse_port_proto("80/sctp", "tcp").is_err());
}
#[test]
fn non_canonical_private_port_is_rejected() {
assert!(parse_port_proto("+80", "tcp").is_err());
assert!(parse_port_proto("-80", "tcp").is_err());
assert!(parse_port_proto("080", "tcp").is_err());
assert!(parse_port_proto(" 80", "tcp").is_err());
assert!(parse_port_proto("80 ", "tcp").is_err());
assert!(parse_port_proto("80/tcp/extra", "tcp").is_err());
assert!(parse_port_proto("0", "tcp").is_err());
assert!(parse_port_proto("65536", "tcp").is_err());
}
#[test]
fn dedup_keeps_first_occurrence_order() {
let input = ["web".to_string(), "web".to_string(), "db".to_string()];
assert_eq!(dedup_preserving_order(&input), vec!["web", "db"]);
let input = [
"a".to_string(),
"b".to_string(),
"a".to_string(),
"c".to_string(),
"b".to_string(),
];
assert_eq!(dedup_preserving_order(&input), vec!["a", "b", "c"]);
}
#[test]
fn running_status_detected_case_insensitively() {
assert!(is_running_status("running"));
assert!(is_running_status("Running"));
assert!(!is_running_status("exited"));
assert!(!is_running_status("created"));
assert!(!is_running_status("paused"));
assert!(!is_running_status(""));
}
}