use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
use crate::error::MicroError;
use crate::model::{Layer, MicroAsset};
use crate::package::{self, Limits, MANIFEST, SCHEMA_VERSION};
use crate::pipeline::Processed;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PackageSpec {
pub name: String,
pub alt: String,
pub emoji: Option<String>,
pub text: Option<String>,
pub aliases: Vec<String>,
pub version: Option<String>,
pub license: Option<String>,
pub author: Option<String>,
}
impl PackageSpec {
pub fn new(name: impl Into<String>, alt: impl Into<String>) -> PackageSpec {
PackageSpec {
name: name.into(),
alt: alt.into(),
..PackageSpec::default()
}
}
#[must_use]
pub fn emoji(mut self, emoji: impl Into<String>) -> Self {
self.emoji = Some(emoji.into());
self
}
#[must_use]
pub fn text(mut self, text: impl Into<String>) -> Self {
self.text = Some(text.into());
self
}
#[must_use]
pub fn alias(mut self, alias: impl Into<String>) -> Self {
self.aliases.push(alias.into());
self
}
#[must_use]
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
#[must_use]
pub fn license(mut self, license: impl Into<String>) -> Self {
self.license = Some(license.into());
self
}
#[must_use]
pub fn author(mut self, author: impl Into<String>) -> Self {
self.author = Some(author.into());
self
}
fn check(&self, processed: &Processed) -> Result<(), MicroError> {
let mut asset = MicroAsset::new(&self.name, &self.alt)?.with_size(processed.size)?;
if let Some(emoji) = &self.emoji {
asset = asset.with_emoji(emoji)?;
}
if let Some(text) = &self.text {
asset = asset.with_text(text)?;
}
for alias in &self.aliases {
asset = asset.with_alias(alias)?;
}
for (key, value) in [
("version", &self.version),
("license", &self.license),
("author", &self.author),
] {
if value
.as_deref()
.is_some_and(|value| value.chars().any(char::is_control))
{
return Err(MicroError::Manifest(format!(
"the {key} holds control characters"
)));
}
}
Ok(())
}
pub fn manifest(&self, processed: &Processed) -> Value {
let mut map = Map::new();
map.insert("schema_version".into(), json!(SCHEMA_VERSION));
map.insert("name".into(), json!(self.name));
map.insert("alt".into(), json!(self.alt.trim()));
map.insert("size".into(), json!(processed.size.to_string()));
map.insert(
"kind".into(),
json!(if processed.animated() {
"animated"
} else {
"static"
}),
);
let mut fallback = Map::new();
if let Some(emoji) = &self.emoji {
fallback.insert("emoji".into(), json!(emoji));
}
if let Some(text) = &self.text {
fallback.insert("text".into(), json!(text));
}
if !fallback.is_empty() {
map.insert("fallback".into(), Value::Object(fallback));
}
map.insert("static".into(), json!(STATIC));
if processed.animated() {
map.insert("animation".into(), json!(ANIMATION));
}
if !self.aliases.is_empty() {
map.insert("aliases".into(), json!(self.aliases));
}
for (key, value) in [
("version", &self.version),
("license", &self.license),
("author", &self.author),
] {
if let Some(value) = value {
map.insert(key.into(), json!(value));
}
}
Value::Object(map)
}
}
pub const STATIC: &str = "static.png";
pub const ANIMATION: &str = "animation.gif";
fn io_error(path: &Path, error: impl std::fmt::Display) -> MicroError {
MicroError::Io(format!("{}: {error}", path.display()))
}
pub fn package_files(
spec: &PackageSpec,
processed: &Processed,
) -> Result<Vec<(&'static str, Vec<u8>)>, MicroError> {
spec.check(processed)?;
let manifest = serde_json::to_vec_pretty(&spec.manifest(processed))
.map_err(|e| MicroError::Manifest(e.to_string()))?;
let mut files = vec![(MANIFEST, manifest), (STATIC, processed.png()?)];
if let Some(gif) = processed.gif()? {
files.push((ANIMATION, gif));
}
Ok(files)
}
pub fn write_package(
dest: &Path,
spec: &PackageSpec,
processed: &Processed,
archive: bool,
) -> Result<MicroAsset, MicroError> {
let files = package_files(spec, processed)?;
let exists = || MicroError::Io(format!("{} already exists", dest.display()));
if std::fs::symlink_metadata(dest).is_ok() {
return Err(exists());
}
if let Some(parent) = dest.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent).map_err(|e| io_error(parent, e))?;
}
let created = if archive {
std::fs::File::create_new(dest).map(Some)
} else {
std::fs::create_dir(dest).map(|()| None)
};
let archive_file = match created {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Err(exists()),
Err(e) => return Err(io_error(dest, e)),
};
let written = match archive_file {
Some(file) => write_archive(file, dest, &files),
None => write_directory(dest, &files),
};
let loaded =
written.and_then(|()| package::load_package(dest, Layer::User, &Limits::default()));
if loaded.is_err() {
remove(dest);
}
loaded
}
fn write_directory(dest: &Path, files: &[(&str, Vec<u8>)]) -> Result<(), MicroError> {
for (name, bytes) in files {
let path = dest.join(name);
std::fs::write(&path, bytes).map_err(|e| io_error(&path, e))?;
}
Ok(())
}
fn write_archive(
file: std::fs::File,
dest: &Path,
files: &[(&str, Vec<u8>)],
) -> Result<(), MicroError> {
use zip::write::SimpleFileOptions;
let mut writer = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for (name, bytes) in files {
writer
.start_file(*name, options)
.map_err(|e| io_error(dest, e))?;
writer.write_all(bytes).map_err(|e| io_error(dest, e))?;
}
writer.finish().map_err(|e| io_error(dest, e))?;
Ok(())
}
fn remove(path: &Path) {
if std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()) {
let _ = std::fs::remove_dir_all(path);
} else {
let _ = std::fs::remove_file(path);
}
}
pub fn package_path(layer_dir: &Path, name: &str, archive: bool) -> PathBuf {
let stem = name.replace('/', ".");
if archive {
layer_dir.join(format!("{stem}.richmicro"))
} else {
layer_dir.join(stem)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::CellSize;
use crate::pipeline::Pipeline;
use rich_art::graphics::AnimationFrame;
use rich_art::image::{Rgba, RgbaImage};
use std::time::Duration;
fn animated() -> Processed {
let frame = |c: u8| AnimationFrame {
image: RgbaImage::from_pixel(8, 8, Rgba([c, 0, 0, 255])),
delay: Duration::from_millis(120),
};
Pipeline::new(CellSize::default()).process_frames(&[frame(10), frame(200)])
}
#[test]
fn writes_directories_and_archives_the_reader_accepts() {
let dir = tempfile::tempdir().unwrap();
let processed = animated();
let spec = PackageSpec::new("team/spin", "a red square blinking")
.text("*")
.license("MIT")
.version("1.0.0");
let asset = write_package(&dir.path().join("spin"), &spec, &processed, false).unwrap();
assert_eq!(asset.kind(), crate::AssetKind::Animated);
assert_eq!(asset.license(), Some("MIT"));
let zipped = dir.path().join("spin.richmicro");
let asset = write_package(&zipped, &spec, &processed, true).unwrap();
assert!(asset.variants().animation.is_some());
assert!(zipped.is_file());
assert!(write_package(&zipped, &spec, &processed, true).is_err());
}
#[test]
fn bad_metadata_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let processed = animated();
let dest = dir.path().join("bad");
let spec = PackageSpec::new("Bad Name", "alt");
assert!(write_package(&dest, &spec, &processed, false).is_err());
let spec = PackageSpec::new("ok", "alt").text("TOO-WIDE");
assert!(write_package(&dest, &spec, &processed, false).is_err());
let spec = PackageSpec::new("ok", "alt").author("\u{1b}]0;title\u{7}");
let error = write_package(&dest, &spec, &processed, false).unwrap_err();
assert!(error.to_string().contains("control"), "{error}");
assert!(!dest.exists());
}
#[cfg(unix)]
#[test]
fn an_existing_link_is_refused_and_left_alone() {
let dir = tempfile::tempdir().unwrap();
let processed = animated();
let spec = PackageSpec::new("ok", "alt").text("ok");
for archive in [false, true] {
let dest = dir.path().join(format!("out-{archive}"));
std::os::unix::fs::symlink("/nonexistent/target", &dest).unwrap();
let error = write_package(&dest, &spec, &processed, archive).unwrap_err();
assert!(error.to_string().contains("already exists"), "{error}");
let link = std::fs::symlink_metadata(&dest).expect("the link is still there");
assert!(link.file_type().is_symlink());
let target = dir.path().join(format!("target-{archive}"));
std::fs::create_dir(&target).unwrap();
std::fs::write(target.join("keep"), "x").unwrap();
let dest = dir.path().join(format!("dir-{archive}"));
std::os::unix::fs::symlink(&target, &dest).unwrap();
assert!(write_package(&dest, &spec, &processed, archive).is_err());
assert!(target.join("keep").is_file());
assert!(std::fs::symlink_metadata(&dest).is_ok());
}
}
#[test]
fn package_paths() {
let root = Path::new("/r");
assert_eq!(package_path(root, "a/b", false), Path::new("/r/a.b"));
assert_eq!(package_path(root, "a", true), Path::new("/r/a.richmicro"));
}
}