use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TargetKind {
Sprite,
Atlas,
Animation,
AnimationPreview,
Export,
}
impl std::fmt::Display for TargetKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TargetKind::Sprite => write!(f, "sprite"),
TargetKind::Atlas => write!(f, "atlas"),
TargetKind::Animation => write!(f, "animation"),
TargetKind::AnimationPreview => write!(f, "preview"),
TargetKind::Export => write!(f, "export"),
}
}
}
#[derive(Debug, Clone)]
pub struct BuildTarget {
pub id: String,
pub kind: TargetKind,
pub name: String,
pub sources: Vec<PathBuf>,
pub output: PathBuf,
pub dependencies: Vec<String>,
}
impl BuildTarget {
pub fn sprite(name: String, source: PathBuf, output: PathBuf) -> Self {
let id = format!("sprite:{}", name);
Self {
id,
kind: TargetKind::Sprite,
name,
sources: vec![source],
output,
dependencies: vec![],
}
}
pub fn atlas(name: String, sources: Vec<PathBuf>, output: PathBuf) -> Self {
let id = format!("atlas:{}", name);
Self { id, kind: TargetKind::Atlas, name, sources, output, dependencies: vec![] }
}
pub fn animation(name: String, source: PathBuf, output: PathBuf) -> Self {
let id = format!("animation:{}", name);
Self {
id,
kind: TargetKind::Animation,
name,
sources: vec![source],
output,
dependencies: vec![],
}
}
pub fn animation_preview(name: String, source: PathBuf, output: PathBuf) -> Self {
let id = format!("preview:{}", name);
let dep = format!("animation:{}", name);
Self {
id,
kind: TargetKind::AnimationPreview,
name,
sources: vec![source],
output,
dependencies: vec![dep],
}
}
pub fn export(name: String, format: String, output: PathBuf) -> Self {
let id = format!("export:{}:{}", format, name);
Self { id, kind: TargetKind::Export, name, sources: vec![], output, dependencies: vec![] }
}
pub fn with_dependency(mut self, dep: String) -> Self {
self.dependencies.push(dep);
self
}
pub fn with_dependencies(mut self, deps: Vec<String>) -> Self {
self.dependencies.extend(deps);
self
}
pub fn matches_filter(&self, filter: &str) -> bool {
if self.id == filter {
return true;
}
if self.kind.to_string() == filter {
return true;
}
if let Some((kind_pat, name_pat)) = filter.split_once(':') {
let kind_matches = kind_pat == "*" || kind_pat == self.kind.to_string();
let name_matches = name_pat == "*" || name_pat == self.name;
return kind_matches && name_matches;
}
false
}
}
#[derive(Debug, Default)]
pub struct BuildPlan {
targets: Vec<BuildTarget>,
}
impl BuildPlan {
pub fn new() -> Self {
Self { targets: vec![] }
}
pub fn add_target(&mut self, target: BuildTarget) {
self.targets.push(target);
}
pub fn targets(&self) -> &[BuildTarget] {
&self.targets
}
pub fn len(&self) -> usize {
self.targets.len()
}
pub fn is_empty(&self) -> bool {
self.targets.is_empty()
}
pub fn filter(mut self, patterns: &[String]) -> Self {
if patterns.is_empty() {
return self;
}
self.targets.retain(|t| patterns.iter().any(|p| t.matches_filter(p)));
self
}
pub fn build_order(&self) -> Result<Vec<&BuildTarget>, BuildOrderError> {
let mut result = Vec::new();
let mut visited = std::collections::HashSet::new();
let mut visiting = std::collections::HashSet::new();
for target in &self.targets {
self.visit_target(target, &mut visited, &mut visiting, &mut result)?;
}
Ok(result)
}
fn visit_target<'a>(
&'a self,
target: &'a BuildTarget,
visited: &mut std::collections::HashSet<String>,
visiting: &mut std::collections::HashSet<String>,
result: &mut Vec<&'a BuildTarget>,
) -> Result<(), BuildOrderError> {
if visited.contains(&target.id) {
return Ok(());
}
if visiting.contains(&target.id) {
return Err(BuildOrderError::CyclicDependency(target.id.clone()));
}
visiting.insert(target.id.clone());
for dep_id in &target.dependencies {
if let Some(dep) = self.targets.iter().find(|t| &t.id == dep_id) {
self.visit_target(dep, visited, visiting, result)?;
}
}
visiting.remove(&target.id);
visited.insert(target.id.clone());
result.push(target);
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum BuildOrderError {
#[error("Circular dependency detected involving target '{0}'")]
CyclicDependency(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_target_kind_display() {
assert_eq!(TargetKind::Sprite.to_string(), "sprite");
assert_eq!(TargetKind::Atlas.to_string(), "atlas");
assert_eq!(TargetKind::Animation.to_string(), "animation");
assert_eq!(TargetKind::AnimationPreview.to_string(), "preview");
assert_eq!(TargetKind::Export.to_string(), "export");
}
#[test]
fn test_build_target_sprite() {
let target = BuildTarget::sprite(
"player".to_string(),
PathBuf::from("src/player.pxl"),
PathBuf::from("build/player.png"),
);
assert_eq!(target.id, "sprite:player");
assert_eq!(target.kind, TargetKind::Sprite);
assert_eq!(target.name, "player");
assert_eq!(target.sources.len(), 1);
}
#[test]
fn test_build_target_atlas() {
let target = BuildTarget::atlas(
"characters".to_string(),
vec![PathBuf::from("src/player.pxl"), PathBuf::from("src/enemy.pxl")],
PathBuf::from("build/characters.png"),
);
assert_eq!(target.id, "atlas:characters");
assert_eq!(target.kind, TargetKind::Atlas);
assert_eq!(target.sources.len(), 2);
}
#[test]
fn test_build_target_matches_filter_exact() {
let target = BuildTarget::atlas(
"characters".to_string(),
vec![],
PathBuf::from("build/characters.png"),
);
assert!(target.matches_filter("atlas:characters"));
assert!(!target.matches_filter("atlas:enemies"));
}
#[test]
fn test_build_target_matches_filter_kind() {
let target = BuildTarget::atlas(
"characters".to_string(),
vec![],
PathBuf::from("build/characters.png"),
);
assert!(target.matches_filter("atlas"));
assert!(!target.matches_filter("sprite"));
}
#[test]
fn test_build_target_matches_filter_wildcard() {
let target = BuildTarget::atlas(
"characters".to_string(),
vec![],
PathBuf::from("build/characters.png"),
);
assert!(target.matches_filter("atlas:*"));
assert!(target.matches_filter("*:characters"));
assert!(!target.matches_filter("*:enemies"));
}
#[test]
fn test_build_plan_add_target() {
let mut plan = BuildPlan::new();
plan.add_target(BuildTarget::sprite(
"player".to_string(),
PathBuf::from("src/player.pxl"),
PathBuf::from("build/player.png"),
));
assert_eq!(plan.len(), 1);
assert!(!plan.is_empty());
}
#[test]
fn test_build_plan_filter() {
let mut plan = BuildPlan::new();
plan.add_target(BuildTarget::atlas(
"characters".to_string(),
vec![],
PathBuf::from("build/characters.png"),
));
plan.add_target(BuildTarget::atlas(
"environment".to_string(),
vec![],
PathBuf::from("build/environment.png"),
));
plan.add_target(BuildTarget::sprite(
"player".to_string(),
PathBuf::from("src/player.pxl"),
PathBuf::from("build/player.png"),
));
let filtered = plan.filter(&["atlas".to_string()]);
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_build_plan_build_order_simple() {
let mut plan = BuildPlan::new();
plan.add_target(BuildTarget::sprite(
"a".to_string(),
PathBuf::from("a.pxl"),
PathBuf::from("a.png"),
));
plan.add_target(BuildTarget::sprite(
"b".to_string(),
PathBuf::from("b.pxl"),
PathBuf::from("b.png"),
));
let order = plan.build_order().unwrap();
assert_eq!(order.len(), 2);
}
#[test]
fn test_build_plan_build_order_with_deps() {
let mut plan = BuildPlan::new();
plan.add_target(BuildTarget::animation(
"walk".to_string(),
PathBuf::from("walk.pxl"),
PathBuf::from("walk.png"),
));
plan.add_target(BuildTarget::animation_preview(
"walk".to_string(),
PathBuf::from("walk.pxl"),
PathBuf::from("walk.gif"),
));
let order = plan.build_order().unwrap();
assert_eq!(order.len(), 2);
assert_eq!(order[0].id, "animation:walk");
assert_eq!(order[1].id, "preview:walk");
}
}