use clap::{Parser, Subcommand};
use std::collections::HashSet;
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use crate::alias::{parse_simple_grid, simple_grid_to_sprite};
use crate::analyze::{collect_files, format_report_text, AnalysisReport};
use crate::atlas::{add_animation_to_atlas, pack_atlas, AtlasBox, AtlasConfig, SpriteInput};
use crate::composition::render_composition;
use crate::diff::{diff_files, format_diff};
#[allow(unused_imports)]
use crate::emoji::render_emoji_art;
use crate::explain::{explain_object, format_explanation, resolve_palette_colors, Explanation};
use crate::fmt::format_pixelsrc;
use crate::prime::{get_primer, list_sections, PrimerSection};
use crate::suggest::{format_suggestion, suggest, Suggester, SuggestionFix, SuggestionType};
use crate::terminal::{render_ansi_grid, render_coordinate_grid};
use crate::validate::{Severity, Validator};
use glob::glob;
pub fn is_pixelsrc_file(path: &std::path::Path) -> bool {
matches!(path.extension().and_then(|e| e.to_str()), Some("pxl") | Some("jsonl"))
}
pub fn find_pixelsrc_files(dir: &std::path::Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let dir_str = dir.display().to_string();
if let Ok(paths) = glob(&format!("{}/**/*.pxl", dir_str)) {
files.extend(paths.filter_map(Result::ok));
}
if let Ok(paths) = glob(&format!("{}/**/*.jsonl", dir_str)) {
files.extend(paths.filter_map(Result::ok));
}
files
}
use crate::gif::render_gif;
use crate::import::import_png;
use crate::include::{extract_include_path, is_include_ref, resolve_include_with_detection};
use crate::lsp_agent_client::LspAgentClient;
use crate::models::{Animation, Composition, PaletteRef, Sprite, TtpObject};
use crate::output::{generate_output_path, save_png, scale_image};
use crate::palette_cycle::{generate_cycle_frames, get_cycle_duration};
use crate::palettes;
use crate::parser::parse_stream;
use crate::registry::{PaletteRegistry, PaletteSource, ResolvedPalette, SpriteRegistry};
use crate::renderer::{render_resolved, render_sprite};
use crate::spritesheet::render_spritesheet;
use crate::transforms::{
apply_crop, apply_mirror_horizontal, apply_mirror_vertical, apply_outline, apply_pad,
apply_rotate, apply_shadow, apply_shift, apply_tile,
};
const EXIT_SUCCESS: u8 = 0;
const EXIT_ERROR: u8 = 1;
const EXIT_INVALID_ARGS: u8 = 2;
#[derive(Parser)]
#[command(name = "pxl")]
#[command(about = "Pixelsrc - Parse pixel art definitions (.pxl, .jsonl) and render to PNG")]
#[command(version)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
Render {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
sprite: Option<String>,
#[arg(short = 'c', long)]
composition: Option<String>,
#[arg(long)]
strict: bool,
#[arg(long, default_value = "1", value_parser = clap::value_parser!(u8).range(1..=16))]
scale: u8,
#[arg(long)]
gif: bool,
#[arg(long)]
spritesheet: bool,
#[arg(long)]
emoji: bool,
#[arg(long)]
animation: Option<String>,
#[arg(long)]
format: Option<String>,
#[arg(long)]
max_size: Option<String>,
#[arg(long, default_value = "0")]
padding: u32,
#[arg(long)]
power_of_two: bool,
#[arg(long)]
nine_slice: Option<String>,
},
Import {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, default_value = "16")]
max_colors: usize,
#[arg(short, long)]
name: Option<String>,
},
Prompts {
#[arg()]
template: Option<String>,
},
Palettes {
#[command(subcommand)]
action: PaletteAction,
},
Analyze {
#[arg(required_unless_present = "dir")]
files: Vec<PathBuf>,
#[arg(long)]
dir: Option<PathBuf>,
#[arg(long, short)]
recursive: bool,
#[arg(long, default_value = "text")]
format: String,
#[arg(long, short)]
output: Option<PathBuf>,
},
Fmt {
#[arg(required = true)]
files: Vec<PathBuf>,
#[arg(long)]
check: bool,
#[arg(long)]
stdout: bool,
},
Prime {
#[arg(long)]
brief: bool,
#[arg(long)]
section: Option<String>,
},
Validate {
#[arg(required_unless_present = "stdin")]
files: Vec<PathBuf>,
#[arg(long)]
stdin: bool,
#[arg(long)]
strict: bool,
#[arg(long)]
json: bool,
},
#[command(name = "agent-verify")]
AgentVerify {
#[arg(long)]
stdin: bool,
#[arg(long)]
content: Option<String>,
#[arg(long)]
strict: bool,
#[arg(long)]
grid_info: bool,
#[arg(long)]
suggest_tokens: bool,
#[arg(long)]
resolve_colors: bool,
#[arg(long)]
analyze_timing: bool,
},
Explain {
input: PathBuf,
#[arg(short, long)]
name: Option<String>,
#[arg(long)]
json: bool,
},
Diff {
file_a: PathBuf,
file_b: PathBuf,
#[arg(long)]
sprite: Option<String>,
#[arg(long)]
json: bool,
},
Suggest {
#[arg(required_unless_present = "stdin")]
files: Vec<PathBuf>,
#[arg(long)]
stdin: bool,
#[arg(long)]
json: bool,
#[arg(long)]
only: Option<String>,
},
Inline {
input: PathBuf,
#[arg(long)]
sprite: Option<String>,
},
Alias {
input: PathBuf,
#[arg(long)]
sprite: Option<String>,
},
Grid {
input: PathBuf,
#[arg(long)]
sprite: Option<String>,
#[arg(long)]
full: bool,
},
Show {
file: PathBuf,
#[arg(long)]
sprite: Option<String>,
#[arg(long)]
animation: Option<String>,
#[arg(long, default_value = "0")]
frame: usize,
#[arg(long)]
onion: Option<u32>,
#[arg(long, default_value = "0.3")]
onion_opacity: f32,
#[arg(long, default_value = "#0000FF")]
onion_prev_color: String,
#[arg(long, default_value = "#00FF00")]
onion_next_color: String,
#[arg(long)]
onion_fade: bool,
#[arg(short, long)]
output: Option<PathBuf>,
},
Build {
#[arg(short, long)]
out: Option<PathBuf>,
#[arg(long)]
src: Option<PathBuf>,
#[arg(short, long)]
watch: bool,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
force: bool,
#[arg(short, long)]
verbose: bool,
},
New {
asset_type: String,
name: String,
#[arg(long)]
palette: Option<String>,
},
Init {
path: Option<PathBuf>,
#[arg(long)]
name: Option<String>,
#[arg(long, default_value = "minimal")]
preset: String,
},
Sketch {
#[arg()]
file: Option<PathBuf>,
#[arg(short, long, default_value = "sketch")]
name: String,
#[arg(short, long)]
palette: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
},
Transform {
input: PathBuf,
#[arg(long)]
mirror: Option<String>,
#[arg(long)]
rotate: Option<u16>,
#[arg(long)]
tile: Option<String>,
#[arg(long)]
pad: Option<u32>,
#[arg(long)]
outline: Option<Option<String>>,
#[arg(long, default_value = "1")]
outline_width: u32,
#[arg(long)]
crop: Option<String>,
#[arg(long)]
shift: Option<String>,
#[arg(long)]
shadow: Option<String>,
#[arg(long)]
shadow_token: Option<String>,
#[arg(long)]
sprite: Option<String>,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
stdin: bool,
#[arg(long)]
allow_large: bool,
},
#[cfg(feature = "lsp")]
#[command(hide = true)]
Lsp,
Agent {
#[command(subcommand)]
action: AgentAction,
},
}
#[derive(Subcommand)]
pub enum AgentAction {
Verify {
file: Option<PathBuf>,
#[arg(long)]
stdin: bool,
#[arg(long)]
strict: bool,
},
Completions {
file: Option<PathBuf>,
#[arg(long)]
stdin: bool,
#[arg(long, default_value = "1")]
line: usize,
#[arg(long, default_value = "0")]
character: usize,
},
Position {
file: Option<PathBuf>,
#[arg(long)]
stdin: bool,
#[arg(long)]
line: usize,
#[arg(long)]
character: usize,
},
}
#[derive(Subcommand)]
pub enum PaletteAction {
List,
Show {
name: String,
},
}
pub fn run() -> ExitCode {
let cli = Cli::parse();
match cli.command {
Commands::Render {
input,
output,
sprite,
composition,
strict,
scale,
gif,
spritesheet,
emoji,
animation,
format,
max_size,
padding,
power_of_two,
nine_slice,
} => run_render(
&input,
output.as_deref(),
sprite.as_deref(),
composition.as_deref(),
strict,
scale,
gif,
spritesheet,
emoji,
animation.as_deref(),
format.as_deref(),
max_size.as_deref(),
padding,
power_of_two,
nine_slice.as_deref(),
),
Commands::Import { input, output, max_colors, name } => {
run_import(&input, output.as_deref(), max_colors, name.as_deref())
}
Commands::Prompts { template } => run_prompts(template.as_deref()),
Commands::Palettes { action } => run_palettes(action),
Commands::Analyze { files, dir, recursive, format, output } => {
run_analyze(&files, dir.as_deref(), recursive, &format, output.as_deref())
}
Commands::Fmt { files, check, stdout } => run_fmt(&files, check, stdout),
Commands::Prime { brief, section } => run_prime(brief, section.as_deref()),
Commands::Validate { files, stdin, strict, json } => {
run_validate(&files, stdin, strict, json)
}
Commands::AgentVerify {
stdin,
content,
strict,
grid_info,
suggest_tokens,
resolve_colors,
analyze_timing,
} => run_agent_verify(
stdin,
content.as_deref(),
strict,
grid_info,
suggest_tokens,
resolve_colors,
analyze_timing,
),
Commands::Explain { input, name, json } => run_explain(&input, name.as_deref(), json),
Commands::Diff { file_a, file_b, sprite, json } => {
run_diff(&file_a, &file_b, sprite.as_deref(), json)
}
Commands::Suggest { files, stdin, json, only } => {
run_suggest(&files, stdin, json, only.as_deref())
}
Commands::Inline { input, sprite } => run_inline(&input, sprite.as_deref()),
Commands::Alias { input, sprite } => run_alias(&input, sprite.as_deref()),
Commands::Grid { input, sprite, full } => run_grid(&input, sprite.as_deref(), full),
Commands::Show {
file,
sprite,
animation,
frame,
onion,
onion_opacity,
onion_prev_color,
onion_next_color,
onion_fade,
output,
} => run_show(
&file,
sprite.as_deref(),
animation.as_deref(),
frame,
onion,
onion_opacity,
&onion_prev_color,
&onion_next_color,
onion_fade,
output.as_deref(),
),
Commands::Build { out, src, watch, dry_run, force, verbose } => {
run_build(out.as_deref(), src.as_deref(), watch, dry_run, force, verbose)
}
Commands::New { asset_type, name, palette } => {
run_new(&asset_type, &name, palette.as_deref())
}
Commands::Init { path, name, preset } => {
run_init(path.as_deref(), name.as_deref(), &preset)
}
Commands::Sketch { file, name, palette, output } => {
run_sketch(file.as_deref(), &name, palette.as_deref(), output.as_deref())
}
Commands::Transform {
input,
mirror,
rotate,
tile,
pad,
outline,
outline_width,
crop,
shift,
shadow,
shadow_token,
sprite,
output,
stdin,
allow_large,
} => run_transform(
&input,
mirror.as_deref(),
rotate,
tile.as_deref(),
pad,
outline,
outline_width,
crop.as_deref(),
shift.as_deref(),
shadow.as_deref(),
shadow_token.as_deref(),
sprite.as_deref(),
&output,
stdin,
allow_large,
),
#[cfg(feature = "lsp")]
Commands::Lsp => run_lsp(),
Commands::Agent { action } => run_agent(action),
}
}
#[cfg(feature = "lsp")]
fn run_lsp() -> ExitCode {
use tokio::runtime::Runtime;
let rt = match Runtime::new() {
Ok(rt) => rt,
Err(e) => {
eprintln!("Error: Failed to create async runtime: {}", e);
return ExitCode::from(EXIT_ERROR);
}
};
rt.block_on(crate::lsp::run_server());
ExitCode::from(EXIT_SUCCESS)
}
fn run_agent(action: AgentAction) -> ExitCode {
use crate::lsp_agent_client::LspAgentClient;
use std::io::{self, Read};
match action {
AgentAction::Verify { file, stdin, strict } => {
let content = if stdin {
let mut buf = String::new();
if let Err(e) = io::stdin().read_to_string(&mut buf) {
eprintln!("Error reading stdin: {}", e);
return ExitCode::from(EXIT_ERROR);
}
buf
} else if let Some(path) = file {
match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("Error reading file: {}", e);
return ExitCode::from(EXIT_ERROR);
}
}
} else {
eprintln!("Error: Provide a file or use --stdin");
return ExitCode::from(EXIT_INVALID_ARGS);
};
let client = if strict { LspAgentClient::strict() } else { LspAgentClient::new() };
println!("{}", client.verify_content_json(&content));
ExitCode::from(EXIT_SUCCESS)
}
AgentAction::Completions { file, stdin, line, character } => {
let content = if stdin {
let mut buf = String::new();
if let Err(e) = io::stdin().read_to_string(&mut buf) {
eprintln!("Error reading stdin: {}", e);
return ExitCode::from(EXIT_ERROR);
}
buf
} else if let Some(path) = file {
match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("Error reading file: {}", e);
return ExitCode::from(EXIT_ERROR);
}
}
} else {
eprintln!("Error: Provide a file or use --stdin");
return ExitCode::from(EXIT_INVALID_ARGS);
};
let client = LspAgentClient::new();
println!("{}", client.get_completions_json(&content, line, character));
ExitCode::from(EXIT_SUCCESS)
}
AgentAction::Position { file, stdin, line, character } => {
let content = if stdin {
let mut buf = String::new();
if let Err(e) = io::stdin().read_to_string(&mut buf) {
eprintln!("Error reading stdin: {}", e);
return ExitCode::from(EXIT_ERROR);
}
buf
} else if let Some(path) = file {
match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("Error reading file: {}", e);
return ExitCode::from(EXIT_ERROR);
}
}
} else {
eprintln!("Error: Provide a file or use --stdin");
return ExitCode::from(EXIT_INVALID_ARGS);
};
let client = LspAgentClient::new();
if let Some(pos) = client.get_grid_position(&content, line, character) {
println!("{}", serde_json::to_string_pretty(&pos).unwrap());
} else {
println!("null");
}
ExitCode::from(EXIT_SUCCESS)
}
}
}
const TEMPLATE_CHARACTER: &str = include_str!("../docs/prompts/templates/character.txt");
const TEMPLATE_ITEM: &str = include_str!("../docs/prompts/templates/item.txt");
const TEMPLATE_TILESET: &str = include_str!("../docs/prompts/templates/tileset.txt");
const TEMPLATE_ANIMATION: &str = include_str!("../docs/prompts/templates/animation.txt");
const TEMPLATES: &[(&str, &str)] = &[
("character", TEMPLATE_CHARACTER),
("item", TEMPLATE_ITEM),
("tileset", TEMPLATE_TILESET),
("animation", TEMPLATE_ANIMATION),
];
fn run_prime(brief: bool, section: Option<&str>) -> ExitCode {
let primer_section = match section {
None => PrimerSection::Full,
Some(s) => match s.parse::<PrimerSection>() {
Ok(sec) => sec,
Err(e) => {
eprintln!("Error: {}", e);
eprintln!();
eprintln!("Available sections:");
for sec in list_sections() {
eprintln!(" {}", sec);
}
return ExitCode::from(EXIT_ERROR);
}
},
};
let content = get_primer(primer_section, brief);
println!("{}", content);
ExitCode::from(EXIT_SUCCESS)
}
fn run_prompts(template: Option<&str>) -> ExitCode {
match template {
None => {
println!("Available prompt templates:");
println!();
for (name, _) in TEMPLATES {
println!(" {}", name);
}
println!();
println!("Usage: pxl prompts <template>");
println!();
println!("Templates are designed for use with Claude, GPT, or other LLMs.");
println!("See docs/prompts/ for full documentation and examples.");
ExitCode::from(EXIT_SUCCESS)
}
Some(name) => {
for (tpl_name, content) in TEMPLATES {
if *tpl_name == name {
println!("{}", content);
return ExitCode::from(EXIT_SUCCESS);
}
}
eprintln!("Error: Unknown template '{}'", name);
let template_names: Vec<&str> = TEMPLATES.iter().map(|(n, _)| *n).collect();
if let Some(suggestion) = format_suggestion(&suggest(name, &template_names, 3)) {
eprintln!("{}", suggestion);
}
eprintln!();
eprintln!("Available templates:");
for (tpl_name, _) in TEMPLATES {
eprintln!(" {}", tpl_name);
}
ExitCode::from(EXIT_ERROR)
}
}
}
fn run_palettes(action: PaletteAction) -> ExitCode {
match action {
PaletteAction::List => {
println!("Built-in palettes:");
for name in palettes::list_builtins() {
println!(" @{}", name);
}
ExitCode::from(EXIT_SUCCESS)
}
PaletteAction::Show { name } => {
let palette_name = name.strip_prefix('@').unwrap_or(&name);
match palettes::get_builtin(palette_name) {
Some(palette) => {
println!("Palette: @{}", palette_name);
println!();
for (key, color) in &palette.colors {
println!(" {} => {}", key, color);
}
ExitCode::from(EXIT_SUCCESS)
}
None => {
eprintln!("Error: Unknown palette '{}'", name);
let builtin_names = palettes::list_builtins();
if let Some(suggestion) =
format_suggestion(&suggest(palette_name, &builtin_names, 3))
{
eprintln!("{}", suggestion);
}
eprintln!();
eprintln!("Available palettes:");
for builtin_name in palettes::list_builtins() {
eprintln!(" @{}", builtin_name);
}
ExitCode::from(EXIT_ERROR)
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_render(
input: &PathBuf,
output: Option<&std::path::Path>,
sprite_filter: Option<&str>,
composition_filter: Option<&str>,
strict: bool,
scale: u8,
gif_output: bool,
spritesheet_output: bool,
_emoji_output: bool,
animation_filter: Option<&str>,
format: Option<&str>,
max_size_arg: Option<&str>,
padding: u32,
power_of_two: bool,
nine_slice_arg: Option<&str>,
) -> ExitCode {
let nine_slice_size = if let Some(size_str) = nine_slice_arg {
let parts: Vec<&str> = size_str.split('x').collect();
if parts.len() != 2 {
eprintln!(
"Error: Invalid nine-slice size format '{}'. Use WxH format (e.g., '64x32')",
size_str
);
return ExitCode::from(EXIT_INVALID_ARGS);
}
match (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
(Ok(w), Ok(h)) if w > 0 && h > 0 => Some((w, h)),
_ => {
eprintln!(
"Error: Invalid nine-slice size '{}'. Width and height must be positive integers",
size_str
);
return ExitCode::from(EXIT_INVALID_ARGS);
}
}
} else {
None
};
let file = match File::open(input) {
Ok(f) => f,
Err(e) => {
eprintln!("Error: Cannot open input file '{}': {}", input.display(), e);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let reader = BufReader::new(file);
let parse_result = parse_stream(reader);
let mut all_warnings: Vec<String> = Vec::new();
for warning in &parse_result.warnings {
all_warnings.push(format!("line {}: {}", warning.line, warning.message));
}
if strict && !parse_result.warnings.is_empty() {
for warning in &all_warnings {
eprintln!("Error: {}", warning);
}
return ExitCode::from(EXIT_ERROR);
}
let mut registry = PaletteRegistry::new();
let mut sprite_registry = SpriteRegistry::new();
let mut sprites_by_name: std::collections::HashMap<String, Sprite> =
std::collections::HashMap::new();
let mut animations_by_name: std::collections::HashMap<String, Animation> =
std::collections::HashMap::new();
let mut compositions_by_name: std::collections::HashMap<String, Composition> =
std::collections::HashMap::new();
for obj in parse_result.objects {
match obj {
TtpObject::Palette(palette) => {
registry.register(palette);
}
TtpObject::Sprite(sprite) => {
if sprites_by_name.contains_key(&sprite.name) {
let warning_msg =
format!("Duplicate sprite name '{}', using latest", sprite.name);
all_warnings.push(warning_msg);
if strict {
for warning in &all_warnings {
eprintln!("Error: {}", warning);
}
return ExitCode::from(EXIT_ERROR);
}
}
sprite_registry.register_sprite(sprite.clone());
sprites_by_name.insert(sprite.name.clone(), sprite);
}
TtpObject::Animation(anim) => {
if animations_by_name.contains_key(&anim.name) {
let warning_msg =
format!("Duplicate animation name '{}', using latest", anim.name);
all_warnings.push(warning_msg);
if strict {
for warning in &all_warnings {
eprintln!("Error: {}", warning);
}
return ExitCode::from(EXIT_ERROR);
}
}
animations_by_name.insert(anim.name.clone(), anim);
}
TtpObject::Composition(comp) => {
if compositions_by_name.contains_key(&comp.name) {
let warning_msg =
format!("Duplicate composition name '{}', using latest", comp.name);
all_warnings.push(warning_msg);
if strict {
for warning in &all_warnings {
eprintln!("Error: {}", warning);
}
return ExitCode::from(EXIT_ERROR);
}
}
compositions_by_name.insert(comp.name.clone(), comp);
}
TtpObject::Variant(variant) => {
sprite_registry.register_variant(variant);
}
TtpObject::Particle(_) => {
}
TtpObject::Transform(_) => {
}
}
}
let input_dir = input.parent().unwrap_or(std::path::Path::new("."));
let mut include_visited: HashSet<PathBuf> = HashSet::new();
if gif_output || spritesheet_output {
return run_animation_render(
input,
output,
&animations_by_name,
&sprites_by_name,
&sprite_registry,
®istry,
input_dir,
&mut include_visited,
&mut all_warnings,
strict,
scale,
gif_output,
animation_filter,
);
}
if let Some(fmt) = format {
if fmt.starts_with("atlas") {
return run_atlas_render(
input,
output,
&sprites_by_name,
&animations_by_name,
&sprite_registry,
®istry,
input_dir,
&mut include_visited,
&mut all_warnings,
strict,
scale,
fmt,
max_size_arg,
padding,
power_of_two,
);
} else {
eprintln!("Error: Unknown format '{}'. Supported: atlas, atlas-aseprite, atlas-godot, atlas-unity, atlas-libgdx", fmt);
return ExitCode::from(EXIT_INVALID_ARGS);
}
}
if let Some(comp_name) = composition_filter {
return run_composition_render(
input,
output,
comp_name,
&compositions_by_name,
&sprites_by_name,
&sprite_registry,
®istry,
input_dir,
&mut include_visited,
&mut all_warnings,
strict,
scale,
);
}
let render_sprites = sprite_filter.is_some() || !sprites_by_name.is_empty();
let render_compositions = !compositions_by_name.is_empty() && sprite_filter.is_none();
let mut sprites: Vec<_> = sprites_by_name.values().cloned().collect();
if let Some(name) = sprite_filter {
sprites.retain(|s| s.name == name);
if sprites.is_empty() {
eprintln!("Error: No sprite named '{}' found in input", name);
let sprite_names: Vec<&str> = sprites_by_name.keys().map(|s| s.as_str()).collect();
if let Some(suggestion) = format_suggestion(&suggest(name, &sprite_names, 3)) {
eprintln!("{}", suggestion);
}
return ExitCode::from(EXIT_ERROR);
}
}
if sprites.is_empty() && compositions_by_name.is_empty() {
eprintln!("Error: No sprites or compositions found in input file");
return ExitCode::from(EXIT_ERROR);
}
let is_single_output = sprites.len() == 1 && compositions_by_name.is_empty();
if render_sprites {
for sprite in &sprites {
let uses_include_palette =
matches!(&sprite.palette, PaletteRef::Named(name) if is_include_ref(name));
let (final_grid, final_palette) = if uses_include_palette {
let include_path = if let PaletteRef::Named(name) = &sprite.palette {
extract_include_path(name).unwrap()
} else {
unreachable!()
};
let palette_colors = match resolve_include_with_detection(
include_path,
input_dir,
&mut include_visited,
) {
Ok(palette) => palette.colors,
Err(e) => {
if strict {
eprintln!("Error: sprite '{}': {}", sprite.name, e);
return ExitCode::from(EXIT_ERROR);
}
all_warnings.push(format!("sprite '{}': {}", sprite.name, e));
std::collections::HashMap::new()
}
};
let resolved = match sprite_registry.resolve(&sprite.name, ®istry, strict) {
Ok(r) => r,
Err(e) => {
if strict {
eprintln!("Error: sprite '{}': {}", sprite.name, e);
return ExitCode::from(EXIT_ERROR);
}
all_warnings.push(format!("sprite '{}': {}", sprite.name, e));
continue;
}
};
(resolved.grid, palette_colors)
} else {
let resolved = match sprite_registry.resolve(&sprite.name, ®istry, strict) {
Ok(r) => {
for warning in &r.warnings {
all_warnings
.push(format!("sprite '{}': {}", sprite.name, warning.message));
}
r
}
Err(e) => {
if strict {
eprintln!("Error: sprite '{}': {}", sprite.name, e);
return ExitCode::from(EXIT_ERROR);
}
all_warnings.push(format!("sprite '{}': {}", sprite.name, e));
continue;
}
};
(resolved.grid, resolved.palette)
};
let render_sprite_data = crate::registry::ResolvedSprite {
name: sprite.name.clone(),
size: sprite.size,
grid: final_grid,
palette: final_palette,
warnings: vec![],
nine_slice: sprite.nine_slice.clone(),
};
let (mut image, render_warnings) = render_resolved(&render_sprite_data);
if let Some((target_w, target_h)) = nine_slice_size {
if let Some(ref nine_slice) = sprite.nine_slice {
let (ns_image, ns_warnings) =
crate::renderer::render_nine_slice(&image, nine_slice, target_w, target_h);
image = ns_image;
for warning in ns_warnings {
all_warnings.push(format!("sprite '{}': {}", sprite.name, warning.message));
}
} else {
eprintln!(
"Warning: --nine-slice specified but sprite '{}' has no nine_slice attribute",
sprite.name
);
}
}
let image = scale_image(image, scale);
for warning in render_warnings {
all_warnings.push(format!("sprite '{}': {}", sprite.name, warning.message));
}
if strict && !all_warnings.is_empty() {
for warning in &all_warnings {
eprintln!("Error: {}", warning);
}
return ExitCode::from(EXIT_ERROR);
}
let output_path = generate_output_path(input, &sprite.name, output, is_single_output);
if let Err(e) = save_png(&image, &output_path) {
eprintln!("Error: Failed to save '{}': {}", output_path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
println!("Saved: {}", output_path.display());
}
}
if render_compositions {
for (comp_name, comp) in &compositions_by_name {
let result = render_composition_to_image(
comp,
&sprites_by_name,
&sprite_registry,
®istry,
input_dir,
&mut include_visited,
&mut all_warnings,
strict,
);
let image = match result {
Ok(img) => img,
Err(code) => return code,
};
let image = scale_image(image, scale);
if strict && !all_warnings.is_empty() {
for warning in &all_warnings {
eprintln!("Error: {}", warning);
}
return ExitCode::from(EXIT_ERROR);
}
let is_single = compositions_by_name.len() == 1 && sprites.is_empty();
let output_path = generate_output_path(input, comp_name, output, is_single);
if let Err(e) = save_png(&image, &output_path) {
eprintln!("Error: Failed to save '{}': {}", output_path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
println!("Saved: {}", output_path.display());
}
}
for warning in &all_warnings {
eprintln!("Warning: {}", warning);
}
ExitCode::from(EXIT_SUCCESS)
}
#[allow(clippy::too_many_arguments)]
fn run_composition_render(
input: &std::path::Path,
output: Option<&std::path::Path>,
comp_name: &str,
compositions: &std::collections::HashMap<String, Composition>,
sprites: &std::collections::HashMap<String, Sprite>,
sprite_registry: &SpriteRegistry,
palette_registry: &PaletteRegistry,
input_dir: &std::path::Path,
include_visited: &mut HashSet<PathBuf>,
all_warnings: &mut Vec<String>,
strict: bool,
scale: u8,
) -> ExitCode {
let comp = match compositions.get(comp_name) {
Some(c) => c,
None => {
eprintln!("Error: No composition named '{}' found in input", comp_name);
let comp_names: Vec<&str> = compositions.keys().map(|s| s.as_str()).collect();
if let Some(suggestion) = format_suggestion(&suggest(comp_name, &comp_names, 3)) {
eprintln!("{}", suggestion);
}
return ExitCode::from(EXIT_ERROR);
}
};
let result = render_composition_to_image(
comp,
sprites,
sprite_registry,
palette_registry,
input_dir,
include_visited,
all_warnings,
strict,
);
let image = match result {
Ok(img) => img,
Err(code) => return code,
};
let image = scale_image(image, scale);
if strict && !all_warnings.is_empty() {
for warning in all_warnings.iter() {
eprintln!("Error: {}", warning);
}
return ExitCode::from(EXIT_ERROR);
}
let output_path = generate_output_path(input, comp_name, output, true);
if let Err(e) = save_png(&image, &output_path) {
eprintln!("Error: Failed to save '{}': {}", output_path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
println!("Saved: {}", output_path.display());
for warning in all_warnings.iter() {
eprintln!("Warning: {}", warning);
}
ExitCode::from(EXIT_SUCCESS)
}
#[allow(clippy::too_many_arguments)]
fn render_composition_to_image(
comp: &Composition,
sprites: &std::collections::HashMap<String, Sprite>,
sprite_registry: &SpriteRegistry,
palette_registry: &PaletteRegistry,
input_dir: &std::path::Path,
include_visited: &mut HashSet<PathBuf>,
all_warnings: &mut Vec<String>,
strict: bool,
) -> Result<image::RgbaImage, ExitCode> {
use image::RgbaImage;
let mut required_sprites: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Some(ref base_name) = comp.base {
required_sprites.insert(base_name.clone());
}
for sprite_name in comp.sprites.values().flatten() {
required_sprites.insert(sprite_name.clone());
}
let mut rendered_sprites: std::collections::HashMap<String, RgbaImage> =
std::collections::HashMap::new();
for sprite_name in &required_sprites {
let original_sprite = sprites.get(sprite_name);
let resolved_sprite = match sprite_registry.resolve(sprite_name, palette_registry, strict) {
Ok(resolved) => {
for warning in &resolved.warnings {
all_warnings.push(format!("sprite '{}': {}", sprite_name, warning.message));
}
resolved
}
Err(e) => {
let warning_msg = format!(
"composition '{}': sprite '{}' resolution failed: {}",
comp.name, sprite_name, e
);
if strict {
eprintln!("Error: {}", warning_msg);
return Err(ExitCode::from(EXIT_ERROR));
}
all_warnings.push(warning_msg);
continue;
}
};
let final_palette = if resolved_sprite.palette.is_empty() {
if let Some(sprite) = original_sprite {
if let PaletteRef::Named(name) = &sprite.palette {
if is_include_ref(name) {
let include_path = extract_include_path(name).unwrap();
match resolve_include_with_detection(
include_path,
input_dir,
include_visited,
) {
Ok(palette) => palette.colors,
Err(e) => {
if strict {
eprintln!("Error: sprite '{}': {}", sprite_name, e);
return Err(ExitCode::from(EXIT_ERROR));
}
all_warnings.push(format!("sprite '{}': {}", sprite_name, e));
std::collections::HashMap::new()
}
}
} else {
resolved_sprite.palette.clone()
}
} else {
resolved_sprite.palette.clone()
}
} else {
resolved_sprite.palette.clone()
}
} else {
resolved_sprite.palette.clone()
};
let render_sprite_data = crate::registry::ResolvedSprite {
name: resolved_sprite.name.clone(),
size: resolved_sprite.size,
grid: resolved_sprite.grid.clone(),
palette: final_palette,
warnings: vec![],
nine_slice: resolved_sprite.nine_slice.clone(),
};
let (image, render_warnings) = render_resolved(&render_sprite_data);
for warning in render_warnings {
all_warnings.push(format!("sprite '{}': {}", sprite_name, warning.message));
}
if strict && !all_warnings.is_empty() {
for w in all_warnings.iter() {
eprintln!("Error: {}", w);
}
return Err(ExitCode::from(EXIT_ERROR));
}
rendered_sprites.insert(sprite_name.clone(), image);
}
let result = render_composition(comp, &rendered_sprites, strict, None);
match result {
Ok((image, comp_warnings)) => {
for warning in comp_warnings {
all_warnings.push(format!("composition '{}': {}", comp.name, warning.message));
}
Ok(image)
}
Err(e) => {
eprintln!("Error: composition '{}': {}", comp.name, e);
Err(ExitCode::from(EXIT_ERROR))
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_animation_render(
input: &std::path::Path,
output: Option<&std::path::Path>,
animations: &std::collections::HashMap<String, Animation>,
sprites: &std::collections::HashMap<String, Sprite>,
_sprite_registry: &SpriteRegistry,
palette_registry: &PaletteRegistry,
input_dir: &std::path::Path,
include_visited: &mut HashSet<PathBuf>,
all_warnings: &mut Vec<String>,
strict: bool,
scale: u8,
gif_output: bool,
animation_filter: Option<&str>,
) -> ExitCode {
let animation = if let Some(name) = animation_filter {
match animations.get(name) {
Some(anim) => anim,
None => {
eprintln!("Error: No animation named '{}' found in input", name);
let anim_names: Vec<&str> = animations.keys().map(|s| s.as_str()).collect();
if let Some(suggestion) = format_suggestion(&suggest(name, &anim_names, 3)) {
eprintln!("{}", suggestion);
}
return ExitCode::from(EXIT_ERROR);
}
}
} else {
match animations.values().next() {
Some(anim) => anim,
None => {
eprintln!("Error: No animations found in input file");
return ExitCode::from(EXIT_ERROR);
}
}
};
let mut missing_frames = Vec::new();
for frame_name in &animation.frames {
if !sprites.contains_key(frame_name) {
missing_frames.push(frame_name.clone());
}
}
if !missing_frames.is_empty() {
let warning_msg = format!(
"Animation '{}' references missing sprites: {}",
animation.name,
missing_frames.join(", ")
);
if strict {
eprintln!("Error: {}", warning_msg);
return ExitCode::from(EXIT_ERROR);
}
all_warnings.push(warning_msg);
}
if animation.frames.is_empty() {
let warning_msg = format!("Animation '{}' has no frames", animation.name);
if strict {
eprintln!("Error: {}", warning_msg);
return ExitCode::from(EXIT_ERROR);
}
all_warnings.push(warning_msg);
}
let (frame_images, frame_duration) = if animation.has_palette_cycle()
&& animation.frames.len() == 1
{
let frame_name = &animation.frames[0];
let sprite = match sprites.get(frame_name) {
Some(s) => s,
None => {
eprintln!(
"Error: Animation '{}' references missing sprite '{}'",
animation.name, frame_name
);
return ExitCode::from(EXIT_ERROR);
}
};
let resolved = match &sprite.palette {
PaletteRef::Named(name) if is_include_ref(name) => {
let include_path = extract_include_path(name).unwrap();
match resolve_include_with_detection(include_path, input_dir, include_visited) {
Ok(palette) => ResolvedPalette {
colors: palette.colors,
source: PaletteSource::Named(format!("@include:{}", include_path)),
},
Err(e) => {
if strict {
eprintln!("Error: sprite '{}': {}", sprite.name, e);
return ExitCode::from(EXIT_ERROR);
}
all_warnings.push(format!("sprite '{}': {}", sprite.name, e));
ResolvedPalette {
colors: std::collections::HashMap::new(),
source: PaletteSource::Fallback,
}
}
}
}
_ => match palette_registry.resolve(sprite, strict) {
Ok(result) => {
if let Some(warning) = result.warning {
all_warnings.push(format!("sprite '{}': {}", sprite.name, warning.message));
if strict {
for warning in all_warnings.iter() {
eprintln!("Error: {}", warning);
}
return ExitCode::from(EXIT_ERROR);
}
}
result.palette
}
Err(e) => {
eprintln!("Error: sprite '{}': {}", sprite.name, e);
return ExitCode::from(EXIT_ERROR);
}
},
};
let (frames, cycle_warnings) = generate_cycle_frames(sprite, &resolved.colors, animation);
for warning in cycle_warnings {
all_warnings.push(format!("sprite '{}': {}", sprite.name, warning));
}
if strict && !all_warnings.is_empty() {
for warning in all_warnings.iter() {
eprintln!("Error: {}", warning);
}
return ExitCode::from(EXIT_ERROR);
}
let scaled_frames: Vec<_> = frames.into_iter().map(|f| scale_image(f, scale)).collect();
let duration = get_cycle_duration(animation);
(scaled_frames, duration)
} else {
let mut frame_images = Vec::new();
for frame_name in &animation.frames {
let sprite = match sprites.get(frame_name) {
Some(s) => s,
None => continue, };
let resolved = match &sprite.palette {
PaletteRef::Named(name) if is_include_ref(name) => {
let include_path = extract_include_path(name).unwrap();
match resolve_include_with_detection(include_path, input_dir, include_visited) {
Ok(palette) => ResolvedPalette {
colors: palette.colors,
source: PaletteSource::Named(format!("@include:{}", include_path)),
},
Err(e) => {
if strict {
eprintln!("Error: sprite '{}': {}", sprite.name, e);
return ExitCode::from(EXIT_ERROR);
}
all_warnings.push(format!("sprite '{}': {}", sprite.name, e));
ResolvedPalette {
colors: std::collections::HashMap::new(),
source: PaletteSource::Fallback,
}
}
}
}
_ => match palette_registry.resolve(sprite, strict) {
Ok(result) => {
if let Some(warning) = result.warning {
all_warnings
.push(format!("sprite '{}': {}", sprite.name, warning.message));
if strict {
for warning in all_warnings.iter() {
eprintln!("Error: {}", warning);
}
return ExitCode::from(EXIT_ERROR);
}
}
result.palette
}
Err(e) => {
eprintln!("Error: sprite '{}': {}", sprite.name, e);
return ExitCode::from(EXIT_ERROR);
}
},
};
let (image, render_warnings) = render_sprite(sprite, &resolved.colors);
let image = scale_image(image, scale);
for warning in render_warnings {
all_warnings.push(format!("sprite '{}': {}", sprite.name, warning.message));
}
if strict && !all_warnings.is_empty() {
for warning in all_warnings.iter() {
eprintln!("Error: {}", warning);
}
return ExitCode::from(EXIT_ERROR);
}
frame_images.push(image);
}
(frame_images, animation.duration_ms())
};
if frame_images.is_empty() {
eprintln!("Error: No valid frames to render in animation '{}'", animation.name);
return ExitCode::from(EXIT_ERROR);
}
let output_path = if let Some(path) = output {
path.to_path_buf()
} else {
let extension = if gif_output { "gif" } else { "png" };
let stem = input.file_stem().unwrap_or_default().to_string_lossy();
input
.parent()
.unwrap_or(std::path::Path::new("."))
.join(format!("{}_{}.{}", stem, animation.name, extension))
};
if gif_output {
if let Err(e) = render_gif(&frame_images, frame_duration, animation.loops(), &output_path) {
eprintln!("Error: Failed to save GIF '{}': {}", output_path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
} else {
let sheet = render_spritesheet(&frame_images, None);
if let Err(e) = save_png(&sheet, &output_path) {
eprintln!("Error: Failed to save spritesheet '{}': {}", output_path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
}
println!("Saved: {}", output_path.display());
for warning in all_warnings.iter() {
eprintln!("Warning: {}", warning);
}
ExitCode::from(EXIT_SUCCESS)
}
fn parse_max_size(arg: Option<&str>) -> Result<(u32, u32), String> {
match arg {
None => Ok((4096, 4096)), Some(s) => {
let parts: Vec<&str> = s.split('x').collect();
if parts.len() != 2 {
return Err(format!("Invalid max-size format '{}'. Use WxH (e.g., 512x512)", s));
}
let w = parts[0].parse::<u32>().map_err(|_| format!("Invalid width in '{}'", s))?;
let h = parts[1].parse::<u32>().map_err(|_| format!("Invalid height in '{}'", s))?;
if w == 0 || h == 0 {
return Err("Width and height must be greater than 0".to_string());
}
Ok((w, h))
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_atlas_render(
input: &std::path::Path,
output: Option<&std::path::Path>,
sprites: &std::collections::HashMap<String, Sprite>,
animations: &std::collections::HashMap<String, Animation>,
_sprite_registry: &SpriteRegistry,
palette_registry: &PaletteRegistry,
input_dir: &std::path::Path,
include_visited: &mut HashSet<PathBuf>,
all_warnings: &mut Vec<String>,
strict: bool,
scale: u8,
format: &str,
max_size_arg: Option<&str>,
padding: u32,
power_of_two: bool,
) -> ExitCode {
let max_size = match parse_max_size(max_size_arg) {
Ok(size) => size,
Err(e) => {
eprintln!("Error: {}", e);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let config = AtlasConfig { max_size, padding, power_of_two };
let mut sprite_inputs: Vec<SpriteInput> = Vec::new();
for sprite in sprites.values() {
let resolved = match &sprite.palette {
PaletteRef::Named(name) if is_include_ref(name) => {
let include_path = extract_include_path(name).unwrap();
match resolve_include_with_detection(include_path, input_dir, include_visited) {
Ok(palette) => ResolvedPalette {
colors: palette.colors,
source: PaletteSource::Named(format!("@include:{}", include_path)),
},
Err(e) => {
if strict {
eprintln!("Error: sprite '{}': {}", sprite.name, e);
return ExitCode::from(EXIT_ERROR);
}
all_warnings.push(format!("sprite '{}': {}", sprite.name, e));
continue;
}
}
}
_ => match palette_registry.resolve(sprite, strict) {
Ok(result) => {
if let Some(warning) = result.warning {
all_warnings.push(format!("sprite '{}': {}", sprite.name, warning.message));
if strict {
for w in all_warnings.iter() {
eprintln!("Error: {}", w);
}
return ExitCode::from(EXIT_ERROR);
}
}
result.palette
}
Err(e) => {
eprintln!("Error: sprite '{}': {}", sprite.name, e);
return ExitCode::from(EXIT_ERROR);
}
},
};
let (image, render_warnings) = render_sprite(sprite, &resolved.colors);
let image = scale_image(image, scale);
for warning in render_warnings {
all_warnings.push(format!("sprite '{}': {}", sprite.name, warning.message));
}
if strict && !all_warnings.is_empty() {
for w in all_warnings.iter() {
eprintln!("Error: {}", w);
}
return ExitCode::from(EXIT_ERROR);
}
let (origin, boxes) = if let Some(ref meta) = sprite.metadata {
let origin = meta.origin;
let boxes = meta.boxes.as_ref().map(|b| {
b.iter()
.map(|(name, cb)| {
(name.clone(), AtlasBox { x: cb.x, y: cb.y, w: cb.w, h: cb.h })
})
.collect()
});
(origin, boxes)
} else {
(None, None)
};
sprite_inputs.push(SpriteInput { name: sprite.name.clone(), image, origin, boxes });
}
if sprite_inputs.is_empty() {
eprintln!("Error: No sprites to pack into atlas");
return ExitCode::from(EXIT_ERROR);
}
let base_name = if let Some(out_path) = output {
out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("atlas").to_string()
} else {
input
.file_stem()
.and_then(|s| s.to_str())
.map(|s| format!("{}_atlas", s))
.unwrap_or_else(|| "atlas".to_string())
};
let output_dir = output
.and_then(|p| p.parent())
.unwrap_or_else(|| input.parent().unwrap_or(std::path::Path::new(".")));
let result = pack_atlas(&sprite_inputs, &config, &base_name);
if result.atlases.is_empty() {
eprintln!("Error: Failed to pack sprites into atlas");
return ExitCode::from(EXIT_ERROR);
}
for (image, mut metadata) in result.atlases {
for anim in animations.values() {
let fps = 1000 / anim.duration_ms().max(1);
add_animation_to_atlas(&mut metadata, &anim.name, &anim.frames, fps);
}
let image_path = output_dir.join(&metadata.image);
let json_name = metadata.image.replace(".png", ".json");
let json_path = output_dir.join(&json_name);
if let Err(e) = save_png(&image, &image_path) {
eprintln!("Error: Failed to save atlas '{}': {}", image_path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
let json_content = match format {
"atlas" => serde_json::to_string_pretty(&metadata).unwrap(),
"atlas-aseprite" => generate_aseprite_json(&metadata),
"atlas-godot" => generate_godot_json(&metadata),
"atlas-unity" => generate_unity_json(&metadata),
"atlas-libgdx" => generate_libgdx_atlas(&metadata),
_ => serde_json::to_string_pretty(&metadata).unwrap(),
};
let final_json_path = if format == "atlas-libgdx" {
output_dir.join(metadata.image.replace(".png", ".atlas"))
} else {
json_path
};
if let Err(e) = std::fs::write(&final_json_path, &json_content) {
eprintln!("Error: Failed to save metadata '{}': {}", final_json_path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
println!("Saved: {} + {}", image_path.display(), final_json_path.display());
}
for warning in all_warnings.iter() {
eprintln!("Warning: {}", warning);
}
ExitCode::from(EXIT_SUCCESS)
}
fn generate_aseprite_json(metadata: &crate::atlas::AtlasMetadata) -> String {
let frames: serde_json::Map<String, serde_json::Value> = metadata
.frames
.iter()
.map(|(name, frame)| {
(
format!("{}.png", name),
serde_json::json!({
"frame": {"x": frame.x, "y": frame.y, "w": frame.w, "h": frame.h},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {"x": 0, "y": 0, "w": frame.w, "h": frame.h},
"sourceSize": {"w": frame.w, "h": frame.h}
}),
)
})
.collect();
let meta = serde_json::json!({
"app": "pixelsrc",
"version": "1.0",
"image": metadata.image,
"format": "RGBA8888",
"size": {"w": metadata.size[0], "h": metadata.size[1]},
"scale": "1"
});
serde_json::to_string_pretty(&serde_json::json!({
"frames": frames,
"meta": meta
}))
.unwrap()
}
fn generate_godot_json(metadata: &crate::atlas::AtlasMetadata) -> String {
let textures: Vec<serde_json::Value> = metadata
.frames
.iter()
.map(|(name, frame)| {
serde_json::json!({
"name": name,
"region": {"x": frame.x, "y": frame.y, "w": frame.w, "h": frame.h}
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"textures": [{
"image": metadata.image,
"size": {"w": metadata.size[0], "h": metadata.size[1]},
"sprites": textures
}]
}))
.unwrap()
}
fn generate_unity_json(metadata: &crate::atlas::AtlasMetadata) -> String {
let sprites: Vec<serde_json::Value> = metadata
.frames
.iter()
.map(|(name, frame)| {
serde_json::json!({
"name": name,
"rect": {
"x": frame.x,
"y": metadata.size[1] - frame.y - frame.h, "width": frame.w,
"height": frame.h
},
"pivot": {"x": 0.5, "y": 0.5}
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"texture": metadata.image,
"textureSize": {"width": metadata.size[0], "height": metadata.size[1]},
"sprites": sprites
}))
.unwrap()
}
fn generate_libgdx_atlas(metadata: &crate::atlas::AtlasMetadata) -> String {
let mut lines = vec![
metadata.image.clone(),
format!("size: {},{}", metadata.size[0], metadata.size[1]),
"format: RGBA8888".to_string(),
"filter: Nearest,Nearest".to_string(),
"repeat: none".to_string(),
];
for (name, frame) in &metadata.frames {
lines.push(name.clone());
lines.push(" rotate: false".to_string());
lines.push(format!(" xy: {}, {}", frame.x, frame.y));
lines.push(format!(" size: {}, {}", frame.w, frame.h));
lines.push(format!(" orig: {}, {}", frame.w, frame.h));
lines.push(" offset: 0, 0".to_string());
lines.push(" index: -1".to_string());
}
lines.join("\n")
}
fn run_import(
input: &PathBuf,
output: Option<&std::path::Path>,
max_colors: usize,
sprite_name: Option<&str>,
) -> ExitCode {
if !(2..=256).contains(&max_colors) {
eprintln!("Error: --max-colors must be between 2 and 256");
return ExitCode::from(EXIT_INVALID_ARGS);
}
let name = sprite_name
.map(String::from)
.unwrap_or_else(|| input.file_stem().unwrap_or_default().to_string_lossy().to_string());
let result = match import_png(input, &name, max_colors) {
Ok(r) => r,
Err(e) => {
eprintln!("Error: {}", e);
return ExitCode::from(EXIT_ERROR);
}
};
let output_path = output.map(|p| p.to_path_buf()).unwrap_or_else(|| {
let stem = input.file_stem().unwrap_or_default().to_string_lossy();
input.parent().unwrap_or(std::path::Path::new(".")).join(format!("{}.jsonl", stem))
});
let jsonl = result.to_jsonl();
if let Err(e) = std::fs::write(&output_path, jsonl) {
eprintln!("Error: Failed to write '{}': {}", output_path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
println!(
"Imported: {} ({}x{}, {} colors)",
output_path.display(),
result.width,
result.height,
result.palette.len()
);
ExitCode::from(EXIT_SUCCESS)
}
fn run_analyze(
files: &[PathBuf],
dir: Option<&std::path::Path>,
recursive: bool,
format: &str,
output: Option<&std::path::Path>,
) -> ExitCode {
if format != "text" && format != "json" {
eprintln!("Error: --format must be 'text' or 'json'");
return ExitCode::from(EXIT_INVALID_ARGS);
}
let file_list = match collect_files(files, dir, recursive) {
Ok(files) => files,
Err(e) => {
eprintln!("Error: {}", e);
return ExitCode::from(EXIT_ERROR);
}
};
if file_list.is_empty() {
eprintln!("Error: No files to analyze");
return ExitCode::from(EXIT_INVALID_ARGS);
}
let mut report = AnalysisReport::new();
let total_files = file_list.len();
let show_progress = total_files > 1 && output.is_some();
for (i, path) in file_list.iter().enumerate() {
if show_progress {
eprint!("\rAnalyzing file {}/{}: {}", i + 1, total_files, path.display());
}
if let Err(e) = report.analyze_file(path) {
report.files_failed += 1;
report.failed_files.push((path.clone(), e));
}
}
if show_progress {
eprintln!(); }
let output_text = if format == "json" {
serde_json::json!({
"files_analyzed": report.files_analyzed,
"files_failed": report.files_failed,
"total_sprites": report.total_sprites,
"total_palettes": report.total_palettes,
"total_compositions": report.total_compositions,
"total_animations": report.total_animations,
"total_variants": report.total_variants,
"unique_tokens": report.token_counter.unique_count(),
"total_token_occurrences": report.token_counter.total(),
"top_tokens": report.token_counter.top_n(10).iter().map(|(t, c)| {
serde_json::json!({
"token": t,
"count": c,
"percentage": report.token_counter.percentage(t)
})
}).collect::<Vec<_>>(),
"co_occurrence": report.co_occurrence.top_n(10).iter().map(|((t1, t2), count)| {
serde_json::json!({
"token1": t1,
"token2": t2,
"sprites": count
})
}).collect::<Vec<_>>(),
"token_families": report.token_families().iter().take(10).map(|family| {
serde_json::json!({
"prefix": family.prefix,
"tokens": family.tokens,
"total_count": family.total_count
})
}).collect::<Vec<_>>(),
"avg_palette_size": report.avg_palette_size(),
})
.to_string()
} else {
format_report_text(&report)
};
if let Some(output_path) = output {
if let Err(e) = std::fs::write(output_path, &output_text) {
eprintln!("Error: Failed to write '{}': {}", output_path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
println!("Report written to: {}", output_path.display());
} else {
print!("{}", output_text);
}
ExitCode::from(EXIT_SUCCESS)
}
fn run_fmt(files: &[PathBuf], check: bool, stdout_mode: bool) -> ExitCode {
let mut needs_formatting = false;
for file in files {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: Cannot read '{}': {}", file.display(), e);
return ExitCode::from(EXIT_ERROR);
}
};
let formatted = match format_pixelsrc(&content) {
Ok(f) => f,
Err(e) => {
eprintln!("Error: Cannot format '{}': {}", file.display(), e);
return ExitCode::from(EXIT_ERROR);
}
};
if check {
if content != formatted {
eprintln!("{}: needs formatting", file.display());
needs_formatting = true;
}
} else if stdout_mode {
print!("{}", formatted);
} else {
if content != formatted {
if let Err(e) = std::fs::write(file, &formatted) {
eprintln!("Error: Cannot write '{}': {}", file.display(), e);
return ExitCode::from(EXIT_ERROR);
}
eprintln!("{}: formatted", file.display());
} else {
eprintln!("{}: already formatted", file.display());
}
}
}
if check && needs_formatting {
ExitCode::from(EXIT_ERROR)
} else {
ExitCode::from(EXIT_SUCCESS)
}
}
fn run_validate(files: &[PathBuf], stdin: bool, strict: bool, json: bool) -> ExitCode {
use std::io::{self, BufRead};
let mut validator = Validator::new();
if stdin {
let stdin_handle = io::stdin();
for (line_idx, line_result) in stdin_handle.lock().lines().enumerate() {
let line_number = line_idx + 1;
match line_result {
Ok(line) => validator.validate_line(line_number, &line),
Err(e) => {
eprintln!("Error reading stdin at line {}: {}", line_number, e);
return ExitCode::from(EXIT_ERROR);
}
}
}
} else {
if files.is_empty() {
eprintln!("Error: No files to validate");
return ExitCode::from(EXIT_INVALID_ARGS);
}
for path in files {
if !json {
println!("Validating {}...", path.display());
}
if let Err(e) = validator.validate_file(path) {
eprintln!("Error: Cannot read '{}': {}", path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
}
}
let issues = validator.into_issues();
let error_count = issues.iter().filter(|i| matches!(i.severity, Severity::Error)).count();
let warning_count = issues.iter().filter(|i| matches!(i.severity, Severity::Warning)).count();
let has_failures = error_count > 0 || (strict && warning_count > 0);
if json {
let errors: Vec<_> = issues
.iter()
.filter(|i| matches!(i.severity, Severity::Error))
.map(|i| {
let mut obj = serde_json::json!({
"line": i.line,
"type": i.issue_type.to_string(),
"message": i.message,
});
if let Some(ref ctx) = i.context {
obj["context"] = serde_json::json!(ctx);
}
if let Some(ref sug) = i.suggestion {
obj["suggestion"] = serde_json::json!(sug);
}
obj
})
.collect();
let warnings: Vec<_> = issues
.iter()
.filter(|i| matches!(i.severity, Severity::Warning))
.map(|i| {
let mut obj = serde_json::json!({
"line": i.line,
"type": i.issue_type.to_string(),
"message": i.message,
});
if let Some(ref ctx) = i.context {
obj["context"] = serde_json::json!(ctx);
}
if let Some(ref sug) = i.suggestion {
obj["suggestion"] = serde_json::json!(sug);
}
obj
})
.collect();
let output = serde_json::json!({
"valid": !has_failures,
"errors": errors,
"warnings": warnings,
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
} else {
if issues.is_empty() {
println!();
println!("No issues found.");
} else {
println!();
for issue in &issues {
let severity_str = match issue.severity {
Severity::Error => "ERROR",
Severity::Warning => "WARNING",
};
let mut msg = format!("Line {}: {} - {}", issue.line, severity_str, issue.message);
if let Some(ref ctx) = issue.context {
msg.push_str(&format!(" ({})", ctx));
}
if let Some(ref sug) = issue.suggestion {
msg.push_str(&format!(" ({})", sug));
}
eprintln!("{}", msg);
}
println!();
match (error_count, warning_count) {
(0, w) => println!("Found {} warning{}.", w, if w == 1 { "" } else { "s" }),
(e, 0) => println!("Found {} error{}.", e, if e == 1 { "" } else { "s" }),
(e, w) => println!(
"Found {} error{}, {} warning{}.",
e,
if e == 1 { "" } else { "s" },
w,
if w == 1 { "" } else { "s" }
),
}
if !strict && warning_count > 0 && error_count == 0 {
println!("Hint: Run with --strict to treat warnings as errors.");
}
}
}
if has_failures {
ExitCode::from(EXIT_ERROR)
} else {
ExitCode::from(EXIT_SUCCESS)
}
}
fn run_agent_verify(
stdin: bool,
content: Option<&str>,
strict: bool,
grid_info: bool,
suggest_tokens: bool,
resolve_colors_flag: bool,
analyze_timing_flag: bool,
) -> ExitCode {
use std::io::{self, Read};
let content_string: String = if let Some(c) = content {
c.to_string()
} else if stdin || content.is_none() {
let mut buffer = String::new();
if let Err(e) = io::stdin().read_to_string(&mut buffer) {
let error_json = serde_json::json!({
"error": format!("Failed to read from stdin: {}", e)
});
println!("{}", serde_json::to_string_pretty(&error_json).unwrap());
return ExitCode::from(EXIT_ERROR);
}
buffer
} else {
let error_json = serde_json::json!({
"error": "No content provided. Use --content or provide input via stdin."
});
println!("{}", serde_json::to_string_pretty(&error_json).unwrap());
return ExitCode::from(EXIT_INVALID_ARGS);
};
let client = if strict { LspAgentClient::strict() } else { LspAgentClient::new() };
let mut result = serde_json::Map::new();
let verification = client.verify_content(&content_string);
result.insert("valid".to_string(), serde_json::json!(verification.valid));
result.insert("error_count".to_string(), serde_json::json!(verification.error_count));
result.insert("warning_count".to_string(), serde_json::json!(verification.warning_count));
let errors: Vec<serde_json::Value> = verification
.errors
.iter()
.map(|d| {
let mut obj = serde_json::json!({
"line": d.line,
"type": d.issue_type,
"message": d.message,
});
if let Some(ref ctx) = d.context {
obj["context"] = serde_json::json!(ctx);
}
if let Some(ref sug) = d.suggestion {
obj["suggestion"] = serde_json::json!(sug);
}
obj
})
.collect();
result.insert("errors".to_string(), serde_json::json!(errors));
let warnings: Vec<serde_json::Value> = verification
.warnings
.iter()
.map(|d| {
let mut obj = serde_json::json!({
"line": d.line,
"type": d.issue_type,
"message": d.message,
});
if let Some(ref ctx) = d.context {
obj["context"] = serde_json::json!(ctx);
}
if let Some(ref sug) = d.suggestion {
obj["suggestion"] = serde_json::json!(sug);
}
obj
})
.collect();
result.insert("warnings".to_string(), serde_json::json!(warnings));
if grid_info {
let grid_info_vec: Vec<serde_json::Value> = content_string
.lines()
.filter_map(|line| {
let obj: serde_json::Value = serde_json::from_str(line).ok()?;
let obj = obj.as_object()?;
if obj.get("type")?.as_str()? != "sprite" {
return None;
}
let name = obj.get("name")?.as_str()?;
let grid = obj.get("grid")?.as_array()?;
let row_widths: Vec<usize> = grid
.iter()
.filter_map(|row| {
let row_str = row.as_str()?;
let (tokens, _) = crate::tokenizer::tokenize(row_str);
Some(tokens.len())
})
.collect();
let expected_width = if let Some(size) = obj.get("size").and_then(|s| s.as_array())
{
size.first().and_then(|v| v.as_u64()).unwrap_or(0) as usize
} else {
row_widths.first().copied().unwrap_or(0)
};
let expected_height = if let Some(size) = obj.get("size").and_then(|s| s.as_array())
{
size.get(1).and_then(|v| v.as_u64()).unwrap_or(0) as usize
} else {
row_widths.len()
};
let aligned = row_widths.iter().all(|&w| w == expected_width)
&& row_widths.len() == expected_height;
Some(serde_json::json!({
"name": name,
"size": [expected_width, expected_height],
"actual_rows": row_widths.len(),
"row_widths": row_widths,
"aligned": aligned,
}))
})
.collect();
result.insert("grid_info".to_string(), serde_json::json!(grid_info_vec));
}
if suggest_tokens {
let completions = client.get_completions(&content_string, 1, 0);
let tokens: Vec<serde_json::Value> = completions
.items
.iter()
.map(|c| {
let mut obj = serde_json::json!({
"token": c.label,
});
if let Some(ref detail) = c.detail {
obj["color"] = serde_json::json!(detail);
}
obj
})
.collect();
result.insert("available_tokens".to_string(), serde_json::json!(tokens));
}
if resolve_colors_flag {
let color_result = client.resolve_colors(&content_string);
let resolved: Vec<serde_json::Value> = color_result
.colors
.iter()
.map(|c| {
serde_json::json!({
"token": c.token,
"original": c.original,
"resolved": c.resolved,
"palette": c.palette,
})
})
.collect();
result.insert("resolved_colors".to_string(), serde_json::json!(resolved));
if !color_result.errors.is_empty() {
result.insert(
"color_resolution_errors".to_string(),
serde_json::json!(color_result.errors),
);
}
}
if analyze_timing_flag {
let timing_result = client.analyze_timing(&content_string);
let analysis: Vec<serde_json::Value> = timing_result
.animations
.iter()
.map(|t| {
let mut obj = serde_json::json!({
"animation": t.animation,
"timing_function": t.timing_function,
"description": t.description,
"curve_type": t.curve_type,
});
if let Some(ref curve) = t.ascii_curve {
obj["ascii_curve"] = serde_json::json!(curve);
}
obj
})
.collect();
result.insert("timing_analysis".to_string(), serde_json::json!(analysis));
}
println!("{}", serde_json::to_string_pretty(&serde_json::Value::Object(result)).unwrap());
if verification.valid {
ExitCode::from(EXIT_SUCCESS)
} else {
ExitCode::from(EXIT_ERROR)
}
}
fn run_explain(input: &PathBuf, name_filter: Option<&str>, json: bool) -> ExitCode {
use std::collections::HashMap;
let file = match File::open(input) {
Ok(f) => f,
Err(e) => {
eprintln!("Error: Cannot open input file '{}': {}", input.display(), e);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let reader = BufReader::new(file);
let parse_result = parse_stream(reader);
if parse_result.objects.is_empty() {
eprintln!("Error: No objects found in input file");
return ExitCode::from(EXIT_ERROR);
}
let mut known_palettes: HashMap<String, HashMap<String, String>> = HashMap::new();
for obj in &parse_result.objects {
if let TtpObject::Palette(palette) = obj {
known_palettes.insert(palette.name.clone(), palette.colors.clone());
}
}
let objects_to_explain: Vec<&TtpObject> = if let Some(name) = name_filter {
let filtered: Vec<_> = parse_result
.objects
.iter()
.filter(|obj| match obj {
TtpObject::Sprite(s) => s.name == name,
TtpObject::Palette(p) => p.name == name,
TtpObject::Animation(a) => a.name == name,
TtpObject::Composition(c) => c.name == name,
TtpObject::Variant(v) => v.name == name,
TtpObject::Particle(p) => p.name == name,
TtpObject::Transform(t) => t.name == name,
})
.collect();
if filtered.is_empty() {
eprintln!("Error: No object named '{}' found", name);
let all_names: Vec<&str> = parse_result
.objects
.iter()
.map(|obj| match obj {
TtpObject::Sprite(s) => s.name.as_str(),
TtpObject::Palette(p) => p.name.as_str(),
TtpObject::Animation(a) => a.name.as_str(),
TtpObject::Composition(c) => c.name.as_str(),
TtpObject::Variant(v) => v.name.as_str(),
TtpObject::Particle(p) => p.name.as_str(),
TtpObject::Transform(t) => t.name.as_str(),
})
.collect();
if let Some(suggestion) = format_suggestion(&suggest(name, &all_names, 3)) {
eprintln!("{}", suggestion);
}
return ExitCode::from(EXIT_ERROR);
}
filtered
} else {
parse_result.objects.iter().collect()
};
let mut explanations = Vec::new();
for obj in objects_to_explain {
let palette_colors = match obj {
TtpObject::Sprite(sprite) => resolve_palette_colors(&sprite.palette, &known_palettes),
_ => None,
};
let explanation = explain_object(obj, palette_colors.as_ref());
explanations.push(explanation);
}
if json {
let json_explanations: Vec<serde_json::Value> = explanations
.iter()
.map(|exp| match exp {
Explanation::Sprite(s) => serde_json::json!({
"type": "sprite",
"name": s.name,
"width": s.width,
"height": s.height,
"total_cells": s.total_cells,
"palette": s.palette_ref,
"tokens": s.tokens.iter().map(|t| serde_json::json!({
"token": t.token,
"count": t.count,
"percentage": t.percentage,
"color": t.color,
"color_name": t.color_name,
})).collect::<Vec<_>>(),
"transparency_ratio": s.transparency_ratio,
"consistent_rows": s.consistent_rows,
"issues": s.issues,
}),
Explanation::Palette(p) => serde_json::json!({
"type": "palette",
"name": p.name,
"color_count": p.color_count,
"colors": p.colors.iter().map(|(token, hex, name)| serde_json::json!({
"token": token,
"color": hex,
"color_name": name,
})).collect::<Vec<_>>(),
"is_builtin": p.is_builtin,
}),
Explanation::Animation(a) => serde_json::json!({
"type": "animation",
"name": a.name,
"frames": a.frames,
"frame_count": a.frame_count,
"duration_ms": a.duration_ms,
"loops": a.loops,
}),
Explanation::Composition(c) => serde_json::json!({
"type": "composition",
"name": c.name,
"base": c.base,
"size": c.size,
"cell_size": c.cell_size,
"sprite_count": c.sprite_count,
"layer_count": c.layer_count,
}),
Explanation::Variant(v) => serde_json::json!({
"type": "variant",
"name": v.name,
"base": v.base,
"override_count": v.override_count,
"overrides": v.overrides.iter().map(|(token, color)| serde_json::json!({
"token": token,
"color": color,
})).collect::<Vec<_>>(),
}),
Explanation::Particle(p) => serde_json::json!({
"type": "particle",
"name": p.name,
"sprite": p.sprite,
"rate": p.rate,
"lifetime": p.lifetime,
"has_gravity": p.has_gravity,
"has_fade": p.has_fade,
}),
Explanation::Transform(t) => serde_json::json!({
"type": "transform",
"name": t.name,
"is_parameterized": t.is_parameterized,
"params": t.params,
"generates_animation": t.generates_animation,
"frame_count": t.frame_count,
"transform_type": t.transform_type,
}),
})
.collect();
let output = if json_explanations.len() == 1 {
serde_json::to_string_pretty(&json_explanations[0]).unwrap()
} else {
serde_json::to_string_pretty(&json_explanations).unwrap()
};
println!("{}", output);
} else {
for (i, exp) in explanations.iter().enumerate() {
if i > 0 {
println!("\n{}", "=".repeat(40));
println!();
}
print!("{}", format_explanation(exp));
}
}
ExitCode::from(EXIT_SUCCESS)
}
fn run_diff(file_a: &PathBuf, file_b: &PathBuf, sprite: Option<&str>, json: bool) -> ExitCode {
let file_a_display = file_a.display().to_string();
let file_b_display = file_b.display().to_string();
let diffs = match diff_files(file_a, file_b) {
Ok(d) => d,
Err(e) => {
eprintln!("Error: {}", e);
return ExitCode::from(EXIT_ERROR);
}
};
let filtered_diffs: Vec<_> = if let Some(name) = sprite {
diffs.into_iter().filter(|(n, _)| n == name).collect()
} else {
diffs
};
if filtered_diffs.is_empty() {
if sprite.is_some() {
eprintln!("Error: Sprite '{}' not found in either file", sprite.unwrap());
return ExitCode::from(EXIT_ERROR);
}
println!("No sprites found to compare.");
return ExitCode::from(EXIT_SUCCESS);
}
if json {
let output: Vec<_> = filtered_diffs
.iter()
.map(|(name, diff)| {
let mut obj = serde_json::json!({
"sprite": name,
"summary": diff.summary,
});
if let Some(ref dim) = diff.dimension_change {
obj["dimension_change"] = serde_json::json!({
"old": [dim.old.0, dim.old.1],
"new": [dim.new.0, dim.new.1],
});
}
if !diff.palette_changes.is_empty() {
let palette_changes: Vec<_> = diff
.palette_changes
.iter()
.map(|c| match c {
crate::diff::PaletteChange::Added { token, color } => {
serde_json::json!({
"type": "added",
"token": token,
"color": color,
})
}
crate::diff::PaletteChange::Removed { token } => {
serde_json::json!({
"type": "removed",
"token": token,
})
}
crate::diff::PaletteChange::Changed { token, old_color, new_color } => {
serde_json::json!({
"type": "changed",
"token": token,
"old_color": old_color,
"new_color": new_color,
})
}
})
.collect();
obj["palette_changes"] = serde_json::json!(palette_changes);
}
if !diff.grid_changes.is_empty() {
let grid_changes: Vec<_> = diff
.grid_changes
.iter()
.map(|c| {
serde_json::json!({
"row": c.row,
"description": c.description,
})
})
.collect();
obj["grid_changes"] = serde_json::json!(grid_changes);
}
obj
})
.collect();
println!("{}", serde_json::to_string_pretty(&output).unwrap());
} else {
for (i, (name, diff)) in filtered_diffs.iter().enumerate() {
if i > 0 {
println!();
println!("---");
println!();
}
println!("{}", format_diff(name, diff, &file_a_display, &file_b_display));
}
}
ExitCode::from(EXIT_SUCCESS)
}
fn run_suggest(files: &[PathBuf], stdin: bool, json: bool, only: Option<&str>) -> ExitCode {
use std::io::{self, BufReader};
let type_filter: Option<SuggestionType> = match only {
Some("token") => Some(SuggestionType::MissingToken),
Some("row") => Some(SuggestionType::RowCompletion),
Some(other) => {
eprintln!("Error: Unknown suggestion type '{}'. Use 'token' or 'row'.", other);
return ExitCode::from(EXIT_INVALID_ARGS);
}
None => None,
};
let mut suggester = Suggester::new();
if stdin {
let stdin_handle = io::stdin();
if let Err(e) = suggester.analyze_reader(stdin_handle.lock()) {
eprintln!("Error reading stdin: {}", e);
return ExitCode::from(EXIT_ERROR);
}
} else {
if files.is_empty() {
eprintln!("Error: No files to analyze");
return ExitCode::from(EXIT_INVALID_ARGS);
}
for path in files {
if !json {
println!("Analyzing {}...", path.display());
}
let file = match File::open(path) {
Ok(f) => f,
Err(e) => {
eprintln!("Error: Cannot open '{}': {}", path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
};
if let Err(e) = suggester.analyze_reader(BufReader::new(file)) {
eprintln!("Error reading '{}': {}", path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
}
}
let report = suggester.into_report();
let suggestions: Vec<_> = if let Some(filter_type) = type_filter {
report.filter_by_type(filter_type).into_iter().cloned().collect()
} else {
report.suggestions.clone()
};
if json {
let output = serde_json::json!({
"sprites_analyzed": report.sprites_analyzed,
"suggestion_count": suggestions.len(),
"suggestions": suggestions,
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
} else {
if suggestions.is_empty() {
println!();
println!("No suggestions found.");
println!("Analyzed {} sprite(s).", report.sprites_analyzed);
} else {
println!();
println!(
"Found {} suggestion(s) in {} sprite(s):",
suggestions.len(),
report.sprites_analyzed
);
println!();
for suggestion in &suggestions {
println!(
"Line {}: [{}] {}",
suggestion.line, suggestion.suggestion_type, suggestion.sprite
);
println!(" {}", suggestion.message);
match &suggestion.fix {
SuggestionFix::ReplaceToken { from, to } => {
println!(" Fix: Replace {} with {}", from, to);
}
SuggestionFix::AddToPalette { token, suggested_color } => {
println!(" Fix: Add \"{}\": \"{}\" to palette", token, suggested_color);
}
SuggestionFix::ExtendRow {
row_index,
suggested,
tokens_to_add,
pad_token,
..
} => {
println!(
" Fix: Extend row {} by adding {} {} token(s)",
row_index + 1,
tokens_to_add,
pad_token
);
println!(" Suggested: \"{}\"", suggested);
}
}
println!();
}
let token_count = suggestions
.iter()
.filter(|s| s.suggestion_type == SuggestionType::MissingToken)
.count();
let row_count = suggestions
.iter()
.filter(|s| s.suggestion_type == SuggestionType::RowCompletion)
.count();
if token_count > 0 || row_count > 0 {
print!("Summary: ");
let mut parts = Vec::new();
if token_count > 0 {
parts.push(format!("{} missing token(s)", token_count));
}
if row_count > 0 {
parts.push(format!("{} row completion(s)", row_count));
}
println!("{}", parts.join(", "));
}
}
}
ExitCode::from(EXIT_SUCCESS)
}
fn run_inline(input: &PathBuf, sprite_filter: Option<&str>) -> ExitCode {
use crate::alias::{format_columns, parse_grid_row};
use crate::models::TtpObject;
let file = match File::open(input) {
Ok(f) => f,
Err(e) => {
eprintln!("Error: Cannot open input file '{}': {}", input.display(), e);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let reader = BufReader::new(file);
let parse_result = parse_stream(reader);
let mut sprites: Vec<_> = parse_result
.objects
.into_iter()
.filter_map(|obj| match obj {
TtpObject::Sprite(s) => Some(s),
_ => None,
})
.collect();
if sprites.is_empty() {
eprintln!("Error: No sprites found in input file");
return ExitCode::from(EXIT_ERROR);
}
if let Some(name) = sprite_filter {
let sprite_names: Vec<String> = sprites.iter().map(|s| s.name.clone()).collect();
sprites.retain(|s| s.name == name);
if sprites.is_empty() {
eprintln!("Error: No sprite named '{}' found in input", name);
let name_refs: Vec<&str> = sprite_names.iter().map(|s| s.as_str()).collect();
if let Some(suggestion) = format_suggestion(&suggest(name, &name_refs, 3)) {
eprintln!("{}", suggestion);
}
return ExitCode::from(EXIT_ERROR);
}
}
for (i, sprite) in sprites.iter().enumerate() {
if i > 0 {
println!(); }
if sprites.len() > 1 {
println!("# {}", sprite.name);
}
let rows: Vec<Vec<String>> = sprite.grid.iter().map(|row| parse_grid_row(row)).collect();
let formatted = format_columns(rows);
for row in formatted {
println!("{}", row);
}
}
ExitCode::from(EXIT_SUCCESS)
}
fn run_alias(input: &PathBuf, sprite_filter: Option<&str>) -> ExitCode {
use crate::alias::extract_aliases;
use crate::models::TtpObject;
use serde_json::json;
let file = match File::open(input) {
Ok(f) => f,
Err(e) => {
eprintln!("Error: Cannot open input file '{}': {}", input.display(), e);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let reader = BufReader::new(file);
let parse_result = parse_stream(reader);
let mut sprites: Vec<_> = parse_result
.objects
.into_iter()
.filter_map(|obj| match obj {
TtpObject::Sprite(s) => Some(s),
_ => None,
})
.collect();
if sprites.is_empty() {
eprintln!("Error: No sprites found in input file");
return ExitCode::from(EXIT_ERROR);
}
if let Some(name) = sprite_filter {
let sprite_names: Vec<String> = sprites.iter().map(|s| s.name.clone()).collect();
sprites.retain(|s| s.name == name);
if sprites.is_empty() {
eprintln!("Error: No sprite named '{}' found in input", name);
let name_refs: Vec<&str> = sprite_names.iter().map(|s| s.as_str()).collect();
if let Some(suggestion) = format_suggestion(&suggest(name, &name_refs, 3)) {
eprintln!("{}", suggestion);
}
return ExitCode::from(EXIT_ERROR);
}
}
for (i, sprite) in sprites.iter().enumerate() {
if i > 0 {
println!(); }
let (aliases, transformed_grid) = extract_aliases(&sprite.grid);
let mut alias_pairs: Vec<_> = aliases.iter().collect();
alias_pairs.sort_by_key(|(c, _)| *c);
let aliases_map: serde_json::Map<String, serde_json::Value> =
alias_pairs.into_iter().map(|(c, name)| (c.to_string(), json!(name))).collect();
let output = json!({
"aliases": aliases_map,
"grid": transformed_grid
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
}
ExitCode::from(EXIT_SUCCESS)
}
fn run_grid(input: &PathBuf, sprite_filter: Option<&str>, full_names: bool) -> ExitCode {
use crate::models::TtpObject;
let file = match File::open(input) {
Ok(f) => f,
Err(e) => {
eprintln!("Error: Cannot open input file '{}': {}", input.display(), e);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let reader = BufReader::new(file);
let parse_result = parse_stream(reader);
let mut sprites_by_name: std::collections::HashMap<String, crate::models::Sprite> =
std::collections::HashMap::new();
for obj in parse_result.objects {
if let TtpObject::Sprite(sprite) = obj {
sprites_by_name.insert(sprite.name.clone(), sprite);
}
}
if sprites_by_name.is_empty() {
eprintln!("Error: No sprites found in input file");
return ExitCode::from(EXIT_ERROR);
}
let sprite = if let Some(name) = sprite_filter {
match sprites_by_name.get(name) {
Some(s) => s,
None => {
eprintln!("Error: No sprite named '{}' found in input", name);
let sprite_names: Vec<&str> = sprites_by_name.keys().map(|s| s.as_str()).collect();
if let Some(suggestion) = format_suggestion(&suggest(name, &sprite_names, 3)) {
eprintln!("{}", suggestion);
}
return ExitCode::from(EXIT_ERROR);
}
}
} else {
sprites_by_name.values().next().unwrap()
};
let output = render_coordinate_grid(&sprite.grid, full_names);
if sprites_by_name.len() > 1 || sprite_filter.is_some() {
println!("Sprite: {}", sprite.name);
println!();
}
print!("{}", output);
ExitCode::from(EXIT_SUCCESS)
}
fn run_show(
file: &PathBuf,
sprite_filter: Option<&str>,
animation_filter: Option<&str>,
frame_index: usize,
onion_count: Option<u32>,
onion_opacity: f32,
onion_prev_color: &str,
onion_next_color: &str,
onion_fade: bool,
output: Option<&Path>,
) -> ExitCode {
use crate::models::{Animation, Sprite, TtpObject};
use crate::onion::{parse_hex_color, render_onion_skin, OnionConfig};
use crate::registry::PaletteRegistry;
use crate::renderer::render_sprite;
use std::collections::HashMap;
let input_file = match File::open(file) {
Ok(f) => f,
Err(e) => {
eprintln!("Error: Cannot open input file '{}': {}", file.display(), e);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let reader = BufReader::new(input_file);
let parse_result = parse_stream(reader);
let mut sprites_by_name: HashMap<String, Sprite> = HashMap::new();
let mut animations_by_name: HashMap<String, Animation> = HashMap::new();
let mut registry = PaletteRegistry::new();
for obj in parse_result.objects {
match obj {
TtpObject::Palette(palette) => {
registry.register(palette);
}
TtpObject::Sprite(sprite) => {
sprites_by_name.insert(sprite.name.clone(), sprite);
}
TtpObject::Animation(animation) => {
animations_by_name.insert(animation.name.clone(), animation);
}
_ => {}
}
}
if let Some(onion) = onion_count {
let animation = if let Some(name) = animation_filter {
match animations_by_name.get(name) {
Some(a) => a,
None => {
eprintln!("Error: No animation named '{}' found in input", name);
let anim_names: Vec<&str> =
animations_by_name.keys().map(|s| s.as_str()).collect();
if let Some(suggestion) = format_suggestion(&suggest(name, &anim_names, 3)) {
eprintln!("{}", suggestion);
}
return ExitCode::from(EXIT_ERROR);
}
}
} else {
match animations_by_name.values().next() {
Some(a) => a,
None => {
eprintln!(
"Error: No animations found in input file (--onion requires an animation)"
);
return ExitCode::from(EXIT_ERROR);
}
}
};
if animation.frames.is_empty() {
eprintln!("Error: Animation '{}' has no frames", animation.name);
return ExitCode::from(EXIT_ERROR);
}
let prev_color = match parse_hex_color(onion_prev_color) {
Some(c) => c,
None => {
eprintln!("Error: Invalid color for --onion-prev-color: {}", onion_prev_color);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let next_color = match parse_hex_color(onion_next_color) {
Some(c) => c,
None => {
eprintln!("Error: Invalid color for --onion-next-color: {}", onion_next_color);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let mut frame_images = Vec::new();
for frame_name in &animation.frames {
let sprite = match sprites_by_name.get(frame_name) {
Some(s) => s,
None => {
eprintln!("Error: Animation frame '{}' not found in sprites", frame_name);
return ExitCode::from(EXIT_ERROR);
}
};
let resolved_palette = match registry.resolve(sprite, false) {
Ok(result) => result.palette.colors,
Err(e) => {
eprintln!("Error: sprite '{}': {}", sprite.name, e);
return ExitCode::from(EXIT_ERROR);
}
};
let (image, _warnings) = render_sprite(sprite, &resolved_palette);
frame_images.push(image);
}
let config = OnionConfig {
count: onion,
opacity: onion_opacity.clamp(0.0, 1.0),
prev_color,
next_color,
fade: onion_fade,
};
let frame_idx = frame_index.min(frame_images.len().saturating_sub(1));
let result = render_onion_skin(&frame_images, frame_idx, &config);
if let Some(output_path) = output {
if let Err(e) = result.save(output_path) {
eprintln!("Error: Failed to save image: {}", e);
return ExitCode::from(EXIT_ERROR);
}
println!(
"Onion skin preview saved: {} (frame {}/{}, {} ghost frames)",
output_path.display(),
frame_idx + 1,
animation.frames.len(),
onion
);
} else {
println!(
"Animation: {} (frame {}/{}, {} ghost frames)",
animation.name,
frame_idx + 1,
animation.frames.len(),
onion
);
println!();
use crate::terminal::render_image_ansi;
let ansi_output = render_image_ansi(&result);
print!("{}", ansi_output);
println!();
println!("Legend: Previous frames = blue tint, Next frames = green tint");
}
return ExitCode::from(EXIT_SUCCESS);
}
if sprites_by_name.is_empty() {
eprintln!("Error: No sprites found in input file");
return ExitCode::from(EXIT_ERROR);
}
let sprite = if let Some(name) = sprite_filter {
match sprites_by_name.get(name) {
Some(s) => s,
None => {
eprintln!("Error: No sprite named '{}' found in input", name);
let sprite_names: Vec<&str> = sprites_by_name.keys().map(|s| s.as_str()).collect();
if let Some(suggestion) = format_suggestion(&suggest(name, &sprite_names, 3)) {
eprintln!("{}", suggestion);
}
return ExitCode::from(EXIT_ERROR);
}
}
} else {
match sprites_by_name.values().next() {
Some(s) => s,
None => {
eprintln!("Error: No sprites found in input file");
return ExitCode::from(EXIT_ERROR);
}
}
};
let resolved_palette = match registry.resolve(sprite, false) {
Ok(result) => result.palette.colors,
Err(e) => {
eprintln!("Error: sprite '{}': {}", sprite.name, e);
return ExitCode::from(EXIT_ERROR);
}
};
let palette_hex: HashMap<String, String> =
resolved_palette.iter().map(|(token, hex)| (token.clone(), hex.clone())).collect();
let aliases: HashMap<char, String> = HashMap::new();
let (colored_output, legend) = render_ansi_grid(&sprite.grid, &palette_hex, &aliases);
let height = sprite.grid.len();
let width = if let Some(size) = &sprite.size {
size[0] as usize
} else {
use crate::tokenizer::tokenize;
sprite.grid.first().map(|row| tokenize(row).0.len()).unwrap_or(0)
};
println!("Sprite: {} ({}x{})", sprite.name, width, height);
println!();
print!("{}", colored_output);
println!("{}", legend);
ExitCode::from(EXIT_SUCCESS)
}
fn run_build(
out: Option<&Path>,
src: Option<&Path>,
watch: bool,
dry_run: bool,
force: bool,
verbose: bool,
) -> ExitCode {
use crate::build::{BuildContext, BuildPipeline, IncrementalBuild, IncrementalStats};
use crate::config::loader::{find_config, load_config, merge_cli_overrides, CliOverrides};
let (config, project_root) = match find_config() {
Some(config_path) => {
if verbose {
println!("Using config: {}", config_path.display());
}
let cfg = match load_config(Some(&config_path)) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("Error loading config: {}", e);
return ExitCode::from(EXIT_ERROR);
}
};
let root = config_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
(cfg, root)
}
None => {
if verbose {
println!("No pxl.toml found, using defaults");
}
let root = std::env::current_dir().unwrap_or_default();
(crate::config::loader::default_config(), root)
}
};
let mut config = config;
let overrides = CliOverrides {
out: out.map(|p| p.to_path_buf()),
src: src.map(|p| p.to_path_buf()),
..Default::default()
};
merge_cli_overrides(&mut config, &overrides);
let src_dir = if config.project.src.is_absolute() {
config.project.src.clone()
} else {
project_root.join(&config.project.src)
};
if !src_dir.exists() {
eprintln!("Error: Source directory not found: {}", src_dir.display());
eprintln!("Create the directory or specify a different path with --src");
return ExitCode::from(EXIT_ERROR);
}
if dry_run {
let out_dir = if config.project.out.is_absolute() {
config.project.out.clone()
} else {
project_root.join(&config.project.out)
};
println!("Dry run - would build:");
println!(" Source: {}", src_dir.display());
println!(" Output: {}", out_dir.display());
let context = BuildContext::new(config, project_root).with_verbose(verbose);
let pipeline = BuildPipeline::new(context).with_dry_run(true);
match pipeline.build() {
Ok(result) => {
println!(" Targets: {}", result.targets.len());
for target in &result.targets {
println!(" - {}", target.target_id);
}
}
Err(e) => {
eprintln!(" Error discovering targets: {}", e);
}
}
return ExitCode::from(EXIT_SUCCESS);
}
if watch {
let watch_config = config.watch.clone();
let context = BuildContext::new(config, project_root).with_verbose(verbose);
println!("Starting watch mode...");
if force {
println!("Force mode: caching disabled");
}
println!("Press Ctrl+C to stop");
println!();
match crate::watch::watch_with_incremental(context, watch_config, force) {
Ok(()) => ExitCode::from(EXIT_SUCCESS),
Err(e) => {
eprintln!("Watch error: {}", e);
ExitCode::from(EXIT_ERROR)
}
}
} else {
if force {
println!("Building (force rebuild, ignoring cache)...");
} else {
println!("Building (incremental)...");
}
let context = BuildContext::new(config, project_root).with_verbose(verbose);
let mut incremental = IncrementalBuild::new(context).with_force(force);
match incremental.run() {
Ok(result) => {
let stats = IncrementalStats::from_result(&result);
if result.is_success() {
if stats.had_skips() && !force {
println!("{} ({} skipped - unchanged)", result.summary(), stats.skipped);
} else {
println!("{}", result.summary());
}
ExitCode::from(EXIT_SUCCESS)
} else {
eprintln!("{}", result.summary());
ExitCode::from(EXIT_ERROR)
}
}
Err(e) => {
eprintln!("Build error: {}", e);
ExitCode::from(EXIT_ERROR)
}
}
}
}
fn run_new(asset_type: &str, name: &str, palette: Option<&str>) -> ExitCode {
use crate::scaffold::{new_animation, new_palette, new_sprite, ScaffoldError};
let result = match asset_type.to_lowercase().as_str() {
"sprite" => new_sprite(name, palette),
"animation" | "anim" => new_animation(name, palette),
"palette" => new_palette(name),
_ => {
eprintln!(
"Unknown asset type '{}'. Available types: sprite, animation, palette",
asset_type
);
return ExitCode::from(EXIT_ERROR);
}
};
match result {
Ok(path) => {
println!("Created {} at {}", asset_type, path.display());
ExitCode::from(EXIT_SUCCESS)
}
Err(ScaffoldError::FileExists(path)) => {
eprintln!("Error: File already exists: {}", path.display());
ExitCode::from(EXIT_ERROR)
}
Err(ScaffoldError::NotInProject) => {
eprintln!("Error: Not in a pixelsrc project (no pxl.toml found)");
eprintln!("Run 'pxl init' to create a new project first");
ExitCode::from(EXIT_ERROR)
}
Err(ScaffoldError::InvalidName(name)) => {
eprintln!(
"Error: Invalid asset name '{}'. Use lowercase letters, numbers, and underscores.",
name
);
ExitCode::from(EXIT_ERROR)
}
Err(e) => {
eprintln!("Error: {}", e);
ExitCode::from(EXIT_ERROR)
}
}
}
fn run_init(path: Option<&Path>, name: Option<&str>, preset: &str) -> ExitCode {
use crate::init::{init_project, InitError};
let project_path = match path {
Some(p) => p.to_path_buf(),
None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
};
let project_name = name
.map(|n| n.to_string())
.or_else(|| project_path.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "my-project".to_string());
match init_project(&project_path, &project_name, preset) {
Ok(()) => {
println!("Created pixelsrc project '{}' at {}", project_name, project_path.display());
println!();
println!("Project structure:");
println!(" {}/", project_path.display());
println!(" ├── pxl.toml");
println!(" ├── .gitignore");
println!(" ├── src/pxl/");
println!(" │ ├── palettes/main.pxl");
println!(" │ └── sprites/example.pxl");
println!(" └── build/");
println!();
println!("Next steps:");
println!(" cd {}", project_path.display());
println!(" pxl render src/pxl/sprites/example.pxl");
ExitCode::from(EXIT_SUCCESS)
}
Err(InitError::DirectoryExists(dir)) => {
eprintln!("Error: Directory '{}' already exists and is not empty", dir);
eprintln!("Use an empty directory or specify a different path");
ExitCode::from(EXIT_ERROR)
}
Err(InitError::UnknownPreset(preset)) => {
eprintln!("Error: Unknown preset '{}'", preset);
eprintln!("Available presets: minimal, artist, animator, game");
ExitCode::from(EXIT_ERROR)
}
Err(e) => {
eprintln!("Error: {}", e);
ExitCode::from(EXIT_ERROR)
}
}
}
fn run_sketch(
file: Option<&Path>,
name: &str,
palette: Option<&str>,
output: Option<&Path>,
) -> ExitCode {
use std::io::{self, Read, Write};
let input = match file {
Some(path) => match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
eprintln!("Error: Cannot read '{}': {}", path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
},
None => {
let mut buffer = String::new();
if let Err(e) = io::stdin().read_to_string(&mut buffer) {
eprintln!("Error: Cannot read from stdin: {}", e);
return ExitCode::from(EXIT_ERROR);
}
buffer
}
};
let grid = parse_simple_grid(&input);
if grid.is_empty() {
eprintln!("Error: Empty input - no grid data found");
return ExitCode::from(EXIT_INVALID_ARGS);
}
let sprite = simple_grid_to_sprite(grid, name, palette);
let json_output = match serde_json::to_string_pretty(&sprite) {
Ok(s) => s,
Err(e) => {
eprintln!("Error: Failed to serialize sprite: {}", e);
return ExitCode::from(EXIT_ERROR);
}
};
match output {
Some(path) => {
let mut file = match std::fs::File::create(path) {
Ok(f) => f,
Err(e) => {
eprintln!("Error: Cannot create '{}': {}", path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
};
if let Err(e) = writeln!(file, "{}", json_output) {
eprintln!("Error: Cannot write to '{}': {}", path.display(), e);
return ExitCode::from(EXIT_ERROR);
}
}
None => {
println!("{}", json_output);
}
}
ExitCode::from(EXIT_SUCCESS)
}
#[allow(clippy::too_many_arguments)]
fn run_transform(
input: &Path,
mirror: Option<&str>,
rotate: Option<u16>,
tile: Option<&str>,
pad: Option<u32>,
outline: Option<Option<String>>,
outline_width: u32,
crop: Option<&str>,
shift: Option<&str>,
shadow: Option<&str>,
shadow_token: Option<&str>,
sprite_name: Option<&str>,
output: &Path,
stdin: bool,
allow_large: bool,
) -> ExitCode {
use std::io::{self, Cursor, Read, Write};
let content = if stdin {
let mut buffer = String::new();
if let Err(e) = io::stdin().read_to_string(&mut buffer) {
eprintln!("Error: Cannot read from stdin: {}", e);
return ExitCode::from(EXIT_ERROR);
}
buffer
} else {
match std::fs::read_to_string(input) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: Cannot read '{}': {}", input.display(), e);
return ExitCode::from(EXIT_ERROR);
}
}
};
let reader = Cursor::new(content.as_bytes());
let parse_result = parse_stream(reader);
let objects = parse_result.objects;
for warning in &parse_result.warnings {
eprintln!("Warning: {}", warning.message);
}
let sprites: Vec<&Sprite> = objects
.iter()
.filter_map(|obj| match obj {
TtpObject::Sprite(s) => Some(s),
_ => None,
})
.collect();
if sprites.is_empty() {
eprintln!("Error: No sprites found in input file");
return ExitCode::from(EXIT_INVALID_ARGS);
}
let target_sprite = match sprite_name {
Some(name) => match sprites.iter().find(|s| s.name == name) {
Some(s) => *s,
None => {
eprintln!("Error: Sprite '{}' not found in input file", name);
eprintln!(
"Available sprites: {}",
sprites.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", ")
);
return ExitCode::from(EXIT_INVALID_ARGS);
}
},
None => {
if sprites.len() > 1 {
eprintln!("Warning: Multiple sprites found, using '{}'", sprites[0].name);
eprintln!("Use --sprite to specify which sprite to transform");
}
sprites[0]
}
};
let mut grid: Vec<String> = if target_sprite.grid.is_empty() {
eprintln!("Error: Sprite '{}' has no grid data", target_sprite.name);
return ExitCode::from(EXIT_ERROR);
} else {
target_sprite.grid.clone()
};
if let Some(axis) = mirror {
match axis.to_lowercase().as_str() {
"h" | "horizontal" => {
grid = apply_mirror_horizontal(&grid);
}
"v" | "vertical" => {
grid = apply_mirror_vertical(&grid);
}
"both" => {
grid = apply_mirror_horizontal(&grid);
grid = apply_mirror_vertical(&grid);
}
_ => {
eprintln!(
"Error: Invalid mirror axis '{}'. Use 'horizontal', 'vertical', or 'both'",
axis
);
return ExitCode::from(EXIT_INVALID_ARGS);
}
}
}
if let Some(degrees) = rotate {
if degrees != 90 && degrees != 180 && degrees != 270 {
eprintln!("Error: Invalid rotation degrees {}. Use 90, 180, or 270", degrees);
return ExitCode::from(EXIT_INVALID_ARGS);
}
grid = apply_rotate(&grid, degrees);
}
if let Some(tile_spec) = tile {
let parts: Vec<&str> = tile_spec.split('x').collect();
if parts.len() != 2 {
eprintln!("Error: Invalid tile format '{}'. Use 'WxH' (e.g., '2x3')", tile_spec);
return ExitCode::from(EXIT_INVALID_ARGS);
}
let w: u32 = match parts[0].parse() {
Ok(v) => v,
Err(_) => {
eprintln!("Error: Invalid tile width '{}'. Must be a positive integer", parts[0]);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let h: u32 = match parts[1].parse() {
Ok(v) => v,
Err(_) => {
eprintln!("Error: Invalid tile height '{}'. Must be a positive integer", parts[1]);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
if !allow_large && w * h > 100 {
eprintln!("Warning: Tile {}x{} creates {} copies of the sprite", w, h, w * h);
eprintln!("Use --allow-large to proceed with large expansions");
return ExitCode::from(EXIT_INVALID_ARGS);
}
grid = apply_tile(&grid, w, h);
}
if let Some(size) = pad {
grid = apply_pad(&grid, size);
}
if let Some(crop_spec) = crop {
let parts: Vec<&str> = crop_spec.split(',').collect();
if parts.len() != 4 {
eprintln!(
"Error: Invalid crop format '{}'. Use 'X,Y,W,H' (e.g., '0,0,8,8')",
crop_spec
);
return ExitCode::from(EXIT_INVALID_ARGS);
}
let x: u32 = match parts[0].parse() {
Ok(v) => v,
Err(_) => {
eprintln!("Error: Invalid crop X '{}'", parts[0]);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let y: u32 = match parts[1].parse() {
Ok(v) => v,
Err(_) => {
eprintln!("Error: Invalid crop Y '{}'", parts[1]);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let w: u32 = match parts[2].parse() {
Ok(v) => v,
Err(_) => {
eprintln!("Error: Invalid crop width '{}'", parts[2]);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let h: u32 = match parts[3].parse() {
Ok(v) => v,
Err(_) => {
eprintln!("Error: Invalid crop height '{}'", parts[3]);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
grid = apply_crop(&grid, x, y, w, h);
}
if let Some(shift_spec) = shift {
let parts: Vec<&str> = shift_spec.split(',').collect();
if parts.len() != 2 {
eprintln!("Error: Invalid shift format '{}'. Use 'X,Y' (e.g., '4,0')", shift_spec);
return ExitCode::from(EXIT_INVALID_ARGS);
}
let x: i32 = match parts[0].parse() {
Ok(v) => v,
Err(_) => {
eprintln!("Error: Invalid shift X '{}'", parts[0]);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let y: i32 = match parts[1].parse() {
Ok(v) => v,
Err(_) => {
eprintln!("Error: Invalid shift Y '{}'", parts[1]);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
grid = apply_shift(&grid, x, y);
}
if let Some(shadow_spec) = shadow {
let parts: Vec<&str> = shadow_spec.split(',').collect();
if parts.len() < 2 {
eprintln!("Error: Invalid shadow format '{}'. Use 'X,Y' (e.g., '2,2')", shadow_spec);
return ExitCode::from(EXIT_INVALID_ARGS);
}
let x: i32 = match parts[0].parse() {
Ok(v) => v,
Err(_) => {
eprintln!("Error: Invalid shadow X '{}'", parts[0]);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
let y: i32 = match parts[1].parse() {
Ok(v) => v,
Err(_) => {
eprintln!("Error: Invalid shadow Y '{}'", parts[1]);
return ExitCode::from(EXIT_INVALID_ARGS);
}
};
grid = apply_shadow(&grid, x, y, shadow_token);
}
if let Some(outline_opt) = outline {
let token = outline_opt.as_deref().filter(|s| !s.is_empty());
grid = apply_outline(&grid, token, outline_width);
}
let mut output_sprite = target_sprite.clone();
output_sprite.grid = grid;
let sprite_json = match serde_json::to_string(&serde_json::json!({
"type": "sprite",
"name": output_sprite.name,
"palette": output_sprite.palette,
"grid": output_sprite.grid,
})) {
Ok(s) => s,
Err(e) => {
eprintln!("Error: Failed to serialize sprite: {}", e);
return ExitCode::from(EXIT_ERROR);
}
};
let mut output_lines: Vec<String> = Vec::new();
for obj in &objects {
if let TtpObject::Palette(p) = obj {
match serde_json::to_string(&serde_json::json!({
"type": "palette",
"name": p.name,
"colors": p.colors,
})) {
Ok(line) => output_lines.push(line),
Err(e) => {
eprintln!("Error: Failed to serialize palette '{}': {}", p.name, e);
return ExitCode::from(EXIT_ERROR);
}
}
}
}
output_lines.push(sprite_json);
for obj in &objects {
if let TtpObject::Sprite(s) = obj {
if s.name != target_sprite.name {
match serde_json::to_string(&serde_json::json!({
"type": "sprite",
"name": s.name,
"palette": s.palette,
"grid": s.grid,
})) {
Ok(line) => output_lines.push(line),
Err(e) => {
eprintln!("Error: Failed to serialize sprite '{}': {}", s.name, e);
return ExitCode::from(EXIT_ERROR);
}
}
}
}
}
let output_content = output_lines.join("\n");
let mut file = match std::fs::File::create(output) {
Ok(f) => f,
Err(e) => {
eprintln!("Error: Cannot create '{}': {}", output.display(), e);
return ExitCode::from(EXIT_ERROR);
}
};
if let Err(e) = writeln!(file, "{}", output_content) {
eprintln!("Error: Cannot write to '{}': {}", output.display(), e);
return ExitCode::from(EXIT_ERROR);
}
ExitCode::from(EXIT_SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_is_pixelsrc_file_pxl() {
assert!(is_pixelsrc_file(Path::new("test.pxl")));
assert!(is_pixelsrc_file(Path::new("path/to/sprite.pxl")));
assert!(is_pixelsrc_file(Path::new("/absolute/path.pxl")));
}
#[test]
fn test_is_pixelsrc_file_jsonl() {
assert!(is_pixelsrc_file(Path::new("test.jsonl")));
assert!(is_pixelsrc_file(Path::new("path/to/sprite.jsonl")));
assert!(is_pixelsrc_file(Path::new("/absolute/path.jsonl")));
}
#[test]
fn test_is_pixelsrc_file_invalid() {
assert!(!is_pixelsrc_file(Path::new("test.png")));
assert!(!is_pixelsrc_file(Path::new("test.json")));
assert!(!is_pixelsrc_file(Path::new("test.txt")));
assert!(!is_pixelsrc_file(Path::new("test")));
assert!(!is_pixelsrc_file(Path::new("pxl")));
assert!(!is_pixelsrc_file(Path::new(".pxl")));
}
#[test]
fn test_find_pixelsrc_files() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
fs::write(dir_path.join("test1.pxl"), "{}").unwrap();
fs::write(dir_path.join("test2.jsonl"), "{}").unwrap();
fs::write(dir_path.join("test3.png"), "ignored").unwrap();
let sub_dir = dir_path.join("subdir");
fs::create_dir(&sub_dir).unwrap();
fs::write(sub_dir.join("nested.pxl"), "{}").unwrap();
let files = find_pixelsrc_files(dir_path);
assert_eq!(files.len(), 3);
for file in &files {
assert!(is_pixelsrc_file(file));
}
}
}