use std::fs;
use std::path::{Path, PathBuf};
use prost::Message as _;
use crate::broker::lifecycle::names::validate_service_name;
use crate::broker::server::service_def_loader::ServiceDefinitionError;
use super::io::{service_definition_dir_v2, service_definition_path_v2, SERVICE_DEF_V2_EXTENSION};
use super::ServiceDefinition;
#[derive(Clone, Debug)]
pub struct ServiceDefinitionLoader {
root: PathBuf,
}
impl ServiceDefinitionLoader {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
#[must_use]
pub fn default_root() -> Self {
Self::new(service_definition_dir_v2())
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn load(&self, service_name: &str) -> Result<ServiceDefinition, ServiceDefinitionError> {
let path = service_definition_path_v2(&self.root, service_name)?;
let bytes = fs::read(&path)?;
let definition = ServiceDefinition::decode(bytes.as_slice())?;
validate_loaded_definition(&definition, service_name)?;
Ok(definition)
}
pub fn reload(&self, service_name: &str) -> Result<ServiceDefinition, ServiceDefinitionError> {
self.load(service_name)
}
pub fn lookup_or_reload(
&self,
service_name: &str,
) -> Result<ServiceDefinition, ServiceDefinitionError> {
self.load(service_name)
}
#[must_use]
pub fn enumerate(&self) -> Vec<ServiceDefinition> {
self.scan()
.into_iter()
.filter_map(|entry| entry.result.ok())
.collect()
}
#[must_use]
pub fn scan(&self) -> Vec<ServiceDefinitionScanEntry> {
let read_dir = match fs::read_dir(&self.root) {
Ok(rd) => rd,
Err(_) => return Vec::new(),
};
let mut out: Vec<ServiceDefinitionScanEntry> = Vec::new();
for entry in read_dir.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
let suffix = format!(".{SERVICE_DEF_V2_EXTENSION}");
let Some(stem) = name.strip_suffix(&suffix) else {
continue;
};
let result = self.load_from_path(&path, stem);
out.push(ServiceDefinitionScanEntry { path, result });
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
fn load_from_path(
&self,
path: &Path,
filename_service: &str,
) -> Result<ServiceDefinition, ServiceDefinitionError> {
let bytes = fs::read(path)?;
let definition = ServiceDefinition::decode(bytes.as_slice())?;
validate_loaded_definition(&definition, filename_service)?;
Ok(definition)
}
}
#[derive(Debug)]
pub struct ServiceDefinitionScanEntry {
pub path: PathBuf,
pub result: Result<ServiceDefinition, ServiceDefinitionError>,
}
fn validate_loaded_definition(
definition: &ServiceDefinition,
expected_service: &str,
) -> Result<(), ServiceDefinitionError> {
validate_service_name(expected_service)?;
if definition.service_name != expected_service {
return Err(ServiceDefinitionError::ServiceNameMismatch {
requested: expected_service.to_owned(),
actual: definition.service_name.clone(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::broker::protocol_v2::{BrokerIsolation, ServiceDefinitionBuilder};
use tempfile::tempdir;
fn install_test_servicedef(root: &Path, name: &str) -> PathBuf {
ServiceDefinitionBuilder::shared_broker(name, "/usr/bin/zccache-daemon")
.min_version("1.0.0")
.label("env", "test")
.install_in(root)
.expect("install_in")
}
#[test]
fn load_round_trips_an_installed_servicedef() {
let dir = tempdir().expect("tempdir");
install_test_servicedef(dir.path(), "zccache");
let loader = ServiceDefinitionLoader::new(dir.path());
let loaded = loader.load("zccache").expect("load");
assert_eq!(loaded.service_name, "zccache");
assert_eq!(loaded.binary_path, "/usr/bin/zccache-daemon");
assert_eq!(loaded.isolation, BrokerIsolation::SharedBroker as i32);
assert_eq!(loaded.min_version, "1.0.0");
assert_eq!(loaded.labels.get("env").map(String::as_str), Some("test"));
}
#[test]
fn load_returns_io_error_for_missing_file() {
let dir = tempdir().expect("tempdir");
let loader = ServiceDefinitionLoader::new(dir.path());
let err = loader.load("no-such-service").expect_err("must Err");
assert!(
matches!(err, ServiceDefinitionError::Io(_)),
"missing file → Io, got: {err:?}"
);
}
#[test]
fn load_rejects_invalid_service_name() {
let dir = tempdir().expect("tempdir");
let loader = ServiceDefinitionLoader::new(dir.path());
for bad in ["BAD-Caps", "", "a/b", "x\0y"] {
let err = loader.load(bad).expect_err("must Err");
assert!(
matches!(err, ServiceDefinitionError::InvalidName(_)),
"{bad:?} → InvalidName, got: {err:?}"
);
}
}
#[test]
fn load_detects_filename_service_mismatch() {
let dir = tempdir().expect("tempdir");
install_test_servicedef(dir.path(), "zccache");
let original = dir.path().join("zccache.servicedef.v2");
let renamed = dir.path().join("other.servicedef.v2");
fs::rename(&original, &renamed).expect("rename");
let loader = ServiceDefinitionLoader::new(dir.path());
let err = loader.load("other").expect_err("mismatch must Err");
assert!(
matches!(
err,
ServiceDefinitionError::ServiceNameMismatch { ref requested, ref actual }
if requested == "other" && actual == "zccache"
),
"expected ServiceNameMismatch, got: {err:?}"
);
}
#[test]
fn load_rejects_corrupt_protobuf_bytes() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("badproto.servicedef.v2");
fs::write(&path, b"\x01\x02\x03\x04\x05\xFF\xFF\xFF\xFF\x00").expect("write");
let loader = ServiceDefinitionLoader::new(dir.path());
let result = loader.load("badproto");
assert!(
matches!(
result,
Err(ServiceDefinitionError::Decode(_))
| Err(ServiceDefinitionError::ServiceNameMismatch { .. })
),
"corrupt proto must Err, got: {result:?}"
);
}
#[test]
fn enumerate_returns_every_parseable_servicedef() {
let dir = tempdir().expect("tempdir");
install_test_servicedef(dir.path(), "zccache");
install_test_servicedef(dir.path(), "fbuild");
install_test_servicedef(dir.path(), "soldr");
let loader = ServiceDefinitionLoader::new(dir.path());
let defs = loader.enumerate();
assert_eq!(defs.len(), 3);
let names: std::collections::HashSet<String> =
defs.iter().map(|d| d.service_name.clone()).collect();
assert!(names.contains("zccache"));
assert!(names.contains("fbuild"));
assert!(names.contains("soldr"));
}
#[test]
fn enumerate_skips_corrupt_files_silently() {
let dir = tempdir().expect("tempdir");
install_test_servicedef(dir.path(), "zccache");
fs::write(
dir.path().join("corrupt.servicedef.v2"),
b"\xFF\xFF\xFF\xFF",
)
.expect("write corrupt");
let loader = ServiceDefinitionLoader::new(dir.path());
let defs = loader.enumerate();
assert_eq!(defs.len(), 1, "only zccache should be returned");
assert_eq!(defs[0].service_name, "zccache");
}
#[test]
fn scan_surfaces_per_file_errors() {
let dir = tempdir().expect("tempdir");
install_test_servicedef(dir.path(), "zccache");
fs::write(
dir.path().join("corrupt.servicedef.v2"),
b"\xFF\xFF\xFF\xFF",
)
.expect("write corrupt");
let loader = ServiceDefinitionLoader::new(dir.path());
let entries = loader.scan();
assert_eq!(entries.len(), 2);
let ok_count = entries.iter().filter(|e| e.result.is_ok()).count();
let err_count = entries.iter().filter(|e| e.result.is_err()).count();
assert_eq!(ok_count, 1);
assert_eq!(err_count, 1);
}
#[test]
fn enumerate_ignores_files_with_wrong_extension() {
let dir = tempdir().expect("tempdir");
install_test_servicedef(dir.path(), "zccache");
fs::write(dir.path().join("legacy.servicedef"), b"junk").expect("write legacy");
fs::write(dir.path().join("readme.txt"), b"hello").expect("write readme");
let loader = ServiceDefinitionLoader::new(dir.path());
let defs = loader.enumerate();
assert_eq!(
defs.len(),
1,
"only the .servicedef.v2 file should be loaded"
);
assert_eq!(defs[0].service_name, "zccache");
}
#[test]
fn enumerate_handles_missing_root_gracefully() {
let loader = ServiceDefinitionLoader::new("/nonexistent/path/to/services");
let defs = loader.enumerate();
assert!(defs.is_empty(), "missing root → empty result");
let entries = loader.scan();
assert!(entries.is_empty(), "missing root → empty scan");
}
#[test]
fn reload_is_equivalent_to_load() {
let dir = tempdir().expect("tempdir");
install_test_servicedef(dir.path(), "zccache");
let loader = ServiceDefinitionLoader::new(dir.path());
let a = loader.load("zccache").expect("load");
let b = loader.reload("zccache").expect("reload");
let c = loader.lookup_or_reload("zccache").expect("lookup_or_reload");
assert_eq!(a, b);
assert_eq!(b, c);
}
}