diff --git a/ROADMAP.md b/ROADMAP.md index 30dbb1f..b248f1e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,16 +22,16 @@ __Objective:__ Establish a clean, testable core with `clap` and a minimal `ratat __Objective:__ Parse markdown documents into a rich `Slide` struct. -| Task | Description | Key Crates | -| ----------------------- | --------------------------------------------------------------- | -------------------- | -| __✓ Parser Core__ | Split files on `---` separators. | `pulldown-cmark`[^4] | -| | Detect title blocks, lists, and code fences. | | -| | Represent as `Vec`. | | -| __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | -| __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | `serde_yml`[^5] | -| __Error & Validation__ | Provide friendly parser errors with file/line info. | `thiserror`[^6] | -| __✓ Basic CLI UX__ | `lantern present file.md` runs full TUI. | `clap` | -| | `lantern print` renders to stdout with width constraint. | | +| Task | Description | Key Crates | +| ------------------------ | --------------------------------------------------------------- | -------------------- | +| __✓ Parser Core__ | Split files on `---` separators. | `pulldown-cmark`[^4] | +| | Detect title blocks, lists, and code fences. | | +| | Represent as `Vec`. | | +| __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | +| __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | `serde_yml`[^5] | +| __✓ Error & Validation__ | Provide friendly parser errors with file/line info. | `thiserror`[^6] | +| __✓ Basic CLI UX__ | `lantern present file.md` runs full TUI. | `clap` | +| | `lantern print` renders to stdout with width constraint. | | ## Rendering & Navigation @@ -81,10 +81,10 @@ __Objective:__ Add richness and visual polish to text and layout. | Task | Description | Key Crates | | -------------------- | ------------------------------------------------------------- | ----------------------------- | -| __Tables & Lists__ | Render GitHub-style tables, bullets, and task lists. | `pulldown-cmark`, `ratatui` | +| __Tables & Lists__ | Render GitHub-style tables, bullets, and task lists | `pulldown-cmark`, `ratatui` | +| __Horizontal Rules__ | Use box-drawing (`─`, `═`) and/or black horizontal bar (`▬`) | Unicode constants | | __Admonitions__ | Highlighted boxes with icons | `owo-colors`, internal glyphs | -| __Horizontal Rules__ | Use box-drawing (`─`, `═`) and shading (`░`, `▓`). | Unicode constants | -| __Generators__ | `lantern init` scaffolds an example deck with code and notes. | `include_str!`, `fs` | +| __Generators__ | `lantern init` scaffolds an example deck with code and notes | `include_str!`, `fs` | ## RC diff --git a/cli/src/main.rs b/cli/src/main.rs index beadb1a..52c36d8 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -58,6 +58,9 @@ enum Commands { /// Enable strict mode with additional checks #[arg(short, long)] strict: bool, + /// Validate file as a theme instead of slides + #[arg(short, long)] + theme: bool, }, } @@ -86,12 +89,11 @@ fn main() { eprintln!("Init command not yet implemented"); } - Commands::Check { file, strict } => { - tracing::info!("Checking slides: {}", file.display()); - if strict { - tracing::debug!("Strict mode enabled"); + Commands::Check { file, strict, theme } => { + if let Err(e) = run_check(&file, strict, theme) { + eprintln!("Error: {}", e); + std::process::exit(1); } - eprintln!("Check command not yet implemented"); } } } @@ -140,6 +142,69 @@ fn run_present(file: &PathBuf, theme_arg: Option) -> io::Result<()> { result } +fn run_check(file: &PathBuf, strict: bool, is_theme: bool) -> io::Result<()> { + use lantern_core::validator::{validate_slides, validate_theme_file}; + use owo_colors::OwoColorize; + + if is_theme { + tracing::info!("Validating theme file: {}", file.display()); + let result = validate_theme_file(file); + + if result.is_valid() { + println!("{} Theme is valid", "✓".green().bold()); + } else { + println!("{} Theme validation failed", "✗".red().bold()); + } + + for error in &result.errors { + println!(" {} {}", "Error:".red().bold(), error); + } + + for warning in &result.warnings { + println!(" {} {}", "Warning:".yellow().bold(), warning); + } + + if !result.is_valid() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Theme validation failed", + )); + } + } else { + tracing::info!("Validating slides: {}", file.display()); + if strict { + tracing::debug!("Strict mode enabled"); + } + + let result = validate_slides(file, strict); + + if result.is_valid() && !result.has_issues() { + println!("{} Slides are valid", "✓".green().bold()); + } else if result.is_valid() { + println!("{} Slides are valid (with warnings)", "✓".yellow().bold()); + } else { + println!("{} Slide validation failed", "✗".red().bold()); + } + + for error in &result.errors { + println!(" {} {}", "Error:".red().bold(), error); + } + + for warning in &result.warnings { + println!(" {} {}", "Warning:".yellow().bold(), warning); + } + + if !result.is_valid() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Slide validation failed", + )); + } + } + + Ok(()) +} + fn run_print(file: &PathBuf, width: usize, theme_arg: Option) -> io::Result<()> { tracing::info!("Printing slides from: {} (width: {})", file.display(), width); @@ -220,9 +285,23 @@ mod tests { fn cli_check_command() { let cli = ArgParser::parse_from(["slides", "check", "test.md", "--strict"]); match cli.command { - Commands::Check { file, strict } => { + Commands::Check { file, strict, theme } => { assert_eq!(file, PathBuf::from("test.md")); assert!(strict); + assert!(!theme); + } + _ => panic!("Expected Check command"), + } + } + + #[test] + fn cli_check_theme_command() { + let cli = ArgParser::parse_from(["slides", "check", "theme.yml", "--theme"]); + match cli.command { + Commands::Check { file, strict, theme } => { + assert_eq!(file, PathBuf::from("theme.yml")); + assert!(!strict); + assert!(theme); } _ => panic!("Expected Check command"), } @@ -289,4 +368,111 @@ mod tests { std::fs::remove_file(&test_file).ok(); } + + #[test] + fn run_check_valid_slides() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_check_valid.md"); + let content = "# Test Slide\n\nThis is a test paragraph."; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = run_check(&test_file, false, false); + assert!(result.is_ok()); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn run_check_invalid_slides() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_check_invalid.md"); + let content = ""; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = run_check(&test_file, false, false); + assert!(result.is_err()); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn run_check_nonexistent_file() { + let test_file = PathBuf::from("/nonexistent/test_check.md"); + let result = run_check(&test_file, false, false); + assert!(result.is_err()); + } + + #[test] + fn run_check_strict_mode() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_check_strict.md"); + let content = "---\ntheme: nonexistent-theme\n---\n# Slide 1\n\nContent"; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = run_check(&test_file, true, false); + assert!(result.is_ok()); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn run_check_valid_theme() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_check_valid_theme.yml"); + let content = r###" +system: "base16" +name: "Test Theme" +author: "Test Author" +variant: "dark" +palette: + base00: "#000000" + base01: "#111111" + base02: "#222222" + base03: "#333333" + base04: "#444444" + base05: "#555555" + base06: "#666666" + base07: "#777777" + base08: "#888888" + base09: "#999999" + base0A: "#aaaaaa" + base0B: "#bbbbbb" + base0C: "#cccccc" + base0D: "#dddddd" + base0E: "#eeeeee" + base0F: "#ffffff" +"###; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = run_check(&test_file, false, true); + assert!(result.is_ok()); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn run_check_invalid_theme() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_check_invalid_theme.yml"); + let content = "invalid: yaml: content: [unclosed"; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = run_check(&test_file, false, true); + assert!(result.is_err()); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn run_check_invalid_frontmatter() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_check_bad_frontmatter.md"); + let content = "---\ninvalid yaml: [unclosed\n---\n# Slide"; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = run_check(&test_file, false, false); + assert!(result.is_err()); + + std::fs::remove_file(&test_file).ok(); + } } diff --git a/core/src/error.rs b/core/src/error.rs index 58fc46b..76a6739 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -21,6 +21,9 @@ pub enum SlideError { #[error("JSON parsing failed: {0}")] JsonError(#[from] serde_json::Error), + + #[error("Theme validation error: {0}")] + ThemeError(String), } pub type Result = std::result::Result; @@ -40,6 +43,10 @@ impl SlideError { pub fn front_matter(message: impl Into) -> Self { Self::FrontMatterError(message.into()) } + + pub fn theme_error(message: impl Into) -> Self { + Self::ThemeError(message.into()) + } } #[cfg(test)] diff --git a/core/src/lib.rs b/core/src/lib.rs index 17d6d92..6913a52 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -6,3 +6,4 @@ pub mod printer; pub mod slide; pub mod term; pub mod theme; +pub mod validator; diff --git a/core/src/theme.rs b/core/src/theme.rs index 8d97eb5..c589748 100644 --- a/core/src/theme.rs +++ b/core/src/theme.rs @@ -23,16 +23,12 @@ fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8)> { /// /// Defines a standard 16-color palette that can be mapped to semantic theme roles. #[derive(Debug, Clone, Deserialize)] -struct Base16Scheme { - #[allow(dead_code)] - system: String, - #[allow(dead_code)] - name: String, - #[allow(dead_code)] - author: String, - #[allow(dead_code)] - variant: String, - palette: Base16Palette, +pub struct Base16Scheme { + pub system: String, + pub name: String, + pub author: String, + pub variant: String, + pub palette: Base16Palette, } /// Base16 color palette with 16 standardized color slots. @@ -42,30 +38,29 @@ struct Base16Scheme { /// - base04-07: Foreground shades (darker to lightest) /// - base08-0F: Accent colors (red, orange, yellow, green, cyan, blue, magenta, brown) #[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -struct Base16Palette { - base00: String, - base01: String, - base02: String, - base03: String, - base04: String, - base05: String, - base06: String, - base07: String, - base08: String, - base09: String, +pub struct Base16Palette { + pub base00: String, + pub base01: String, + pub base02: String, + pub base03: String, + pub base04: String, + pub base05: String, + pub base06: String, + pub base07: String, + pub base08: String, + pub base09: String, #[serde(rename = "base0A")] - base0a: String, + pub base0a: String, #[serde(rename = "base0B")] - base0b: String, + pub base0b: String, #[serde(rename = "base0C")] - base0c: String, + pub base0c: String, #[serde(rename = "base0D")] - base0d: String, + pub base0d: String, #[serde(rename = "base0E")] - base0e: String, + pub base0e: String, #[serde(rename = "base0F")] - base0f: String, + pub base0f: String, } static CATPPUCCIN_LATTE: &str = include_str!("themes/catppuccin-latte.yml"); diff --git a/core/src/validator.rs b/core/src/validator.rs new file mode 100644 index 0000000..65e266d --- /dev/null +++ b/core/src/validator.rs @@ -0,0 +1,453 @@ +use crate::error::{Result, SlideError}; +use crate::metadata::Meta; +use crate::parser::parse_slides_with_meta; +use crate::theme::{Base16Scheme, ThemeColors, ThemeRegistry}; +use std::path::Path; + +/// Validation result containing errors and warnings +#[derive(Debug, Clone, Default)] +pub struct ValidationResult { + pub errors: Vec, + pub warnings: Vec, +} + +impl ValidationResult { + pub fn new() -> Self { + Self::default() + } + + pub fn add_error(&mut self, error: String) { + self.errors.push(error); + } + + pub fn add_warning(&mut self, warning: String) { + self.warnings.push(warning); + } + + pub fn is_valid(&self) -> bool { + self.errors.is_empty() + } + + pub fn has_issues(&self) -> bool { + !self.errors.is_empty() || !self.warnings.is_empty() + } +} + +/// Validate a slide deck markdown file +/// +/// Checks for: +/// - File readability +/// - Valid frontmatter (YAML/TOML) +/// - Slide parsing +/// - Empty slide deck +/// - Theme references +pub fn validate_slides(file_path: &Path, strict: bool) -> ValidationResult { + let mut result = ValidationResult::new(); + + let markdown = match std::fs::read_to_string(file_path) { + Ok(content) => content, + Err(e) => { + result.add_error(format!("Failed to read file '{}': {}", file_path.display(), e)); + return result; + } + }; + + let (meta, slides) = match parse_slides_with_meta(&markdown) { + Ok((m, s)) => (m, s), + Err(e) => { + result.add_error(format!("Parse error: {}", e)); + return result; + } + }; + + if slides.is_empty() { + result.add_error("No slides found in file".to_string()); + return result; + } + + if strict { + validate_metadata(&meta, &mut result); + validate_slide_content(&slides, &mut result); + } + + result +} + +/// Validate metadata fields +fn validate_metadata(meta: &Meta, result: &mut ValidationResult) { + if meta.theme != "default" && !ThemeRegistry::available_themes().contains(&meta.theme.as_str()) { + result.add_warning(format!( + "Theme '{}' is not a built-in theme. Available themes: {}", + meta.theme, + ThemeRegistry::available_themes().join(", ") + )); + } + + if meta.author == "Unknown" { + result.add_warning("No author specified in frontmatter".to_string()); + } +} + +/// Validate slide content +fn validate_slide_content(slides: &[crate::slide::Slide], result: &mut ValidationResult) { + for (idx, slide) in slides.iter().enumerate() { + if slide.blocks.is_empty() { + result.add_warning(format!("Slide {} is empty", idx + 1)); + } + } +} + +/// Validate a theme file +/// +/// Checks for: +/// - File readability +/// - Valid YAML format +/// - Base16 schema compliance +/// - Color format validity +pub fn validate_theme_file(file_path: &Path) -> ValidationResult { + let mut result = ValidationResult::new(); + + let yaml_content = match std::fs::read_to_string(file_path) { + Ok(content) => content, + Err(e) => { + result.add_error(format!("Failed to read theme file '{}': {}", file_path.display(), e)); + return result; + } + }; + + let scheme: Base16Scheme = match serde_yml::from_str(&yaml_content) { + Ok(s) => s, + Err(e) => { + result.add_error(format!("Failed to parse YAML: {}", e)); + return result; + } + }; + + validate_base16_scheme(&scheme, &mut result); + + if result.is_valid() { + let colors = vec![ + ("base00", &scheme.palette.base00), + ("base01", &scheme.palette.base01), + ("base02", &scheme.palette.base02), + ("base03", &scheme.palette.base03), + ("base04", &scheme.palette.base04), + ("base05", &scheme.palette.base05), + ("base06", &scheme.palette.base06), + ("base07", &scheme.palette.base07), + ("base08", &scheme.palette.base08), + ("base09", &scheme.palette.base09), + ("base0A", &scheme.palette.base0a), + ("base0B", &scheme.palette.base0b), + ("base0C", &scheme.palette.base0c), + ("base0D", &scheme.palette.base0d), + ("base0E", &scheme.palette.base0e), + ("base0F", &scheme.palette.base0f), + ]; + + for (name, color) in colors { + validate_hex_color(name, color, &mut result); + } + } + + result +} + +/// Validate base16 scheme structure +fn validate_base16_scheme(scheme: &Base16Scheme, result: &mut ValidationResult) { + if scheme.system != "base16" { + result.add_error(format!("Invalid system '{}', expected 'base16'", scheme.system)); + } + + if scheme.name.is_empty() { + result.add_error("Theme name is empty".to_string()); + } + + if scheme.author.is_empty() { + result.add_warning("Theme author is empty".to_string()); + } + + let valid_variants = ["dark", "light"]; + if !valid_variants.contains(&scheme.variant.as_str()) { + result.add_warning(format!("Variant '{}' should be 'dark' or 'light'", scheme.variant)); + } +} + +/// Validate hex color format +fn validate_hex_color(name: &str, hex: &str, result: &mut ValidationResult) { + let hex = hex.trim_start_matches('#'); + + if hex.len() != 6 { + result.add_error(format!( + "Color {} has invalid length {} (expected 6 hex digits)", + name, + hex.len() + )); + return; + } + + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + result.add_error(format!("Color {} contains invalid hex characters", name)); + } +} + +/// Validate theme by name +/// +/// Checks if the theme exists in the built-in registry +pub fn validate_theme_name(name: &str) -> Result { + let available = ThemeRegistry::available_themes(); + + if available.contains(&name) || name == "default" { + Ok(ThemeRegistry::get(name)) + } else { + Err(SlideError::theme_error(format!( + "Theme '{}' not found. Available themes: {}", + name, + available.join(", ") + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_slides_nonexistent_file() { + let path = Path::new("/nonexistent/file.md"); + let result = validate_slides(path, false); + assert!(!result.is_valid()); + assert!(!result.errors.is_empty()); + assert!(result.errors[0].contains("Failed to read file")); + } + + #[test] + fn validate_slides_empty_content() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_empty_validation.md"); + std::fs::write(&test_file, "").expect("Failed to write test file"); + + let result = validate_slides(&test_file, false); + assert!(!result.is_valid()); + assert!(result.errors.iter().any(|e| e.contains("No slides found"))); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn validate_slides_valid_content() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_valid_validation.md"); + let content = "# Test Slide\n\nThis is a test paragraph."; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = validate_slides(&test_file, false); + assert!(result.is_valid()); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn validate_slides_invalid_frontmatter() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_invalid_frontmatter.md"); + let content = "---\ninvalid yaml: [unclosed\n---\n# Slide"; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = validate_slides(&test_file, false); + assert!(!result.is_valid()); + assert!(result.errors.iter().any(|e| e.contains("Parse error"))); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn validate_slides_with_warnings_strict() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_warnings_validation.md"); + let content = "---\ntheme: nonexistent-theme\nauthor: Unknown\n---\n# Slide 1\n\nContent"; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = validate_slides(&test_file, true); + assert!(result.is_valid()); + assert!(!result.warnings.is_empty()); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn validate_theme_file_invalid_yaml() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_invalid_theme.yml"); + let content = "invalid: yaml: content: [unclosed"; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = validate_theme_file(&test_file); + assert!(!result.is_valid()); + assert!(result.errors.iter().any(|e| e.contains("Failed to parse YAML"))); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn validate_theme_file_invalid_system() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_invalid_system.yml"); + let content = r###" +system: "base32" +name: "Test" +author: "Test Author" +variant: "dark" +palette: + base00: "#000000" + base01: "#111111" + base02: "#222222" + base03: "#333333" + base04: "#444444" + base05: "#555555" + base06: "#666666" + base07: "#777777" + base08: "#888888" + base09: "#999999" + base0A: "#aaaaaa" + base0B: "#bbbbbb" + base0C: "#cccccc" + base0D: "#dddddd" + base0E: "#eeeeee" + base0F: "#ffffff" +"###; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = validate_theme_file(&test_file); + assert!(!result.is_valid()); + assert!( + result + .errors + .iter() + .any(|e| e.contains("Invalid system") && e.contains("base32")) + ); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn validate_theme_file_invalid_color() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_invalid_color.yml"); + let content = r###" +system: "base16" +name: "Test" +author: "Test Author" +variant: "dark" +palette: + base00: "#000000" + base01: "#111111" + base02: "#222222" + base03: "#333333" + base04: "#GGGGGG" + base05: "#555555" + base06: "#666666" + base07: "#777777" + base08: "#888888" + base09: "#999999" + base0A: "#aaaaaa" + base0B: "#bbbbbb" + base0C: "#cccccc" + base0D: "#dddddd" + base0E: "#eeeeee" + base0F: "#ffffff" +"###; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = validate_theme_file(&test_file); + assert!(!result.is_valid()); + assert!( + result + .errors + .iter() + .any(|e| e.contains("base04") && e.contains("invalid hex")) + ); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn validate_theme_file_valid() { + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_valid_theme.yml"); + let content = r###" +system: "base16" +name: "Test Theme" +author: "Test Author" +variant: "dark" +palette: + base00: "#000000" + base01: "#111111" + base02: "#222222" + base03: "#333333" + base04: "#444444" + base05: "#555555" + base06: "#666666" + base07: "#777777" + base08: "#888888" + base09: "#999999" + base0A: "#aaaaaa" + base0B: "#bbbbbb" + base0C: "#cccccc" + base0D: "#dddddd" + base0E: "#eeeeee" + base0F: "#ffffff" +"###; + std::fs::write(&test_file, content).expect("Failed to write test file"); + + let result = validate_theme_file(&test_file); + assert!(result.is_valid()); + + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn validate_theme_name_builtin() { + let result = validate_theme_name("nord"); + assert!(result.is_ok()); + } + + #[test] + fn validate_theme_name_default() { + let result = validate_theme_name("default"); + assert!(result.is_ok()); + } + + #[test] + fn validate_theme_name_invalid() { + let result = validate_theme_name("nonexistent-theme"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Theme 'nonexistent-theme' not found") + ); + } + + #[test] + fn validation_result_is_valid() { + let mut result = ValidationResult::new(); + assert!(result.is_valid()); + + result.add_warning("test warning".to_string()); + assert!(result.is_valid()); + + result.add_error("test error".to_string()); + assert!(!result.is_valid()); + } + + #[test] + fn validation_result_has_issues() { + let mut result = ValidationResult::new(); + assert!(!result.has_issues()); + + result.add_warning("test warning".to_string()); + assert!(result.has_issues()); + } +}