diff --git a/lib/data/atlas/atlas_test.v b/lib/data/atlas/atlas_test.v index 318f03d5..66d95c86 100644 --- a/lib/data/atlas/atlas_test.v +++ b/lib/data/atlas/atlas_test.v @@ -123,4 +123,49 @@ fn test_export_without_includes() { // Verify exported page1 still has include action exported := os.read_file('${export_path}/test_col2/page1.md')! assert exported.contains('!!include') +} + +fn test_error_deduplication() { + mut a := new(name: 'test')! + mut col := a.new_collection(name: 'test', path: test_base)! + + // Report same error twice + col.error( + category: .missing_include + page_key: 'test:page1' + message: 'Test error' + ) + + col.error( + category: .missing_include + page_key: 'test:page1' + message: 'Test error' // Same hash, should be deduplicated + ) + + assert col.errors.len == 1 + + // Different page_key = different hash + col.error( + category: .missing_include + page_key: 'test:page2' + message: 'Test error' + ) + + assert col.errors.len == 2 +} + +fn test_error_hash() { + err1 := CollectionError{ + category: .missing_include + page_key: 'col:page1' + message: 'Error message' + } + + err2 := CollectionError{ + category: .missing_include + page_key: 'col:page1' + message: 'Different message' // Hash is same! + } + + assert err1.hash() == err2.hash() } \ No newline at end of file diff --git a/lib/data/atlas/collection.v b/lib/data/atlas/collection.v index 78e5ce48..d10eab0e 100644 --- a/lib/data/atlas/collection.v +++ b/lib/data/atlas/collection.v @@ -3,6 +3,7 @@ module atlas import incubaid.herolib.core.pathlib import incubaid.herolib.core.texttools import incubaid.herolib.core.base +import incubaid.herolib.ui.console import os @[heap] @@ -13,8 +14,9 @@ pub mut: pages map[string]&Page images map[string]&File files map[string]&File - atlas &Atlas @[skip; str: skip] // Reference to parent atlas for include resolution - errors []CollectionError + atlas &Atlas @[skip; str: skip] // Reference to parent atlas for include resolution + errors []CollectionError + error_cache map[string]bool // Track error hashes to avoid duplicates } @[params] @@ -30,9 +32,10 @@ fn (mut self Atlas) new_collection(args CollectionNewArgs) !Collection { mut path := pathlib.get_dir(path: args.path)! mut col := Collection{ - name: name - path: path - atlas: &self // Set atlas reference + name: name + path: path + atlas: &self // Set atlas reference + error_cache: map[string]bool{} } return col @@ -208,3 +211,87 @@ pub fn (mut c Collection) export(args CollectionExportArgs) ! { redis.hset('atlas:path', c.name, col_dir.path)! } } + +@[params] +pub struct CollectionErrorArgs { +pub mut: + category CollectionErrorCategory @[required] + message string @[required] + page_key string + file string + show_console bool // Show error in console immediately + log_error bool = true // Log to errors array (default: true) +} + +// Report an error, avoiding duplicates based on hash +pub fn (mut c Collection) error(args CollectionErrorArgs) { + // Create error struct + err := CollectionError{ + category: args.category + page_key: args.page_key + message: args.message + file: args.file + } + + // Calculate hash for deduplication + hash := err.hash() + + // Check if this error was already reported + if hash in c.error_cache { + return // Skip duplicate + } + + // Mark this error as reported + c.error_cache[hash] = true + + // Log to errors array if requested + if args.log_error { + c.errors << err + } + + // Show in console if requested + if args.show_console { + console.print_stderr('[${c.name}] ${err.str()}') + } +} + +// Get all errors +pub fn (c Collection) get_errors() []CollectionError { + return c.errors +} + +// Check if collection has errors +pub fn (c Collection) has_errors() bool { + return c.errors.len > 0 +} + +// Clear all errors +pub fn (mut c Collection) clear_errors() { + c.errors = []CollectionError{} + c.error_cache = map[string]bool{} +} + +// Get error summary by category +pub fn (c Collection) error_summary() map[CollectionErrorCategory]int { + mut summary := map[CollectionErrorCategory]int{} + + for err in c.errors { + summary[err.category] = summary[err.category] + 1 + } + + return summary +} + +// Print all errors to console +pub fn (c Collection) print_errors() { + if c.errors.len == 0 { + console.print_green('Collection ${c.name}: No errors') + return + } + + console.print_header('Collection ${c.name} - Errors (${c.errors.len})') + + for err in c.errors { + console.print_stderr(' ${err.str()}') + } +} diff --git a/lib/data/atlas/collection_error.v b/lib/data/atlas/collection_error.v index bf020b62..92a5d4f4 100644 --- a/lib/data/atlas/collection_error.v +++ b/lib/data/atlas/collection_error.v @@ -1,22 +1,61 @@ module atlas +import crypto.md5 +import incubaid.herolib.ui.console + pub enum CollectionErrorCategory { - missing_include - include_syntax_error - circular_include - page_not_found - file_not_found - collection_not_found - other + circular_include + missing_include + include_syntax_error + invalid_page_reference + file_not_found + invalid_collection + general_error } pub struct CollectionError { -pub: - page_key string // "collection:page_name" if applicable - message string - category CollectionErrorCategory +pub mut: + category CollectionErrorCategory + page_key string // Format: "collection:page" or just collection name + message string + file string // Optional: specific file path if relevant } -pub fn (e CollectionError) markdown() string { - return 'ERROR [${e.category.str()}]: ${e.message}' + (if e.page_key != '' { ' (Page: `${e.page_key}`)' } else { '' }) +// Generate MD5 hash for error deduplication +// Hash is based on category + page_key (or file if page_key is empty) +pub fn (e CollectionError) hash() string { + mut hash_input := '${e.category}' + + if e.page_key != '' { + hash_input += ':${e.page_key}' + } else if e.file != '' { + hash_input += ':${e.file}' + } + + return md5.hexhash(hash_input) +} + +// Get human-readable error message +pub fn (e CollectionError) str() string { + mut location := '' + if e.page_key != '' { + location = ' [${e.page_key}]' + } else if e.file != '' { + location = ' [${e.file}]' + } + + return '[${e.category}]${location}: ${e.message}' +} + +// Get category as string +pub fn (e CollectionError) category_str() string { + return match e.category { + .circular_include { 'Circular Include' } + .missing_include { 'Missing Include' } + .include_syntax_error { 'Include Syntax Error' } + .invalid_page_reference { 'Invalid Page Reference' } + .file_not_found { 'File Not Found' } + .invalid_collection { 'Invalid Collection' } + .general_error { 'General Error' } + } } \ No newline at end of file diff --git a/lib/data/atlas/export.v b/lib/data/atlas/export.v index 99d21255..7e5b1bbf 100644 --- a/lib/data/atlas/export.v +++ b/lib/data/atlas/export.v @@ -26,5 +26,10 @@ pub fn (mut a Atlas) export(args ExportArgs) ! { include: args.include redis: args.redis )! + + // Print errors for this collection if any + if col.has_errors() { + col.print_errors() + } } } \ No newline at end of file diff --git a/lib/data/atlas/page.v b/lib/data/atlas/page.v index ae17046e..6b00eb58 100644 --- a/lib/data/atlas/page.v +++ b/lib/data/atlas/page.v @@ -2,7 +2,6 @@ module atlas import incubaid.herolib.core.pathlib import incubaid.herolib.core.texttools -import incubaid.herolib.data.atlas.collection_error { CollectionError, CollectionErrorCategory } @[heap] pub struct Page { @@ -59,12 +58,13 @@ fn (mut p Page) process_includes(content string, mut visited map[string]bool) !s // Prevent circular includes page_key := p.key() if page_key in visited { - p.collection.errors << CollectionError{ - page_key: page_key - message: 'Circular include detected for page `${page_key}`.' - category: .circular_include - } - return '' // Return empty string for circular includes + p.collection.error( + category: .circular_include + page_key: page_key + message: 'Circular include detected for page `${page_key}`' + show_console: false // Don't show immediately, collect for later + ) + return '' } visited[page_key] = true @@ -90,11 +90,12 @@ fn (mut p Page) process_includes(content string, mut visited map[string]bool) !s target_collection = texttools.name_fix(parts[0]) target_page = texttools.name_fix(parts[1]) } else { - p.collection.errors << CollectionError{ - page_key: page_key - message: 'Invalid include format: `${include_ref}`.' - category: .include_syntax_error - } + p.collection.error( + category: .include_syntax_error + page_key: page_key + message: 'Invalid include format: `${include_ref}`' + show_console: false + ) processed_lines << '' continue } @@ -112,12 +113,12 @@ fn (mut p Page) process_includes(content string, mut visited map[string]bool) !s // Get the referenced page from atlas mut include_page := atlas.page_get(page_ref) or { - p.collection.errors << CollectionError{ - page_key: page_key - message: 'Included page `${page_ref}` not found.' - category: .missing_include - } - // If page not found, keep original line as comment + p.collection.error( + category: .missing_include + page_key: page_key + message: 'Included page `${page_ref}` not found' + show_console: false + ) processed_lines << '' continue } diff --git a/lib/data/atlas/scan.v b/lib/data/atlas/scan.v index 1ff1b2b6..d86aa234 100644 --- a/lib/data/atlas/scan.v +++ b/lib/data/atlas/scan.v @@ -8,96 +8,96 @@ import os @[params] pub struct ScanArgs { pub mut: - path string @[required] + path string @[required] } // Scan a directory for collections fn (mut a Atlas) scan_directory(mut dir pathlib.Path) ! { - if !dir.is_dir() { - return error('Path is not a directory: ${dir.path}') - } - - // Check if this directory is a collection - if is_collection_dir(dir) { - collection_name := get_collection_name(mut dir)! - a.add_collection(path: dir.path, name: collection_name)! - return - } - - // Scan subdirectories - entries := dir.list(recursive: false)! - for entry in entries.paths { - if !entry.is_dir() || should_skip_dir(entry) { - continue - } - - mut mutable_entry := entry - a.scan_directory(mut mutable_entry)! - } + if !dir.is_dir() { + return error('Path is not a directory: ${dir.path}') + } + + // Check if this directory is a collection + if is_collection_dir(dir) { + collection_name := get_collection_name(mut dir)! + a.add_collection(path: dir.path, name: collection_name)! + return + } + + // Scan subdirectories + mut entries := dir.list(recursive: false)! + for mut entry in entries.paths { + if !entry.is_dir() || should_skip_dir(entry) { + continue + } + + mut mutable_entry := entry + a.scan_directory(mut mutable_entry)! + } } // Check if directory is a collection fn is_collection_dir(path pathlib.Path) bool { - return path.file_exists('.collection') + return path.file_exists('.collection') } // Get collection name from .collection file fn get_collection_name(mut path pathlib.Path) !string { - mut collection_name := path.name() - mut filepath := path.file_get('.collection')! - - content := filepath.read()! - if content.trim_space() != '' { - mut params := paramsparser.parse(content)! - if params.exists('name') { - collection_name = params.get('name')! - } - } - - return texttools.name_fix(collection_name) + mut collection_name := path.name() + mut filepath := path.file_get('.collection')! + + content := filepath.read()! + if content.trim_space() != '' { + mut params := paramsparser.parse(content)! + if params.exists('name') { + collection_name = params.get('name')! + } + } + + return texttools.name_fix(collection_name) } // Check if directory should be skipped fn should_skip_dir(entry pathlib.Path) bool { - name := entry.name() - return name.starts_with('.') || name.starts_with('_') + name := entry.name() + return name.starts_with('.') || name.starts_with('_') } // Scan collection directory for files fn (mut c Collection) scan() ! { - c.scan_path(mut c.path)! + c.scan_path(mut c.path)! } fn (mut c Collection) scan_path(mut dir pathlib.Path) ! { - entries := dir.list(recursive: false)! - - for entry in entries.paths { - // Skip hidden files/dirs - if entry.name().starts_with('.') || entry.name().starts_with('_') { - continue - } - - if entry.is_dir() { - // Recursively scan subdirectories - mut mutable_entry := entry - c.scan_path(mut mutable_entry)! - continue - } - - // Process files based on extension - match entry.extension_lower() { - 'md' { - mut mutable_entry := entry - c.add_page(mut mutable_entry)! - } - 'png', 'jpg', 'jpeg', 'gif', 'svg' { - mut mutable_entry := entry - c.add_image(mut mutable_entry)! - } - else { - mut mutable_entry := entry - c.add_file(mut mutable_entry)! - } - } - } -} \ No newline at end of file + mut entries := dir.list(recursive: false)! + + for mut entry in entries.paths { + // Skip hidden files/dirs + if entry.name().starts_with('.') || entry.name().starts_with('_') { + continue + } + + if entry.is_dir() { + // Recursively scan subdirectories + mut mutable_entry := entry + c.scan_path(mut mutable_entry)! + continue + } + + // Process files based on extension + match entry.extension_lower() { + 'md' { + mut mutable_entry := entry + c.add_page(mut mutable_entry)! + } + 'png', 'jpg', 'jpeg', 'gif', 'svg' { + mut mutable_entry := entry + c.add_image(mut mutable_entry)! + } + else { + mut mutable_entry := entry + c.add_file(mut mutable_entry)! + } + } + } +}