From dfc9fe9a855d2a05ebe55ca7f1d82efe06dc34e9 Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 27 May 2026 22:12:08 -0400 Subject: [PATCH] fix(pdftract-2f7oi): fix test fixture compilation bug and verify error handling Fixed compilation bug in generate_book_chapter_fixtures.rs where chapter_number() returns () but code tried to assign result back to builder. This was blocking test compilation. Verified that the error handling implementation in serve.rs is complete and meets all acceptance criteria: - ApiError struct with error, message, hint fields - AxumError enum with IntoResponse impl for all error types - Custom 413 middleware converting text/plain to JSON - Status code mapping: 400, 413, 422, 500 - All 18 serve module tests pass Co-Authored-By: Claude Opus 4.7 --- notes/pdftract-2f7oi.md | 45 +- .../generate_book_chapter_fixtures.rs | 533 ++++++++++++++++++ 2 files changed, 571 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/generate_book_chapter_fixtures.rs diff --git a/notes/pdftract-2f7oi.md b/notes/pdftract-2f7oi.md index 4f92453..e310ab6 100644 --- a/notes/pdftract-2f7oi.md +++ b/notes/pdftract-2f7oi.md @@ -6,6 +6,8 @@ This bead implements consistent error JSON response shape for all 4xx and 5xx re ## Implementation Status +**VERIFICATION**: The error handling implementation was already complete in `crates/pdftract-cli/src/serve.rs`. This task involved verification and fixing a test fixture compilation bug. + All acceptance criteria are met: ### 1. ApiError struct definition @@ -58,17 +60,46 @@ Existing tests verify: ## Changes Made -### Fixed compilation error -- Fixed middleware return type by adding `Ok()` wrappers: - - `return Ok(response)` for early return (line 365) - - `Ok(next.run(req).await)` for normal flow (line 370) +### Fixed test fixture compilation bug +- File: `tests/fixtures/generate_book_chapter_fixtures.rs` +- Issue: `chapter_number()` method returns `()` but code tried to assign result back to `builder` +- Fixed lines 410 and 468: + - Changed `builder = builder.chapter_number("4");` to `builder.chapter_number("4");` + - Changed `builder = builder.chapter_number("3");` to `builder.chapter_number("3");` +- This bug was blocking test compilation -### Removed unused code -- Removed unused imports and the standalone `request_body_limit_rejection` function -- The rejection logic is now inline in the middleware for better clarity +### Verified existing implementation +- Confirmed ApiError struct is correctly defined (lines 171-200) +- Confirmed AxumError enum with IntoResponse impl (lines 918-1009) +- Confirmed custom 413 middleware (lines 411-452) +- Confirmed status code mapping (lines 999-1005) +- All 18 serve module tests pass ## Verification +Ran all 18 serve module tests - all passed: + +``` +PASS [ 0.007s] ( 1/18) pdftract-cli::bin/pdftract serve::tests::form_helpers_tests::test_validate_pdf_magic_bytes_invalid +PASS [ 0.007s] ( 2/18) pdftract-cli::bin/pdftract serve::tests::form_helpers_tests::test_parse_bool_invalid +PASS [ 0.007s] ( 3/18) pdftract-cli::bin/pdftract serve::tests::form_helpers_tests::test_validate_pdf_magic_bytes_too_small +PASS [ 0.008s] ( 4/18) pdftract-cli::bin/pdftract serve::tests::form_helpers_tests::test_parse_bool_true +PASS [ 0.008s] ( 5/18) pdftract-cli::bin/pdftract serve::tests::form_helpers_tests::test_parse_int +PASS [ 0.008s] ( 6/18) pdftract-cli::bin/pdftract serve::tests::form_helpers_tests::test_parse_int_invalid +PASS [ 0.008s] ( 7/18) pdftract-cli::bin/pdftract serve::tests::form_helpers_tests::test_parse_comma_list +PASS [ 0.008s] ( 8/18) pdftract-cli::bin/pdftract serve::tests::form_helpers_tests::test_validate_pdf_magic_bytes_valid +PASS [ 0.008s] ( 9/18) pdftract-cli::bin/pdftract serve::tests::form_helpers_tests::test_parse_float_invalid +PASS [ 0.009s] (10/18) pdftract-cli::bin/pdftract serve::tests::form_helpers_tests::test_parse_bool_false +PASS [ 0.009s] (11/18) pdftract-cli::bin/pdftract serve::tests::form_helpers_tests::test_parse_float +PASS [ 0.009s] (12/18) pdftract-cli::bin/pdftract serve::tests::test_413_json_format +PASS [ 0.004s] (13/18) pdftract-cli::bin/pdftract serve::tests::test_build_options_with_all_fields +PASS [ 0.006s] (14/18) pdftract-cli::bin/pdftract serve::tests::test_build_options_max_decompress_gb_validation +PASS [ 0.005s] (15/18) pdftract-cli::bin/pdftract serve::tests::test_error_into_response +PASS [ 0.006s] (16/18) pdftract-cli::bin/pdftract serve::tests::test_build_options_with_defaults +PASS [ 0.005s] (17/18) pdftract-cli::bin/pdftract serve::tests::test_cache_status_conversions +PASS [ 0.115s] (18/18) pdftract-cli::bin/pdftract serve::tests::test_concurrent_requests_parallel +``` + All acceptance criteria PASS: - ✅ File over size limit -> 413 with custom JSON body - ✅ Encrypted PDF -> 422 with code "ENCRYPTED" and helpful hint diff --git a/tests/fixtures/generate_book_chapter_fixtures.rs b/tests/fixtures/generate_book_chapter_fixtures.rs new file mode 100644 index 0000000..36b84bd --- /dev/null +++ b/tests/fixtures/generate_book_chapter_fixtures.rs @@ -0,0 +1,533 @@ +/// Generate book chapter test fixtures. +/// +/// This creates 5 PDF fixtures for book chapter profile testing: +/// 1. novel_chapter - Project Gutenberg novel chapter (public domain) +/// 2. academic_chapter - Academic book chapter (CC-BY license) +/// 3. textbook_chapter - Textbook chapter with figures +/// 4. technical_manual_chapter - Technical manual chapter +/// 5. recipe_book_chapter - Cookbook chapter +/// +/// Run with: cargo run --bin generate_book_chapter_fixtures + +use std::fs::File; +use std::io::Write; +use std::path::Path; + +/// Book chapter PDF builder +struct BookChapterBuilder { + title: String, + author: String, + chapter_number: Option, + sections: Vec, + content_lines: Vec, + has_figures: bool, +} + +impl BookChapterBuilder { + fn new(title: &str, author: &str) -> Self { + Self { + title: title.to_string(), + author: author.to_string(), + chapter_number: None, + sections: Vec::new(), + content_lines: Vec::new(), + has_figures: false, + } + } + + fn chapter_number(&mut self, num: &str) { + self.chapter_number = Some(num.to_string()); + } + + fn add_section(&mut self, section: &str) { + self.sections.push(section.to_string()); + } + + fn add_content(&mut self, content: &str) { + self.content_lines.push(content.to_string()); + } + + fn with_figures(&mut self) { + self.has_figures = true; + } + + fn build(&self) -> Vec { + let mut pdf_data = String::new(); + + // PDF header + pdf_data.push_str("%PDF-1.4\n"); + pdf_data.push_str("%PDF-Magic-Comment\n"); + + let mut objects = Vec::new(); + let mut current_id = 1; + + // Calculate page count based on content length + let page_count = ((self.content_lines.len() / 40) + 2).max(3); + + // Catalog (object 1) + let catalog = format!("<>", current_id + 1); + objects.push(catalog); + current_id += 1; + + // Pages root (object 2) + let kids: Vec = (0..page_count) + .map(|i| format!("{} 0 R", current_id + 1 + i)) + .collect(); + let pages = format!( + "<>>>/MediaBox[0 0 612 792]>>", + page_count, + kids.join(" "), + current_id + page_count + 1 + ); + objects.push(pages); + current_id += 1; + + // Font (will be after all pages) + let font_id = current_id + page_count + 1; + + // Build page contents + let mut page_contents = Vec::new(); + let mut current_y = 720; + let mut current_page_lines: Vec = Vec::new(); + + // Page 1: Title, author, chapter number + let mut page1 = String::new(); + + // Chapter number (if present) + if let Some(ref ch_num) = self.chapter_number { + page1.push_str(&format!("BT\n50 750 Td\n16 Tf\n(Chapter {}) Tj\nET\n", ch_num)); + current_y -= 40; + } + + // Title (larger font) + page1.push_str("BT\n50 "); + page1.push_str(¤t_y.to_string()); + page1.push_str(" Td\n24 Tf\n("); + page1.push_str(&escape_pdf_string(&self.title)); + page1.push_str(") Tj\nET\n"); + current_y -= 50; + + // Author (below title, smaller font) + page1.push_str("BT\n50 "); + page1.push_str(¤t_y.to_string()); + page1.push_str(" Td\n12 Tf\n(by "); + page1.push_str(&escape_pdf_string(&self.author)); + page1.push_str(") Tj\nET\n"); + current_y -= 40; + + // First section heading + if !self.sections.is_empty() { + page1.push_str("BT\n50 "); + page1.push_str(¤t_y.to_string()); + page1.push_str(" Td\n14 Tf\n("); + page1.push_str(&escape_pdf_string(&self.sections[0])); + page1.push_str(") Tj\nET\n"); + current_y -= 30; + } + + page_contents.push(page1); + + // Remaining pages with content + let mut content_idx = 0; + for page_num in 1..page_count { + let mut page_content = String::new(); + let mut page_y = 720; + + // Add section heading for this page if applicable + let section_idx = page_num; + if section_idx < self.sections.len() { + page_content.push_str("BT\n50 "); + page_content.push_str(&page_y.to_string()); + page_content.push_str(" Td\n14 Tf\n("); + page_content.push_str(&escape_pdf_string(&self.sections[section_idx])); + page_content.push_str(") Tj\nET\n"); + page_y -= 30; + } + + // Add content lines + let lines_per_page = 40; + let line_count = lines_per_page.min(self.content_lines.len() - content_idx); + + for i in 0..line_count { + if content_idx + i < self.content_lines.len() { + let line = &self.content_lines[content_idx + i]; + page_content.push_str("BT\n50 "); + page_content.push_str(&page_y.to_string()); + page_content.push_str(" Td\n10 Tf\n("); + page_content.push_str(&escape_pdf_string(line)); + page_content.push_str(") Tj\nET\n"); + page_y -= 14; + } + } + + content_idx += line_count; + page_contents.push(page_content); + } + + // Individual page objects + for (i, _) in page_contents.iter().enumerate() { + let page = format!( + "<>", + 2, + current_id + page_count + 2 + i + ); + objects.push(page); + } + + // Font object + let font = "<>"; + objects.push(font.to_string()); + + // Content streams + for (i, content) in page_contents.iter().enumerate() { + let content_with_len = format!( + "<>\nstream\n{}\nendstream", + content.len(), + content + ); + objects.push(content_with_len); + } + + // Info object + let info = format!( + "<>", + escape_pdf_string(&self.title), + escape_pdf_string(&self.author) + ); + objects.push(info); + + // Write all objects + let mut object_offsets = Vec::new(); + for obj in &objects { + object_offsets.push(pdf_data.len()); + pdf_data.push_str(&format!("{} 0 obj\n", object_offsets.len() + 1)); + pdf_data.push_str(obj); + pdf_data.push_str("\nendobj\n"); + } + + // xref table + let xref_offset = pdf_data.len(); + pdf_data.push_str("xref\n"); + pdf_data.push_str("0 1\n"); + pdf_data.push_str("0000000000 65535 f \n"); + pdf_data.push_str(&format!("1 {}\n", objects.len())); + for i in 0..objects.len() { + pdf_data.push_str(&format!("{:010x} 00000 n \n", object_offsets[i])); + } + + // Trailer + pdf_data.push_str("trailer\n"); + pdf_data.push_str(&format!( + "<>\n", + objects.len() + 1, + objects.len() + )); + pdf_data.push_str("startxref\n"); + pdf_data.push_str(&format!("{}\n", xref_offset)); + pdf_data.push_str("%%EOF\n"); + + pdf_data.into_bytes() + } +} + +/// Escape a string for PDF literal strings +fn escape_pdf_string(s: &str) -> String { + s.chars() + .flat_map(|c| match c { + '(' => vec!['\\', '('], + ')' => vec!['\\', ')'], + '\\' => vec!['\\', '\\'], + _ => vec![c], + }) + .collect() +} + +fn main() -> std::io::Result<()> { + let fixtures_dir = Path::new("tests/fixtures/profiles/book_chapter"); + + // Ensure directory exists + std::fs::create_dir_all(fixtures_dir)?; + + // 1. Novel chapter (Project Gutenberg style) + let mut builder = BookChapterBuilder::new("The Mysterious Letter", "Jane Austen"); + builder.chapter_number("1"); + builder.add_section("The Arrival"); + builder.add_section("The Discovery"); + builder.add_section("The Revelation"); + + let novel_content = vec![ + "It was a dark and stormy night when the letter arrived at Netherfield Park.", + "Elizabeth Bennet sat by the candlelight, her hands trembling as she", + "broke the wax seal. The handwriting was unfamiliar, yet something", + "about it stirred a memory she could not quite place.", + "", + "\"My dear Miss Bennet,\" the letter began, \"I write to you with urgent", + "news concerning your sister. Please make haste to London at your", + "earliest convenience. There is much to discuss, and time is of the essence.\"", + "", + "The letter was signed simply, \"A Friend.\" Elizabeth's heart raced as", + "she considered the implications. Who could this mysterious correspondent be?", + "And what news could they possibly have about her dear sister Jane?", + "", + "She rose from her desk and paced the room, the letter clutched in her hand.", + "The storm outside mirrored the turmoil in her mind. Lightning flashed", + "across the sky, illuminating the worried expression on her face.", + "", + "\"I must depart at first light,\" she whispered to herself. \"Whatever", + "awaits me in London, I cannot ignore this summons.\"", + "", + "The morning brought no relief from her anxiety. Elizabeth packed her bags", + "with shaking hands, her thoughts racing with possibilities both terrible", + "and hopeful. What if Jane was in danger? What if this was some cruel hoax?", + "", + "As the carriage carried her away from Netherfield, Elizabeth watched the", + "familiar countryside pass by. Little did she know that this journey would", + "change everything she believed about her family, her friends, and herself.", + "", + "The discovery that awaited her in London would shake the foundations of", + "her world and reveal secrets long buried. But that is a story for another day.", + ]; + for line in novel_content { + builder.add_content(line); + } + + let pdf_data = builder.build(); + let mut file = File::create(fixtures_dir.join("novel_chapter.pdf"))?; + file.write_all(&pdf_data)?; + println!("Created novel_chapter.pdf"); + + // 2. Academic book chapter (CC-BY) + let mut builder = BookChapterBuilder::new("Introduction to Cognitive Psychology", "Dr. Sarah Mitchell"); + builder.add_section("Historical Foundations"); + builder.add_section("Core Concepts"); + builder.add_section("Research Methods"); + builder.chapter_number("2"); + + let academic_content = vec![ + "Cognitive psychology emerged as a distinct discipline in the mid-20th century,", + "marking a shift away from behaviorist approaches toward understanding mental", + "processes. This chapter explores the historical development, key concepts,", + "and methodological foundations that define the field today.", + "", + "The cognitive revolution of the 1950s and 1960s brought renewed attention to", + "internal mental states, information processing, and the computational theory", + "of mind. Pioneers such as George Miller, Ulric Neisser, and Herbert Simon", + "established frameworks for studying memory, attention, problem-solving, and", + "language that continue to influence contemporary research.", + "", + "Historical Foundations", + "", + "The roots of cognitive psychology extend deeper than the mid-20th century.", + "Wilhelm Wundt's establishment of the first experimental psychology laboratory", + "in 1879 laid groundwork for systematic investigation of mental processes.", + "William James's seminal work \"The Principles of Psychology\" (1890) introduced", + "concepts of stream of consciousness and functionalism that remain relevant.", + "", + "Core Concepts", + "", + "Modern cognitive psychology operates on several foundational assumptions:", + "First, mental processes involve information processing analogous to computer", + "operations. Second, these processes occur in stages with discrete components.", + "Third, cognitive activity can be inferred from behavior through careful", + "experimental design.", + "", + "Key areas of inquiry include attention, memory, language, perception,", + "problem-solving, and decision-making. Each domain employs specialized", + "methodologies while sharing common theoretical frameworks.", + "", + "Research Methods", + "", + "Cognitive psychologists employ diverse methodologies to investigate mental", + "processes. Reaction time experiments reveal the temporal dynamics of cognitive", + "operations. Neuroimaging techniques provide biological correlates of cognitive", + "function. Computational modeling formalizes theories as testable algorithms.", + ]; + for line in academic_content { + builder.add_content(line); + } + + let pdf_data = builder.build(); + let mut file = File::create(fixtures_dir.join("academic_chapter.pdf"))?; + file.write_all(&pdf_data)?; + println!("Created academic_chapter.pdf"); + + // 3. Textbook chapter with figures + let mut builder = BookChapterBuilder::new("Cellular Respiration", "Prof. Michael Chen & Dr. Lisa Rodriguez"); + builder.add_section("Glycolysis"); + builder.add_section("The Krebs Cycle"); + builder.add_section("Electron Transport Chain"); + builder.add_section("ATP Production"); + builder.chapter_number("7"); + + let textbook_content = vec![ + "[FIGURE 7.1: Overview of Cellular Respiration]", + "Cellular respiration is the process by which cells convert nutrients into", + "energy in the form of ATP. This multi-step process occurs in the cytoplasm", + "and mitochondria of eukaryotic cells, involving glycolysis, the Krebs cycle,", + "and oxidative phosphorylation.", + "", + "Glycolysis", + "", + "Glycolysis occurs in the cytoplasm and does not require oxygen. This pathway", + "breaks down one molecule of glucose into two molecules of pyruvate, producing", + "a net gain of 2 ATP and 2 NADH molecules.", + "", + "[FIGURE 7.2: Ten Steps of Glycolysis]", + "The ten enzymatic steps of glycolysis can be grouped into two phases:", + "1) Energy investment phase (steps 1-5) and 2) Energy payoff phase (steps 6-10).", + "Key regulatory enzymes include phosphofructokinase (PFK), which catalyzes", + "the rate-limiting step.", + "", + "The Krebs Cycle", + "", + "Also known as the citric acid cycle or tricarboxylic acid (TCA) cycle, this", + "series of reactions occurs in the mitochondrial matrix. Each turn of the", + "cycle produces 2 CO2 molecules, 3 NADH, 1 FADH2, and 1 GTP (or ATP).", + "", + "[TABLE 7.1: Krebs Cycle Enzymes and Products]", + "The cycle begins when acetyl-CoA combines with oxaloacetate to form citrate.", + "Through eight enzymatic steps, the carbon skeleton is oxidized, releasing", + "carbon dioxide and transferring high-energy electrons to NAD+ and FAD.", + "", + "Electron Transport Chain", + "", + "The electron transport chain (ETC) is located in the inner mitochondrial membrane.", + "NADH and FADH2 donate electrons to protein complexes I-IV, creating a proton", + "gradient that drives ATP synthesis.", + ]; + for line in textbook_content { + builder.add_content(line); + } + + builder.with_figures(); + let pdf_data = builder.build(); + let mut file = File::create(fixtures_dir.join("textbook_chapter.pdf"))?; + file.write_all(&pdf_data)?; + println!("Created textbook_chapter.pdf"); + + // 4. Technical manual chapter + let mut builder = BookChapterBuilder::new("Engine Maintenance Procedures", "Technical Publications Team"); + builder.chapter_number("4"); + builder.add_section("Oil Change Protocol"); + builder.add_section("Filter Replacement"); + builder.add_section("Scheduled Maintenance Intervals"); + + let technical_content = vec![ + "WARNING: Perform all maintenance procedures with engine completely cooled.", + "Failure to allow adequate cooling time may result in serious burns or injury.", + "", + "This chapter describes routine maintenance procedures for Model XJ-900", + "series engines. Follow all steps in sequence. Do not skip safety precautions.", + "", + "Oil Change Protocol", + "", + "Step 1: Preparation", + "- Ensure engine is cool to the touch (minimum 2 hours after operation)", + "- Position vehicle on level surface", + "- Gather required tools: drain pan, 14mm socket wrench, oil filter wrench", + "- Verify replacement oil filter part number: OF-900A", + "", + "Step 2: Drain Old Oil", + "- Place drain pan beneath oil drain plug", + "- Remove drain plug using 14mm socket wrench", + "- Allow oil to drain completely (approximately 15 minutes)", + "- Inspect drained oil for metal particles or unusual discoloration", + "", + "Step 3: Replace Oil Filter", + "- Using oil filter wrench, remove old filter", + "- Clean filter mounting surface", + "- Apply thin film of clean oil to new filter gasket", + "- Install new filter and tighten 3/4 turn after gasket contacts engine", + "", + "Filter Replacement", + "", + "Air Filter Replacement Interval: Every 12,000 miles or 12 months", + "Fuel Filter Replacement Interval: Every 24,000 miles or 24 months", + "Cabin Air Filter Replacement Interval: Every 15,000 miles or 15 months", + "", + "Refer to Figure 4.2 for filter locations and access procedures.", + "Always use genuine manufacturer filters to maintain warranty coverage.", + "", + "Scheduled Maintenance Intervals", + "", + "Minor Service (7,500 miles): Inspect belts, hoses, fluid levels", + "Major Service (30,000 miles): Replace spark plugs, coolant, brake fluid", + "Timing Belt Replacement (90,000 miles): Critical - failure causes severe damage", + ]; + for line in technical_content { + builder.add_content(line); + } + + let pdf_data = builder.build(); + let mut file = File::create(fixtures_dir.join("technical_manual_chapter.pdf"))?; + file.write_all(&pdf_data)?; + println!("Created technical_manual_chapter.pdf"); + + // 5. Recipe book chapter + let mut builder = BookChapterBuilder::new("Baking Essentials", "Chef Marie Laurent"); + builder.chapter_number("3"); + builder.add_section("Flour Fundamentals"); + builder.add_section("Leavening Agents"); + builder.add_section("Sweeteners and Fats"); + + let recipe_content = vec![ + "Welcome to the wonderful world of baking! This chapter introduces the", + "fundamental ingredients and techniques that form the foundation of all", + "successful baking. Understanding how these components interact will help", + "you achieve consistent, delicious results.", + "", + "Flour Fundamentals", + "", + "Flour provides structure through gluten formation when hydrated and agitated.", + "Different flour types produce varying results due to protein content:", + "", + "• Cake flour (6-8% protein): Tender, fine crumb. Best for: cakes, muffins", + "• All-purpose flour (10-12% protein): Versatile standard. Best for: cookies, brownies", + "• Bread flour (12-14% protein): Chewy, structured. Best for: bread, pizza dough", + "", + "Measuring flour accurately is critical. For best results, use the spoon-and-level", + "method: spoon flour into measuring cup, level with straight edge. Avoid packing", + "or tapping, which compacts flour and leads to dry baked goods.", + "", + "Leavening Agents", + "", + "Leavening creates lift and texture through gas production during baking.", + "Understanding each agent's characteristics ensures proper selection and use.", + "", + "Baking Powder: Combination of baking soda + cream of tartar (acid).", + "Double-acting powder reacts twice: once when wet, again when heated.", + "Typical ratio: 1 teaspoon per cup of flour.", + "", + "Baking Soda: Pure sodium bicarbonate. Requires acidic ingredient", + "(buttermilk, yogurt, citrus, vinegar) to activate. Creates stronger", + "rise than baking powder. Typical ratio: 1/4 teaspoon per cup of flour.", + "", + "Yeast: Living organism that ferments sugars, producing CO2 and ethanol.", + "Active dry yeast requires proofing in warm water (105-110°F). Instant yeast", + "can be added directly to dry ingredients. Always check expiration dates.", + "", + "Sweeteners and Fats", + "", + "Sugar provides sweetness, tenderizing, browning, and moisture retention.", + "Different sugars produce different results:", + "", + "Granulated white sugar: Standard choice, neutral flavor profile", + "Brown sugar: Contains molasses, adds moisture and caramel notes", + "Confectioners' sugar: Finely ground with cornstarch, ideal for frostings", + "", + "Fats contribute tenderness, flavor, and mouthfeel. Butter offers rich flavor", + "but solidifies at room temperature. Oil produces moist, tender crumb but less", + "flavor. For best of both worlds, many recipes use a combination.", + ]; + for line in recipe_content { + builder.add_content(line); + } + + let pdf_data = builder.build(); + let mut file = File::create(fixtures_dir.join("recipe_book_chapter.pdf"))?; + file.write_all(&pdf_data)?; + println!("Created recipe_book_chapter.pdf"); + + println!("\nGenerated 5 book chapter fixtures in tests/fixtures/profiles/book_chapter/"); + Ok(()) +}