use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::mpsc::channel;
use std::time::{Duration, Instant};
use thiserror::Error;
use crate::build::{BuildContext, BuildPipeline, BuildStatus, IncrementalBuild, IncrementalStats};
use crate::config::schema::WatchConfig;
#[derive(Debug, Error)]
pub enum WatchError {
#[error("Failed to initialize file watcher: {0}")]
WatcherInit(notify::Error),
#[error("Failed to watch path: {0}")]
WatchPath(notify::Error),
#[error("Watch channel error: {0}")]
ChannelError(String),
#[error("Build failed: {0}")]
BuildFailed(String),
#[error("Source directory not found: {}", .0.display())]
SourceNotFound(PathBuf),
}
#[derive(Debug, Clone)]
pub struct BuildError {
pub file: PathBuf,
pub line: Option<usize>,
pub column: Option<usize>,
pub message: String,
}
impl BuildError {
pub fn new(file: impl Into<PathBuf>, message: impl Into<String>) -> Self {
Self { file: file.into(), line: None, column: None, message: message.into() }
}
pub fn with_line(file: impl Into<PathBuf>, line: usize, message: impl Into<String>) -> Self {
Self { file: file.into(), line: Some(line), column: None, message: message.into() }
}
pub fn with_location(
file: impl Into<PathBuf>,
line: usize,
column: usize,
message: impl Into<String>,
) -> Self {
Self { file: file.into(), line: Some(line), column: Some(column), message: message.into() }
}
}
impl std::fmt::Display for BuildError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Error in {}", self.file.display())?;
if let Some(line) = self.line {
write!(f, ":{}", line)?;
if let Some(col) = self.column {
write!(f, ":{}", col)?;
}
}
write!(f, ": {}", self.message)
}
}
#[derive(Debug, Default)]
pub struct ErrorTracker {
files_with_errors: HashSet<PathBuf>,
}
impl ErrorTracker {
pub fn new() -> Self {
Self::default()
}
pub fn update(&mut self, result: &BuildResult) -> Vec<PathBuf> {
let current_error_files: HashSet<PathBuf> =
result.build_errors.iter().map(|e| e.file.clone()).collect();
let fixed: Vec<PathBuf> =
self.files_with_errors.difference(¤t_error_files).cloned().collect();
self.files_with_errors = current_error_files;
fixed
}
pub fn has_errors(&self) -> bool {
!self.files_with_errors.is_empty()
}
pub fn error_count(&self) -> usize {
self.files_with_errors.len()
}
}
#[derive(Debug, Clone)]
pub struct WatchOptions {
pub src_dir: PathBuf,
pub out_dir: PathBuf,
pub config: WatchConfig,
pub verbose: bool,
}
impl Default for WatchOptions {
fn default() -> Self {
Self {
src_dir: PathBuf::from("src/pxl"),
out_dir: PathBuf::from("build"),
config: WatchConfig::default(),
verbose: false,
}
}
}
#[derive(Debug)]
pub struct BuildResult {
pub files_processed: usize,
pub sprites_rendered: usize,
pub errors: Vec<String>,
pub build_errors: Vec<BuildError>,
pub warnings: Vec<String>,
pub duration: Duration,
}
impl BuildResult {
pub fn new() -> Self {
Self {
files_processed: 0,
sprites_rendered: 0,
errors: vec![],
build_errors: vec![],
warnings: vec![],
duration: Duration::ZERO,
}
}
pub fn success(&self) -> bool {
self.errors.is_empty() && self.build_errors.is_empty()
}
pub fn add_error(&mut self, error: BuildError) {
self.build_errors.push(error);
}
pub fn error_count(&self) -> usize {
self.errors.len() + self.build_errors.len()
}
}
impl Default for BuildResult {
fn default() -> Self {
Self::new()
}
}
fn clear_screen() {
print!("\x1B[2J\x1B[1;1H");
}
fn format_duration(duration: Duration) -> String {
let millis = duration.as_millis();
if millis < 1000 {
format!("{}ms", millis)
} else {
format!("{:.2}s", duration.as_secs_f64())
}
}
fn target_id_to_display_name(target_id: &str) -> String {
if let Some((kind, name)) = target_id.split_once(':') {
match kind {
"sprite" | "animation" | "preview" => format!("{}.pxl", name),
_ => target_id.to_string(),
}
} else {
target_id.to_string()
}
}
fn extract_line_number(message: &str) -> Option<usize> {
let patterns = [
r"[Ll]ine\s+(\d+)",
r"at line\s+(\d+)",
r":(\d+):", r":(\d+)$", ];
for pattern in patterns {
if let Ok(re) = regex::Regex::new(pattern) {
if let Some(caps) = re.captures(message) {
if let Some(m) = caps.get(1) {
if let Ok(line) = m.as_str().parse::<usize>() {
return Some(line);
}
}
}
}
}
None
}
fn format_error_display(target_id: &str, error_msg: &str) -> String {
let display_name = target_id_to_display_name(target_id);
let line_num = extract_line_number(error_msg);
let clean_msg = error_msg.strip_prefix("failed: ").unwrap_or(error_msg);
if let Some(line) = line_num {
format!("Error in {}:\n Line {}: {}", display_name, line, clean_msg)
} else {
format!("Error in {}: {}", display_name, clean_msg)
}
}
fn timestamp() -> String {
use std::time::SystemTime;
let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
let secs = now.as_secs() % 86400; let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
let seconds = secs % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
}
pub fn do_build<F>(options: &WatchOptions, build_fn: F) -> BuildResult
where
F: FnOnce(&Path, &Path) -> BuildResult,
{
let start = Instant::now();
let mut result = build_fn(&options.src_dir, &options.out_dir);
result.duration = start.elapsed();
result
}
pub fn simple_build(src_dir: &Path, _out_dir: &Path) -> BuildResult {
use glob::glob;
let mut result = BuildResult::new();
let pattern = format!("{}/**/*.pxl", src_dir.display());
if let Ok(entries) = glob(&pattern) {
for entry in entries.flatten() {
result.files_processed += 1;
if let Ok(content) = std::fs::read_to_string(&entry) {
result.sprites_rendered += content.matches("\"type\": \"sprite\"").count();
result.sprites_rendered += content.matches("\"type\":\"sprite\"").count();
}
}
}
let pattern_jsonl = format!("{}/**/*.jsonl", src_dir.display());
if let Ok(entries) = glob(&pattern_jsonl) {
for entry in entries.flatten() {
result.files_processed += 1;
if let Ok(content) = std::fs::read_to_string(&entry) {
result.sprites_rendered += content.matches("\"type\": \"sprite\"").count();
result.sprites_rendered += content.matches("\"type\":\"sprite\"").count();
}
}
}
result
}
pub fn watch_and_rebuild(options: WatchOptions) -> Result<(), WatchError> {
if !options.src_dir.exists() {
return Err(WatchError::SourceNotFound(options.src_dir.clone()));
}
if !options.out_dir.exists() {
std::fs::create_dir_all(&options.out_dir).ok();
}
let (tx, rx) = channel();
let debounce_duration = Duration::from_millis(options.config.debounce_ms as u64);
let mut debouncer = new_debouncer(debounce_duration, tx).map_err(WatchError::WatcherInit)?;
debouncer
.watcher()
.watch(&options.src_dir, RecursiveMode::Recursive)
.map_err(WatchError::WatchPath)?;
let mut error_tracker = ErrorTracker::new();
if options.config.clear_screen {
clear_screen();
}
println!("[{}] Building...", timestamp());
let result = do_build(&options, simple_build);
print_build_result(&result, &[]);
error_tracker.update(&result);
println!("[{}] Watching {} for changes...", timestamp(), options.src_dir.display());
loop {
match rx.recv() {
Ok(Ok(events)) => {
let relevant_changes: Vec<_> = events
.iter()
.filter(|e| {
matches!(e.kind, DebouncedEventKind::Any) && is_relevant_file(&e.path)
})
.collect();
if !relevant_changes.is_empty() {
for event in &relevant_changes {
if let Some(name) = event.path.file_name() {
println!("[{}] Changed: {}", timestamp(), name.to_string_lossy());
}
}
if options.config.clear_screen {
clear_screen();
}
println!("[{}] Building...", timestamp());
let result = do_build(&options, simple_build);
let fixed_files = error_tracker.update(&result);
print_build_result(&result, &fixed_files);
println!(
"[{}] Watching {} for changes...",
timestamp(),
options.src_dir.display()
);
}
}
Ok(Err(error)) => {
eprintln!("[{}] Watch error: {:?}", timestamp(), error);
eprintln!("[{}] Continuing to watch...", timestamp());
}
Err(e) => {
return Err(WatchError::ChannelError(e.to_string()));
}
}
}
}
fn is_relevant_file(path: &Path) -> bool {
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy().to_lowercase();
matches!(ext.as_str(), "pxl" | "jsonl" | "json")
} else {
false
}
}
pub fn watch_with_pipeline(
context: BuildContext,
watch_config: WatchConfig,
) -> Result<(), WatchError> {
let src_dir = context.src_dir().to_path_buf();
let verbose = context.is_verbose();
if !src_dir.exists() {
return Err(WatchError::SourceNotFound(src_dir));
}
let out_dir = context.out_dir();
if !out_dir.exists() {
std::fs::create_dir_all(out_dir).ok();
}
let (tx, rx) = channel();
let debounce_duration = Duration::from_millis(watch_config.debounce_ms as u64);
let mut debouncer = new_debouncer(debounce_duration, tx).map_err(WatchError::WatcherInit)?;
debouncer.watcher().watch(&src_dir, RecursiveMode::Recursive).map_err(WatchError::WatchPath)?;
let mut error_tracker = ErrorTracker::new();
let pipeline = BuildPipeline::new(context);
if watch_config.clear_screen {
clear_screen();
}
println!("[{}] Building...", timestamp());
let pipeline_result = pipeline.build();
let result = convert_pipeline_result(&pipeline_result);
let fixed_files = error_tracker.update(&result);
print_pipeline_result(&pipeline_result, &fixed_files, verbose);
println!("[{}] Watching {} for changes...", timestamp(), src_dir.display());
loop {
match rx.recv() {
Ok(Ok(events)) => {
let relevant_changes: Vec<_> = events
.iter()
.filter(|e| {
matches!(e.kind, DebouncedEventKind::Any) && is_relevant_file(&e.path)
})
.collect();
if !relevant_changes.is_empty() {
for event in &relevant_changes {
if let Some(name) = event.path.file_name() {
println!("[{}] Changed: {}", timestamp(), name.to_string_lossy());
}
}
if watch_config.clear_screen {
clear_screen();
}
println!("[{}] Building...", timestamp());
let pipeline_result = pipeline.build();
let result = convert_pipeline_result(&pipeline_result);
let fixed_files = error_tracker.update(&result);
print_pipeline_result(&pipeline_result, &fixed_files, verbose);
println!("[{}] Watching {} for changes...", timestamp(), src_dir.display());
}
}
Ok(Err(error)) => {
eprintln!("[{}] Watch error: {:?}", timestamp(), error);
eprintln!("[{}] Continuing to watch...", timestamp());
}
Err(e) => {
return Err(WatchError::ChannelError(e.to_string()));
}
}
}
}
pub fn watch_with_incremental(
context: BuildContext,
watch_config: WatchConfig,
force: bool,
) -> Result<(), WatchError> {
let src_dir = context.src_dir().to_path_buf();
let verbose = context.is_verbose();
if !src_dir.exists() {
return Err(WatchError::SourceNotFound(src_dir));
}
let out_dir = context.out_dir();
if !out_dir.exists() {
std::fs::create_dir_all(out_dir).ok();
}
let (tx, rx) = channel();
let debounce_duration = Duration::from_millis(watch_config.debounce_ms as u64);
let mut debouncer = new_debouncer(debounce_duration, tx).map_err(WatchError::WatcherInit)?;
debouncer.watcher().watch(&src_dir, RecursiveMode::Recursive).map_err(WatchError::WatchPath)?;
let mut error_tracker = ErrorTracker::new();
let mut incremental = IncrementalBuild::new(context).with_force(force);
if watch_config.clear_screen {
clear_screen();
}
println!("[{}] Building...", timestamp());
let build_result = incremental.run();
let result = convert_pipeline_result(&build_result);
let fixed_files = error_tracker.update(&result);
print_incremental_result(&build_result, &fixed_files, verbose, force);
println!("[{}] Watching {} for changes...", timestamp(), src_dir.display());
loop {
match rx.recv() {
Ok(Ok(events)) => {
let relevant_changes: Vec<_> = events
.iter()
.filter(|e| {
matches!(e.kind, DebouncedEventKind::Any) && is_relevant_file(&e.path)
})
.collect();
if !relevant_changes.is_empty() {
for event in &relevant_changes {
if let Some(name) = event.path.file_name() {
println!("[{}] Changed: {}", timestamp(), name.to_string_lossy());
}
}
if watch_config.clear_screen {
clear_screen();
}
println!("[{}] Building...", timestamp());
let build_result = incremental.run();
let result = convert_pipeline_result(&build_result);
let fixed_files = error_tracker.update(&result);
print_incremental_result(&build_result, &fixed_files, verbose, force);
println!("[{}] Watching {} for changes...", timestamp(), src_dir.display());
}
}
Ok(Err(error)) => {
eprintln!("[{}] Watch error: {:?}", timestamp(), error);
eprintln!("[{}] Continuing to watch...", timestamp());
}
Err(e) => {
return Err(WatchError::ChannelError(e.to_string()));
}
}
}
}
fn print_incremental_result(
build_result: &Result<crate::build::BuildResult, crate::build::pipeline::BuildError>,
fixed_files: &[PathBuf],
verbose: bool,
force: bool,
) {
for fixed in fixed_files {
println!("[{}] Fixed: {}", timestamp(), fixed.display());
}
match build_result {
Ok(result) => {
let stats = IncrementalStats::from_result(result);
if result.is_success() {
if stats.had_skips() && !force {
println!(
"[{}] Build complete ({:?}) - {} built, {} skipped (unchanged)",
timestamp(),
result.total_duration,
stats.built,
stats.skipped
);
} else {
println!(
"[{}] Build complete ({:?}) - {} built",
timestamp(),
result.total_duration,
stats.built
);
}
} else {
let failed = stats.failed;
println!(
"[{}] Build failed ({:?}) - {} error{}",
timestamp(),
result.total_duration,
failed,
if failed == 1 { "" } else { "s" }
);
for target in result.failures() {
let error_msg = format!("{}", target.status);
let formatted = format_error_display(&target.target_id, &error_msg);
eprintln!("[{}] {}", timestamp(), formatted);
}
}
if verbose {
for warning in result.all_warnings() {
eprintln!("[{}] Warning: {}", timestamp(), warning);
}
}
}
Err(e) => {
eprintln!("[{}] Build error: {}", timestamp(), e);
}
}
}
fn convert_pipeline_result(
pipeline_result: &Result<crate::build::BuildResult, crate::build::pipeline::BuildError>,
) -> BuildResult {
let mut result = BuildResult::new();
match pipeline_result {
Ok(build_result) => {
result.files_processed = build_result.targets.len();
result.sprites_rendered = build_result.success_count();
result.duration = build_result.total_duration;
for target in &build_result.targets {
if let BuildStatus::Failed(msg) = &target.status {
result
.add_error(BuildError::new(PathBuf::from(&target.target_id), msg.clone()));
}
}
result.warnings = build_result.all_warnings().into_iter().cloned().collect();
}
Err(e) => {
result.errors.push(e.to_string());
}
}
result
}
fn print_pipeline_result(
pipeline_result: &Result<crate::build::BuildResult, crate::build::pipeline::BuildError>,
fixed_files: &[PathBuf],
verbose: bool,
) {
for fixed in fixed_files {
println!("[{}] Fixed: {}", timestamp(), fixed.display());
}
match pipeline_result {
Ok(build_result) => {
if build_result.is_success() {
println!(
"[{}] Build complete ({:?}) - {} built, {} skipped",
timestamp(),
build_result.total_duration,
build_result.success_count(),
build_result.skipped_count()
);
} else {
let failed = build_result.failed_count();
println!(
"[{}] Build failed ({:?}) - {} error{}",
timestamp(),
build_result.total_duration,
failed,
if failed == 1 { "" } else { "s" }
);
for target in build_result.failures() {
let error_msg = format!("{}", target.status);
let formatted = format_error_display(&target.target_id, &error_msg);
eprintln!("[{}] {}", timestamp(), formatted);
}
}
if verbose {
for warning in build_result.all_warnings() {
eprintln!("[{}] Warning: {}", timestamp(), warning);
}
}
}
Err(e) => {
eprintln!("[{}] Build error: {}", timestamp(), e);
}
}
}
fn print_build_result(result: &BuildResult, fixed_files: &[PathBuf]) {
for fixed in fixed_files {
if let Some(name) = fixed.file_name() {
println!("[{}] Fixed: {}", timestamp(), name.to_string_lossy());
}
}
if result.success() {
println!(
"[{}] Build complete ({}) - Files: {} | Sprites: {}",
timestamp(),
format_duration(result.duration),
result.files_processed,
result.sprites_rendered
);
} else {
let error_count = result.error_count();
println!(
"[{}] Build failed ({}) - {} error{}",
timestamp(),
format_duration(result.duration),
error_count,
if error_count == 1 { "" } else { "s" }
);
for error in &result.build_errors {
if let Some(name) = error.file.file_name() {
eprint!("[{}] Error in {}:", timestamp(), name.to_string_lossy());
if let Some(line) = error.line {
eprint!("\n Line {}: ", line);
} else {
eprint!(" ");
}
eprintln!("{}", error.message);
} else {
eprintln!("[{}] Error: {}", timestamp(), error);
}
}
for error in &result.errors {
eprintln!("[{}] Error: {}", timestamp(), error);
}
}
for warning in &result.warnings {
eprintln!("[{}] Warning: {}", timestamp(), warning);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_watch_options_default() {
let options = WatchOptions::default();
assert_eq!(options.src_dir, PathBuf::from("src/pxl"));
assert_eq!(options.out_dir, PathBuf::from("build"));
assert_eq!(options.config.debounce_ms, 100);
assert!(options.config.clear_screen);
}
#[test]
fn test_build_result_new() {
let result = BuildResult::new();
assert_eq!(result.files_processed, 0);
assert_eq!(result.sprites_rendered, 0);
assert!(result.errors.is_empty());
assert!(result.warnings.is_empty());
assert!(result.success());
}
#[test]
fn test_build_result_with_errors() {
let mut result = BuildResult::new();
result.errors.push("Test error".to_string());
assert!(!result.success());
}
#[test]
fn test_is_relevant_file() {
assert!(is_relevant_file(Path::new("sprite.pxl")));
assert!(is_relevant_file(Path::new("sprites.jsonl")));
assert!(is_relevant_file(Path::new("data.json")));
assert!(!is_relevant_file(Path::new("readme.md")));
assert!(!is_relevant_file(Path::new("image.png")));
assert!(!is_relevant_file(Path::new("noextension")));
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(Duration::from_millis(50)), "50ms");
assert_eq!(format_duration(Duration::from_millis(999)), "999ms");
assert_eq!(format_duration(Duration::from_millis(1000)), "1.00s");
assert_eq!(format_duration(Duration::from_millis(1500)), "1.50s");
}
#[test]
fn test_simple_build_empty_dir() {
let temp = TempDir::new().unwrap();
let src = temp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let result = simple_build(&src, temp.path());
assert_eq!(result.files_processed, 0);
assert_eq!(result.sprites_rendered, 0);
assert!(result.success());
}
#[test]
fn test_simple_build_with_files() {
let temp = TempDir::new().unwrap();
let src = temp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let content = r#"{"type": "sprite", "name": "test1"}
{"type": "sprite", "name": "test2"}"#;
std::fs::write(src.join("test.jsonl"), content).unwrap();
let result = simple_build(&src, temp.path());
assert_eq!(result.files_processed, 1);
assert_eq!(result.sprites_rendered, 2);
assert!(result.success());
}
#[test]
fn test_watch_error_source_not_found() {
let options =
WatchOptions { src_dir: PathBuf::from("/nonexistent/path"), ..Default::default() };
let result = watch_and_rebuild(options);
assert!(matches!(result, Err(WatchError::SourceNotFound(_))));
}
#[test]
fn test_do_build_with_custom_function() {
let temp = TempDir::new().unwrap();
let src = temp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let options =
WatchOptions { src_dir: src, out_dir: temp.path().to_path_buf(), ..Default::default() };
let result = do_build(&options, |_src, _out| {
let mut r = BuildResult::new();
r.files_processed = 5;
r.sprites_rendered = 10;
r
});
assert_eq!(result.files_processed, 5);
assert_eq!(result.sprites_rendered, 10);
assert!(result.duration >= Duration::ZERO);
}
#[test]
fn test_build_error_new() {
let error = BuildError::new("test.pxl", "Invalid syntax");
assert_eq!(error.file, PathBuf::from("test.pxl"));
assert_eq!(error.line, None);
assert_eq!(error.column, None);
assert_eq!(error.message, "Invalid syntax");
}
#[test]
fn test_build_error_with_line() {
let error = BuildError::with_line("test.pxl", 5, "Invalid color");
assert_eq!(error.file, PathBuf::from("test.pxl"));
assert_eq!(error.line, Some(5));
assert_eq!(error.column, None);
assert_eq!(error.message, "Invalid color");
}
#[test]
fn test_build_error_with_location() {
let error = BuildError::with_location("test.pxl", 5, 10, "Unexpected token");
assert_eq!(error.file, PathBuf::from("test.pxl"));
assert_eq!(error.line, Some(5));
assert_eq!(error.column, Some(10));
assert_eq!(error.message, "Unexpected token");
}
#[test]
fn test_build_error_display() {
let error = BuildError::with_line("sprites/broken.pxl", 5, "Invalid color format \"#GGG\"");
let display = format!("{}", error);
assert!(display.contains("sprites/broken.pxl"));
assert!(display.contains("5"));
assert!(display.contains("Invalid color format"));
}
#[test]
fn test_error_tracker_new() {
let tracker = ErrorTracker::new();
assert!(!tracker.has_errors());
assert_eq!(tracker.error_count(), 0);
}
#[test]
fn test_error_tracker_tracks_errors() {
let mut tracker = ErrorTracker::new();
let mut result = BuildResult::new();
result.add_error(BuildError::new("file1.pxl", "Error 1"));
result.add_error(BuildError::new("file2.pxl", "Error 2"));
let fixed = tracker.update(&result);
assert!(fixed.is_empty()); assert!(tracker.has_errors());
assert_eq!(tracker.error_count(), 2);
}
#[test]
fn test_error_tracker_detects_fixed_files() {
let mut tracker = ErrorTracker::new();
let mut result1 = BuildResult::new();
result1.add_error(BuildError::new("file1.pxl", "Error 1"));
result1.add_error(BuildError::new("file2.pxl", "Error 2"));
tracker.update(&result1);
let mut result2 = BuildResult::new();
result2.add_error(BuildError::new("file2.pxl", "Error 2"));
let fixed = tracker.update(&result2);
assert_eq!(fixed.len(), 1);
assert_eq!(fixed[0], PathBuf::from("file1.pxl"));
assert!(tracker.has_errors());
assert_eq!(tracker.error_count(), 1);
}
#[test]
fn test_error_tracker_all_fixed() {
let mut tracker = ErrorTracker::new();
let mut result1 = BuildResult::new();
result1.add_error(BuildError::new("file1.pxl", "Error 1"));
tracker.update(&result1);
let result2 = BuildResult::new();
let fixed = tracker.update(&result2);
assert_eq!(fixed.len(), 1);
assert_eq!(fixed[0], PathBuf::from("file1.pxl"));
assert!(!tracker.has_errors());
assert_eq!(tracker.error_count(), 0);
}
#[test]
fn test_build_result_with_build_errors() {
let mut result = BuildResult::new();
assert!(result.success());
assert_eq!(result.error_count(), 0);
result.add_error(BuildError::new("test.pxl", "Error"));
assert!(!result.success());
assert_eq!(result.error_count(), 1);
}
#[test]
fn test_build_result_mixed_errors() {
let mut result = BuildResult::new();
result.errors.push("Legacy error".to_string());
result.add_error(BuildError::new("test.pxl", "Detailed error"));
assert!(!result.success());
assert_eq!(result.error_count(), 2);
}
#[test]
fn test_convert_pipeline_result_success() {
use crate::build::{BuildResult as PipelineBuildResult, TargetResult};
let mut pipeline_result = PipelineBuildResult::new();
pipeline_result.add_result(TargetResult::success(
"sprite:test".to_string(),
vec![PathBuf::from("test.png")],
Duration::from_millis(50),
));
pipeline_result.total_duration = Duration::from_millis(100);
let watch_result = convert_pipeline_result(&Ok(pipeline_result));
assert!(watch_result.success());
assert_eq!(watch_result.files_processed, 1);
assert_eq!(watch_result.sprites_rendered, 1);
}
#[test]
fn test_convert_pipeline_result_with_failures() {
use crate::build::{BuildResult as PipelineBuildResult, TargetResult};
let mut pipeline_result = PipelineBuildResult::new();
pipeline_result.add_result(TargetResult::success(
"sprite:good".to_string(),
vec![],
Duration::from_millis(50),
));
pipeline_result.add_result(TargetResult::failed(
"sprite:bad".to_string(),
"Invalid syntax".to_string(),
Duration::from_millis(10),
));
let watch_result = convert_pipeline_result(&Ok(pipeline_result));
assert!(!watch_result.success());
assert_eq!(watch_result.files_processed, 2);
assert_eq!(watch_result.sprites_rendered, 1); assert_eq!(watch_result.build_errors.len(), 1);
}
#[test]
fn test_convert_pipeline_result_error() {
use crate::build::pipeline::BuildError as PipelineBuildError;
let pipeline_error = PipelineBuildError::Build("Config error".to_string());
let watch_result = convert_pipeline_result(&Err(pipeline_error));
assert!(!watch_result.success());
assert_eq!(watch_result.errors.len(), 1);
assert!(watch_result.errors[0].contains("Config error"));
}
#[test]
fn test_watch_with_pipeline_source_not_found() {
use crate::config::default_config;
let config = default_config();
let context = BuildContext::new(config, PathBuf::from("/nonexistent/path"));
let watch_config = WatchConfig::default();
let result = watch_with_pipeline(context, watch_config);
assert!(matches!(result, Err(WatchError::SourceNotFound(_))));
}
#[test]
fn test_watch_with_incremental_source_not_found() {
use crate::config::default_config;
let config = default_config();
let context = BuildContext::new(config, PathBuf::from("/nonexistent/path"));
let watch_config = WatchConfig::default();
let result = watch_with_incremental(context, watch_config, false);
assert!(matches!(result, Err(WatchError::SourceNotFound(_))));
}
#[test]
fn test_watch_with_incremental_force_mode() {
use crate::config::default_config;
let config = default_config();
let context = BuildContext::new(config, PathBuf::from("/nonexistent/path"));
let watch_config = WatchConfig::default();
let result = watch_with_incremental(context, watch_config, true);
assert!(matches!(result, Err(WatchError::SourceNotFound(_))));
}
#[test]
fn test_target_id_to_display_name_sprite() {
assert_eq!(target_id_to_display_name("sprite:player"), "player.pxl");
assert_eq!(target_id_to_display_name("sprite:enemy_boss"), "enemy_boss.pxl");
}
#[test]
fn test_target_id_to_display_name_animation() {
assert_eq!(target_id_to_display_name("animation:walk"), "walk.pxl");
assert_eq!(target_id_to_display_name("preview:idle"), "idle.pxl");
}
#[test]
fn test_target_id_to_display_name_atlas() {
assert_eq!(target_id_to_display_name("atlas:main"), "atlas:main");
assert_eq!(target_id_to_display_name("export:godot"), "export:godot");
}
#[test]
fn test_target_id_to_display_name_no_colon() {
assert_eq!(target_id_to_display_name("something"), "something");
}
#[test]
fn test_extract_line_number_basic() {
assert_eq!(extract_line_number("error at line 5"), Some(5));
assert_eq!(extract_line_number("Line 10: syntax error"), Some(10));
assert_eq!(extract_line_number("invalid JSON at line 42"), Some(42));
}
#[test]
fn test_extract_line_number_colon_format() {
assert_eq!(extract_line_number("file.pxl:15:3: error"), Some(15));
assert_eq!(extract_line_number("path/to/file.pxl:99"), Some(99));
}
#[test]
fn test_extract_line_number_none() {
assert_eq!(extract_line_number("no line number here"), None);
assert_eq!(extract_line_number("error: something went wrong"), None);
}
#[test]
fn test_format_error_display_with_line() {
let formatted = format_error_display("sprite:player", "failed: Parse error at line 5");
assert!(formatted.contains("Error in player.pxl:"));
assert!(formatted.contains("Line 5:"));
assert!(formatted.contains("Parse error"));
}
#[test]
fn test_format_error_display_without_line() {
let formatted = format_error_display("sprite:enemy", "File not found");
assert!(formatted.contains("Error in enemy.pxl:"));
assert!(formatted.contains("File not found"));
assert!(!formatted.contains("\n Line"));
}
#[test]
fn test_format_error_display_strips_failed_prefix() {
let formatted = format_error_display("sprite:test", "failed: Some error");
assert!(!formatted.contains("failed:"));
assert!(formatted.contains("Some error"));
}
}