use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use super::ModelVariant;
pub const MANIFEST_FILE: &str = "manifest.toml";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelManifest {
pub architecture: ModelVariant,
pub files: ManifestFiles,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestFiles {
pub encoder: String,
pub encoder_int8: Option<String>,
pub decoder: Option<String>,
pub joint: Option<String>,
pub vocab: String,
}
#[derive(Debug, Deserialize)]
struct RawManifest {
architecture: String,
files: RawFiles,
}
#[derive(Debug, Deserialize)]
struct RawFiles {
encoder: String,
#[serde(default)]
encoder_int8: Option<String>,
#[serde(default)]
decoder: Option<String>,
#[serde(default)]
joint: Option<String>,
vocab: String,
}
impl ModelManifest {
pub fn load(dir: &Path) -> Result<Option<Self>> {
let path = dir.join(MANIFEST_FILE);
if !path.is_file() {
return Ok(None);
}
let text = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read model manifest {}", path.display()))?;
Self::parse(&text)
.with_context(|| format!("invalid model manifest {}", path.display()))
.map(Some)
}
pub fn parse(text: &str) -> Result<Self> {
let raw: RawManifest =
toml::from_str(text).context("failed to parse model manifest TOML")?;
Self::from_raw(raw)
}
fn from_raw(raw: RawManifest) -> Result<Self> {
let architecture: ModelVariant = raw
.architecture
.parse()
.map_err(|e: String| anyhow::anyhow!("invalid architecture: {e}"))?;
let encoder = normalize_required_basename("encoder", &raw.files.encoder)?;
let vocab = normalize_required_basename("vocab", &raw.files.vocab)?;
let encoder_int8 = normalize_optional_basename("encoder_int8", raw.files.encoder_int8)?;
let decoder = normalize_optional_basename("decoder", raw.files.decoder)?;
let joint = normalize_optional_basename("joint", raw.files.joint)?;
if !architecture.is_ctc() {
if decoder.is_none() {
bail!(
"manifest files.decoder is required for architecture '{}'",
architecture.as_str()
);
}
if joint.is_none() {
bail!(
"manifest files.joint is required for architecture '{}'",
architecture.as_str()
);
}
}
Ok(Self {
architecture,
files: ManifestFiles {
encoder,
encoder_int8,
decoder,
joint,
vocab,
},
})
}
pub fn preferred_encoder_path(&self, dir: &Path) -> PathBuf {
if let Some(ref int8_name) = self.files.encoder_int8 {
let int8 = dir.join(int8_name);
if int8.exists() {
return int8;
}
}
dir.join(&self.files.encoder)
}
pub fn prefers_int8(&self, dir: &Path) -> bool {
self.files
.encoder_int8
.as_ref()
.is_some_and(|name| dir.join(name).exists())
}
pub fn decoder_path(&self, dir: &Path) -> Option<PathBuf> {
self.files.decoder.as_ref().map(|name| dir.join(name))
}
pub fn joint_path(&self, dir: &Path) -> Option<PathBuf> {
self.files.joint.as_ref().map(|name| dir.join(name))
}
pub fn vocab_path(&self, dir: &Path) -> PathBuf {
dir.join(&self.files.vocab)
}
}
fn normalize_required_basename(field: &str, value: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
bail!("manifest files.{field} must be a non-empty basename");
}
validate_basename(field, trimmed)?;
Ok(trimmed.to_string())
}
fn normalize_optional_basename(field: &str, value: Option<String>) -> Result<Option<String>> {
let Some(value) = value else {
return Ok(None);
};
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(None);
}
validate_basename(field, trimmed)?;
Ok(Some(trimmed.to_string()))
}
fn validate_basename(field: &str, value: &str) -> Result<()> {
if value.contains('/') || value.contains('\\') || value.contains("..") {
bail!(
"manifest files.{field} must be a basename (got '{value}'); \
paths and '..' are not allowed"
);
}
Ok(())
}
#[cfg(test)]
mod tests;