use std::path::Path;
use serde::Serialize;
use super::error::PackageError;
#[derive(Debug, Serialize)]
pub(crate) struct ManifestFile {
pub path: String,
pub size: u64,
pub sha256: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct Manifest {
pub schema_version: u32,
pub application: String,
pub framework_version: String,
pub target: String,
pub created_at: String,
pub files: Vec<ManifestFile>,
}
impl Manifest {
pub(crate) fn new(
application: String,
framework_version: String,
target: String,
created_at: String,
mut files: Vec<ManifestFile>,
) -> Self {
files.sort_by(|a, b| a.path.cmp(&b.path));
Manifest {
schema_version: 1,
application,
framework_version,
target,
created_at,
files,
}
}
pub(crate) fn to_json(&self) -> Result<String, PackageError> {
serde_json::to_string_pretty(self)
.map_err(|source| PackageError::Serialize {
what: "manifest",
source,
})
.map(|json| format!("{json}\n"))
}
pub(crate) fn write(&self, path: &Path) -> Result<(), PackageError> {
let json = self.to_json()?;
std::fs::write(path, json).map_err(|source| PackageError::Write {
path: path.to_path_buf(),
source,
})
}
}
pub(crate) fn relative_path(base: &Path, file: &Path) -> String {
let rel = file
.strip_prefix(base)
.map(|p| p.to_path_buf())
.unwrap_or_else(|_| file.to_path_buf());
rel.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
pub(crate) fn sorted_relative_paths(base: &Path, files: &[std::path::PathBuf]) -> Vec<String> {
let mut rels: Vec<String> = files.iter().map(|f| relative_path(base, f)).collect();
rels.sort();
rels.dedup();
rels
}
#[cfg(test)]
mod tests {
use super::{Manifest, ManifestFile, relative_path, sorted_relative_paths};
use std::path::{Path, PathBuf};
#[test]
fn manifest_serializes_deterministically() {
let files = vec![
ManifestFile {
path: "public/build/assets/style.css".to_owned(),
size: 100,
sha256: "a".repeat(64),
},
ManifestFile {
path: "app-binary".to_owned(),
size: 200,
sha256: "b".repeat(64),
},
ManifestFile {
path: "public/build/assets/app.js".to_owned(),
size: 300,
sha256: "c".repeat(64),
},
];
let manifest = Manifest::new(
"demo".to_owned(),
"2026.1.0".to_owned(),
"x86_64-unknown-linux-gnu".to_owned(),
"2026-08-17T00:00:00Z".to_owned(),
files,
);
let json = manifest.to_json().expect("serialize");
let app_idx = json.find("\"app-binary\"").expect("app-binary present");
let js_idx = json
.find("\"public/build/assets/app.js\"")
.expect("app.js present");
let css_idx = json
.find("\"public/build/assets/style.css\"")
.expect("style.css present");
assert!(app_idx < js_idx);
assert!(js_idx < css_idx);
assert!(json.contains("\"schema_version\": 1"));
assert!(json.contains("\"application\": \"demo\""));
assert!(json.contains("\"framework_version\": \"2026.1.0\""));
assert!(json.ends_with("}\n"));
}
#[test]
fn same_inputs_produce_same_manifest() {
let make = || {
Manifest::new(
"demo".to_owned(),
"2026.1.0".to_owned(),
"x86_64-unknown-linux-gnu".to_owned(),
"2026-08-17T00:00:00Z".to_owned(),
vec![
ManifestFile {
path: "z".to_owned(),
size: 1,
sha256: "z".repeat(64),
},
ManifestFile {
path: "a".to_owned(),
size: 2,
sha256: "a".repeat(64),
},
],
)
.to_json()
.expect("serialize")
};
assert_eq!(make(), make(), "deterministic manifest output");
}
#[test]
fn relative_path_uses_forward_slashes() {
let base = Path::new("/bundle");
let file = Path::new("/bundle/public/build/assets/app.js");
assert_eq!(relative_path(base, file), "public/build/assets/app.js");
#[cfg(windows)]
{
let win_base = PathBuf::from(r"C:\bundle");
let win_file = PathBuf::from(r"C:\bundle\public\build");
assert_eq!(relative_path(&win_base, &win_file), "public/build");
}
}
#[test]
fn sorted_relative_paths_is_sorted_and_deduped() {
let base = Path::new("/bundle");
let files = vec![
PathBuf::from("/bundle/b"),
PathBuf::from("/bundle/a"),
PathBuf::from("/bundle/a"),
];
assert_eq!(
sorted_relative_paths(base, &files),
vec!["a".to_owned(), "b".to_owned()]
);
}
}