use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::latent::Metadata;
use crate::time::SampleRate;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SourceKind {
Tabular,
TimeSeries,
Embeddings,
}
impl std::fmt::Display for SourceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
SourceKind::Tabular => "tabular",
SourceKind::TimeSeries => "time_series",
SourceKind::Embeddings => "embeddings",
};
f.write_str(s)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct ItemPath(PathBuf);
impl ItemPath {
pub fn new(path: impl Into<String>) -> Result<Self> {
let path = PathBuf::from(path.into());
if path.as_os_str().is_empty() {
return Err(Error::validation("item path must not be empty"));
}
let mut saw_normal = false;
for component in path.components() {
match component {
Component::Normal(_) => saw_normal = true,
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(Self::escape_error(&path));
}
}
}
if !saw_normal {
return Err(Self::escape_error(&path));
}
Ok(Self(path))
}
pub fn join(&self, root: &Path) -> PathBuf {
root.join(&self.0)
}
pub fn as_path(&self) -> &Path {
&self.0
}
fn escape_error(path: &Path) -> Error {
Error::validation(format!(
"path must be root-relative without parent-directory escape: {}",
path.display()
))
}
}
impl std::fmt::Display for ItemPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.display())
}
}
impl TryFrom<String> for ItemPath {
type Error = Error;
fn try_from(path: String) -> Result<Self> {
Self::new(path)
}
}
impl<'de> Deserialize<'de> for ItemPath {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
ItemPath::new(raw).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawDatasetManifest {
pub dataset_id: String,
pub version: String,
pub source_type: SourceKind,
#[serde(default)]
pub paired_audio: bool,
pub sample_rate: u32,
#[serde(default)]
pub items: Vec<RawDatasetItem>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawDatasetItem {
pub id: String,
pub source_path: ItemPath,
#[serde(default)]
pub audio_path: Option<ItemPath>,
#[serde(default)]
pub duration_seconds: Option<f32>,
#[serde(default)]
pub metadata: Metadata,
}
impl RawDatasetManifest {
pub fn from_json_slice(bytes: &[u8]) -> Result<Self> {
Ok(serde_json::from_slice(bytes)?)
}
pub fn validate(self) -> Result<Manifest> {
let sample_rate = SampleRate::try_from(self.sample_rate)?;
let mut seen = HashSet::with_capacity(self.items.len());
let mut items = Vec::with_capacity(self.items.len());
for item in self.items {
if item.id.trim().is_empty() {
return Err(Error::validation("dataset item has an empty id"));
}
if !seen.insert(item.id.clone()) {
return Err(Error::validation(format!("duplicate item id: {}", item.id)));
}
if self.paired_audio && item.audio_path.is_none() {
return Err(Error::validation(format!(
"item {} is missing an audio_path but the dataset is paired",
item.id
)));
}
let duration = match item.duration_seconds {
Some(d) if d <= 0.0 || !d.is_finite() => {
return Err(Error::validation(format!(
"item {} has non-positive duration {d}",
item.id
)));
}
other => other,
};
items.push(ManifestItem {
id: item.id,
source_path: item.source_path,
audio_path: item.audio_path,
duration_seconds: duration,
metadata: item.metadata,
});
}
Ok(Manifest {
dataset_id: self.dataset_id,
version: self.version,
source_type: self.source_type,
paired_audio: self.paired_audio,
sample_rate,
items,
})
}
}
#[derive(Clone, Debug, Serialize)]
pub struct Manifest {
dataset_id: String,
version: String,
source_type: SourceKind,
paired_audio: bool,
sample_rate: SampleRate,
items: Vec<ManifestItem>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ManifestItem {
id: String,
source_path: ItemPath,
audio_path: Option<ItemPath>,
duration_seconds: Option<f32>,
metadata: Metadata,
}
impl Manifest {
pub fn dataset_id(&self) -> &str {
&self.dataset_id
}
pub fn version(&self) -> &str {
&self.version
}
pub fn source_type(&self) -> SourceKind {
self.source_type
}
pub fn paired_audio(&self) -> bool {
self.paired_audio
}
pub fn sample_rate(&self) -> SampleRate {
self.sample_rate
}
pub fn items(&self) -> &[ManifestItem] {
&self.items
}
}
impl ManifestItem {
pub fn id(&self) -> &str {
&self.id
}
pub fn source_path(&self) -> &ItemPath {
&self.source_path
}
pub fn audio_path(&self) -> Option<&ItemPath> {
self.audio_path.as_ref()
}
pub fn duration_seconds(&self) -> Option<f32> {
self.duration_seconds
}
pub fn metadata(&self) -> &Metadata {
&self.metadata
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_prd_example() {
let json = r#"{
"dataset_id": "sensor_texture_demo_v1",
"version": "1.0.0",
"source_type": "time_series",
"paired_audio": true,
"sample_rate": 44100,
"items": [
{
"id": "item_0001",
"source_path": "source/item_0001.parquet",
"audio_path": "audio/item_0001.wav",
"duration_seconds": 10.0,
"metadata": { "class": "normal", "split": "train" }
}
]
}"#;
let raw = RawDatasetManifest::from_json_slice(json.as_bytes()).expect("parse");
assert_eq!(raw.source_type, SourceKind::TimeSeries);
let m = raw.validate().expect("validate");
assert_eq!(m.items().len(), 1);
assert_eq!(m.items()[0].id(), "item_0001");
assert!(m.paired_audio());
assert_eq!(m.sample_rate().get(), 44_100);
assert_eq!(m.source_type(), SourceKind::TimeSeries);
}
#[test]
fn deny_unknown_fields_rejects_typos() {
let json = r#"{
"dataset_id": "d", "version": "1.0.0", "source_type": "tabular",
"sample_rate": 44100, "typo_field": true
}"#;
assert!(RawDatasetManifest::from_json_slice(json.as_bytes()).is_err());
}
#[test]
fn source_kind_serde_matches_historical_strings() {
for (s, kind) in [
("tabular", SourceKind::Tabular),
("time_series", SourceKind::TimeSeries),
("embeddings", SourceKind::Embeddings),
] {
let parsed: SourceKind = serde_json::from_str(&format!("\"{s}\"")).unwrap();
assert_eq!(parsed, kind);
assert_eq!(serde_json::to_string(&kind).unwrap(), format!("\"{s}\""));
}
assert!(serde_json::from_str::<SourceKind>("\"midi\"").is_err());
}
fn valid_manifest() -> RawDatasetManifest {
RawDatasetManifest {
dataset_id: "d".into(),
version: "1.0.0".into(),
source_type: SourceKind::TimeSeries,
paired_audio: true,
sample_rate: 44_100,
items: vec![RawDatasetItem {
id: "item_0001".into(),
source_path: ItemPath::new("source/item_0001.parquet").unwrap(),
audio_path: Some(ItemPath::new("audio/item_0001.wav").unwrap()),
duration_seconds: Some(10.0),
metadata: Metadata::default(),
}],
}
}
#[test]
fn validate_accepts_a_well_formed_manifest() {
assert!(valid_manifest().validate().is_ok());
}
#[test]
fn validate_rejects_zero_sample_rate() {
let mut m = valid_manifest();
m.sample_rate = 0;
assert!(matches!(m.validate(), Err(Error::Validation(_))));
}
#[test]
fn validate_rejects_paired_item_without_audio() {
let mut m = valid_manifest();
m.items[0].audio_path = None;
assert!(matches!(m.validate(), Err(Error::Validation(_))));
}
#[test]
fn validate_rejects_duplicate_ids() {
let mut m = valid_manifest();
let dup = m.items[0].clone();
m.items.push(dup);
assert!(matches!(m.validate(), Err(Error::Validation(_))));
}
#[test]
fn validate_rejects_nonpositive_duration() {
let mut m = valid_manifest();
m.items[0].duration_seconds = Some(0.0);
assert!(matches!(m.validate(), Err(Error::Validation(_))));
}
#[test]
fn item_path_rejects_escapes() {
assert!(ItemPath::new("../source.json").is_err());
assert!(ItemPath::new("audio/../outside.wav").is_err());
assert!(ItemPath::new("/etc/passwd").is_err());
assert!(ItemPath::new("").is_err());
assert!(ItemPath::new(".").is_err());
}
#[test]
fn validate_rejects_source_path_escape() {
let json = r#"{
"dataset_id": "d", "version": "1.0.0", "source_type": "time_series",
"paired_audio": false, "sample_rate": 44100,
"items": [{ "id": "a", "source_path": "../source.json" }]
}"#;
assert!(RawDatasetManifest::from_json_slice(json.as_bytes()).is_err());
let abs = std::env::current_dir().unwrap().join("source.json");
let json = format!(
r#"{{ "dataset_id": "d", "version": "1.0.0", "source_type": "time_series",
"paired_audio": false, "sample_rate": 44100,
"items": [{{ "id": "a", "source_path": {:?} }}] }}"#,
abs.to_string_lossy()
);
assert!(RawDatasetManifest::from_json_slice(json.as_bytes()).is_err());
}
#[test]
fn validate_rejects_audio_path_escape() {
let json = r#"{
"dataset_id": "d", "version": "1.0.0", "source_type": "time_series",
"paired_audio": true, "sample_rate": 44100,
"items": [{ "id": "a", "source_path": "source/a.json",
"audio_path": "audio/../outside.wav" }]
}"#;
assert!(RawDatasetManifest::from_json_slice(json.as_bytes()).is_err());
}
#[test]
fn validate_accepts_current_directory_components() {
let mut m = valid_manifest();
m.items[0].source_path = ItemPath::new("./source/item.json").unwrap();
m.items[0].audio_path = Some(ItemPath::new("audio/./item.wav").unwrap());
assert!(m.validate().is_ok());
}
#[test]
fn resolve_item_paths_are_root_relative() {
let m = valid_manifest().validate().unwrap();
let root = Path::new("/dataset");
let item = &m.items()[0];
assert_eq!(
item.source_path().join(root),
PathBuf::from("/dataset/source/item_0001.parquet")
);
assert_eq!(
item.audio_path().unwrap().join(root),
PathBuf::from("/dataset/audio/item_0001.wav")
);
}
#[test]
fn validate_allows_unpaired_item_without_audio() {
let mut m = valid_manifest();
m.paired_audio = false;
m.items[0].audio_path = None;
assert!(m.validate().is_ok());
}
}