use std::collections::HashSet;
use std::time::Duration;
use crate::compose::types::{ComposeFile, Service};
use crate::error::{ComposeError, Result};
const STOP_GRACE_BUFFER_SECS: u64 = 30;
pub(super) fn service_grace_period_secs(service: &Service) -> i32 {
service
.stop_grace_period
.as_deref()
.and_then(crate::size::parse_duration_secs)
.and_then(|s| i32::try_from(s).ok())
.unwrap_or(10)
}
pub fn validate_stop_timeout(timeout: Option<i32>) -> Result<Option<i32>> {
match timeout {
Some(t) if t < -1 => Err(ComposeError::InvalidTimeout(t)),
other => Ok(other),
}
}
pub(super) fn stop_timeout_param(grace: i32) -> i64 {
if grace < 0 {
i64::from(i32::MAX)
} else {
i64::from(grace)
}
}
pub(super) fn stop_deadline(grace: i32) -> Option<Duration> {
if grace < 0 {
None
} else {
Some(Duration::from_secs(grace as u64 + STOP_GRACE_BUFFER_SECS))
}
}
impl crate::engine::Engine {
pub(super) fn grace_period_secs(&self, service: &Service) -> i32 {
self.stop_timeout
.unwrap_or_else(|| service_grace_period_secs(service))
}
}
pub(super) fn filter_services(
file: &ComposeFile,
order: Vec<String>,
target_services: &[String],
) -> Result<Vec<String>> {
if target_services.is_empty() {
return Ok(order);
}
for name in target_services {
if !file.services.contains_key(name) {
return Err(ComposeError::ServiceNotFound(name.clone()));
}
}
let set: std::collections::HashSet<&str> = target_services.iter().map(|s| s.as_str()).collect();
Ok(order
.into_iter()
.filter(|n| set.contains(n.as_str()))
.collect())
}
pub(super) fn validate_targets(file: &ComposeFile, target_services: &[String]) -> Result<()> {
for name in target_services {
if !file.services.contains_key(name) {
return Err(ComposeError::ServiceNotFound(name.clone()));
}
}
Ok(())
}
pub(super) fn expand_targets(
file: &ComposeFile,
target_services: &[String],
no_deps: bool,
) -> Option<HashSet<String>> {
if target_services.is_empty() {
return None;
}
let mut set = HashSet::new();
let mut stack: Vec<String> = target_services.to_vec();
while let Some(name) = stack.pop() {
if !set.insert(name.clone()) {
continue;
}
if !no_deps {
if let Some(service) = file.services.get(&name) {
for dep in service.depends_on.service_names() {
if !set.contains(&dep) {
stack.push(dep);
}
}
}
}
}
Some(set)
}
pub(super) fn in_started_set(target_set: &Option<HashSet<String>>, name: &str) -> bool {
match target_set {
None => true,
Some(set) => set.contains(name),
}
}
#[cfg(test)]
mod tests {
use super::{
expand_targets, filter_services, in_started_set, service_grace_period_secs, stop_deadline,
stop_timeout_param, validate_stop_timeout, validate_targets, STOP_GRACE_BUFFER_SECS,
};
use crate::compose::types::{ComposeFile, Service};
use crate::error::ComposeError;
use std::collections::HashSet;
use std::time::Duration;
#[test]
fn validate_stop_timeout_accepts_none_zero_and_positive() {
assert_eq!(validate_stop_timeout(None).unwrap(), None);
assert_eq!(validate_stop_timeout(Some(0)).unwrap(), Some(0));
assert_eq!(validate_stop_timeout(Some(30)).unwrap(), Some(30));
}
#[test]
fn validate_stop_timeout_accepts_minus_one_infinite() {
assert_eq!(validate_stop_timeout(Some(-1)).unwrap(), Some(-1));
}
#[test]
fn validate_stop_timeout_rejects_below_minus_one() {
let err = validate_stop_timeout(Some(-2)).unwrap_err();
assert!(matches!(err, ComposeError::InvalidTimeout(-2)));
assert!(validate_stop_timeout(Some(-100)).is_err());
}
#[test]
fn stop_timeout_param_passes_through_non_negative() {
assert_eq!(stop_timeout_param(0), 0);
assert_eq!(stop_timeout_param(10), 10);
}
#[test]
fn stop_timeout_param_maps_infinite_to_max() {
assert_eq!(stop_timeout_param(-1), i64::from(i32::MAX));
}
#[test]
fn stop_deadline_is_grace_plus_buffer() {
assert_eq!(
stop_deadline(10),
Some(Duration::from_secs(10 + STOP_GRACE_BUFFER_SECS))
);
assert_eq!(
stop_deadline(0),
Some(Duration::from_secs(STOP_GRACE_BUFFER_SECS))
);
}
#[test]
fn stop_deadline_infinite_is_none() {
assert_eq!(stop_deadline(-1), None);
}
#[test]
fn grace_period_defaults_to_ten_seconds() {
assert_eq!(service_grace_period_secs(&Service::default()), 10);
}
#[test]
fn grace_period_parses_duration() {
let svc = Service {
stop_grace_period: Some("90s".to_string()),
..Default::default()
};
assert_eq!(service_grace_period_secs(&svc), 90);
let svc = Service {
stop_grace_period: Some("2m".to_string()),
..Default::default()
};
assert_eq!(service_grace_period_secs(&svc), 120);
}
#[test]
fn grace_period_falls_back_on_unparseable() {
let svc = Service {
stop_grace_period: Some("not-a-duration".to_string()),
..Default::default()
};
assert_eq!(service_grace_period_secs(&svc), 10);
}
fn file_with_services(names: &[&str]) -> ComposeFile {
let mut file = ComposeFile::default();
for &name in names {
file.services.insert(name.to_string(), Service::default());
}
file
}
#[test]
fn filter_empty_target_returns_all() {
let file = file_with_services(&["a", "b", "c"]);
let order = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let result = filter_services(&file, order.clone(), &[]).unwrap();
assert_eq!(result, order);
}
#[test]
fn filter_target_subset_returns_intersection() {
let file = file_with_services(&["a", "b", "c"]);
let order = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let result = filter_services(&file, order, &["b".to_string()]).unwrap();
assert_eq!(result, vec!["b".to_string()]);
}
#[test]
fn filter_target_preserves_order() {
let file = file_with_services(&["a", "b", "c"]);
let order = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let result = filter_services(&file, order, &["c".to_string(), "a".to_string()]).unwrap();
assert_eq!(result, vec!["a".to_string(), "c".to_string()]);
}
#[test]
fn filter_unknown_service_returns_error() {
let file = file_with_services(&["a"]);
let order = vec!["a".to_string()];
let err = filter_services(&file, order, &["z".to_string()]).unwrap_err();
assert!(matches!(
err,
crate::error::ComposeError::ServiceNotFound(_)
));
}
#[test]
fn validate_targets_empty_is_ok() {
let file = file_with_services(&["a", "b"]);
assert!(validate_targets(&file, &[]).is_ok());
}
#[test]
fn validate_targets_known_names_ok() {
let file = file_with_services(&["a", "b"]);
assert!(validate_targets(&file, &["a".to_string(), "b".to_string()]).is_ok());
}
#[test]
fn validate_targets_unknown_name_errors() {
let file = file_with_services(&["a"]);
let err = validate_targets(&file, &["no-such-service".to_string()]).unwrap_err();
assert!(matches!(
err,
crate::error::ComposeError::ServiceNotFound(name) if name == "no-such-service"
));
}
fn file_web_depends_db() -> ComposeFile {
crate::parse_str(
"services:\n db:\n image: x\n web:\n image: x\n depends_on:\n - db\n",
)
.unwrap()
}
#[test]
fn expand_targets_empty_is_none() {
let file = file_web_depends_db();
assert!(expand_targets(&file, &[], false).is_none());
}
#[test]
fn expand_targets_includes_dependencies() {
let file = file_web_depends_db();
let set = expand_targets(&file, &["web".to_string()], false).unwrap();
assert!(set.contains("web"));
assert!(set.contains("db"));
}
#[test]
fn expand_targets_no_deps_excludes_dependencies() {
let file = file_web_depends_db();
let set = expand_targets(&file, &["web".to_string()], true).unwrap();
assert!(set.contains("web"));
assert!(!set.contains("db"));
}
#[test]
fn in_started_set_none_is_always_true() {
assert!(in_started_set(&None, "anything"));
}
#[test]
fn in_started_set_member_is_true() {
let set: HashSet<String> = ["web".to_string(), "db".to_string()].into_iter().collect();
assert!(in_started_set(&Some(set), "db"));
}
#[test]
fn in_started_set_excluded_dep_is_false() {
let file = file_web_depends_db();
let target_set = expand_targets(&file, &["web".to_string()], true);
assert!(in_started_set(&target_set, "web"));
assert!(!in_started_set(&target_set, "db"));
}
#[test]
fn in_started_set_partial_target_includes_transitive_dep() {
let file = file_web_depends_db();
let target_set = expand_targets(&file, &["web".to_string()], false);
assert!(in_started_set(&target_set, "web"));
assert!(in_started_set(&target_set, "db"));
}
}