Skip to main content

plissken_core/render/
module_renderer.rs

1//! Module page rendering for Python and Rust documentation
2//!
3//! This module provides rendering functionality for converting `PythonModule`
4//! and `RustModule` structures into Markdown documentation files.
5
6use crate::docstring::{parse_docstring, parse_rust_doc};
7use crate::model::{
8    CrossRef, ParamDoc, PythonClass, PythonFunction, PythonItem, PythonModule, PythonParam,
9    PythonVariable, RustEnum, RustFunction, RustImpl, RustItem, RustModule, RustStruct, SourceType,
10    Visibility,
11};
12use crate::render::docstring_renderer::render_docstring;
13use crate::render::module::{CrossRefLinker, PageLayout};
14use std::collections::HashMap;
15use std::path::PathBuf;
16
17use super::Renderer;
18
19/// Rendered output for a documentation file
20#[derive(Debug, Clone)]
21pub struct RenderedPage {
22    /// Relative path for output (e.g., "my_module.md" or "rust/my_crate.md")
23    pub path: PathBuf,
24    /// The rendered Markdown content
25    pub content: String,
26}
27
28/// Builder for constructing module documentation pages.
29///
30/// This provides a common structure for both Python and Rust module pages,
31/// reducing code duplication between `render_python_module_inline` and
32/// `render_rust_module_inline`.
33struct ModulePageBuilder {
34    content: String,
35}
36
37impl ModulePageBuilder {
38    /// Create a new page builder
39    fn new() -> Self {
40        Self {
41            content: String::new(),
42        }
43    }
44
45    /// Add the module header with a badge
46    fn add_header(&mut self, module_name: &str, badge: &str) {
47        self.content
48            .push_str(&format!("# {} {}\n\n", module_name, badge));
49    }
50
51    /// Add a parsed docstring section
52    fn add_docstring(&mut self, docstring: &crate::model::ParsedDocstring) {
53        self.content.push_str(&render_docstring(docstring));
54        self.content.push_str("\n\n");
55    }
56
57    /// Add a section header (h2)
58    fn add_section(&mut self, title: &str) {
59        self.content.push_str(&format!("## {}\n\n", title));
60    }
61
62    /// Add rendered item content with spacing
63    fn add_item(&mut self, item_content: &str) {
64        self.content.push_str(item_content);
65        self.content.push_str("\n\n");
66    }
67
68    /// Add a variables/constants table
69    fn add_variables_table<T, F>(&mut self, title: &str, items: &[T], row_renderer: F)
70    where
71        F: Fn(&T) -> (String, String, String), // (name, type, desc)
72    {
73        if items.is_empty() {
74            return;
75        }
76        self.content.push_str(&format!("## {}\n\n", title));
77        self.content.push_str("| Name | Type | Description |\n");
78        self.content.push_str("|------|------|-------------|\n");
79        for item in items {
80            let (name, ty, desc) = row_renderer(item);
81            self.content
82                .push_str(&format!("| `{}` | `{}` | {} |\n", name, ty, desc));
83        }
84        self.content.push('\n');
85    }
86
87    /// Build the final content
88    fn build(self) -> String {
89        self.content
90    }
91}
92
93/// Module page renderer that converts DocModel modules into Markdown files.
94pub struct ModuleRenderer<'a> {
95    renderer: &'a Renderer,
96    linker: CrossRefLinker,
97}
98
99// Allow dead code for page-per-item rendering methods (reserved for future use)
100#[allow(dead_code)]
101impl<'a> ModuleRenderer<'a> {
102    /// Create a new module renderer
103    pub fn new(renderer: &'a Renderer) -> Self {
104        Self {
105            renderer,
106            linker: CrossRefLinker::empty(),
107        }
108    }
109
110    /// Create a module renderer with cross-references for bi-directional linking
111    pub fn with_cross_refs(renderer: &'a Renderer, cross_refs: Vec<CrossRef>) -> Self {
112        Self {
113            renderer,
114            linker: CrossRefLinker::new(cross_refs),
115        }
116    }
117
118    // =========================================================================
119    // Python Module Rendering
120    // =========================================================================
121
122    /// Render a Python module to a single Markdown file with all content inline.
123    ///
124    /// This creates one file per module with classes, methods, and functions
125    /// all rendered inline rather than as separate pages.
126    pub fn render_python_module(
127        &self,
128        module: &PythonModule,
129    ) -> Result<Vec<RenderedPage>, tera::Error> {
130        // Separate classes and functions
131        let mut classes = Vec::new();
132        let mut functions = Vec::new();
133        let mut variables = Vec::new();
134
135        for item in &module.items {
136            match item {
137                PythonItem::Class(c) => classes.push(c),
138                PythonItem::Function(f) => functions.push(f),
139                PythonItem::Variable(v) => variables.push(v),
140            }
141        }
142
143        // Render everything into a single page
144        let page = self.render_python_module_inline(module, &classes, &functions, &variables)?;
145
146        Ok(vec![page])
147    }
148
149    /// Render a Python module with all content inline in a single page.
150    fn render_python_module_inline(
151        &self,
152        module: &PythonModule,
153        classes: &[&PythonClass],
154        functions: &[&PythonFunction],
155        variables: &[&PythonVariable],
156    ) -> Result<RenderedPage, tera::Error> {
157        let mut builder = ModulePageBuilder::new();
158        let layout = PageLayout::new();
159
160        // Module header with source badge - use full namespace path
161        let source_badge = self.source_badge(&module.source_type)?;
162        builder.add_header(&module.path, &source_badge);
163
164        // Module docstring
165        if let Some(ref docstring) = module.docstring {
166            builder.add_docstring(&parse_docstring(docstring));
167        }
168
169        // Module-level variables (constants)
170        builder.add_variables_table("Variables", variables, |var| {
171            (
172                var.name.clone(),
173                var.ty.clone().unwrap_or_else(|| "-".to_string()),
174                var.docstring.clone().unwrap_or_default(),
175            )
176        });
177
178        // Classes section
179        if !classes.is_empty() {
180            builder.add_section("Classes");
181            for class in classes {
182                builder.add_item(&self.render_python_class_inline(class, &module.path)?);
183            }
184        }
185
186        // Functions section
187        if !functions.is_empty() {
188            builder.add_section("Functions");
189            for func in functions {
190                builder.add_item(&self.render_python_function_inline(func, &module.path)?);
191            }
192        }
193
194        let path = layout.python_module_page(&module.path);
195        Ok(RenderedPage {
196            path,
197            content: builder.build(),
198        })
199    }
200
201    /// Render a Python class inline (for single-page module format).
202    fn render_python_class_inline(
203        &self,
204        class: &PythonClass,
205        module_path: &str,
206    ) -> Result<String, tera::Error> {
207        let mut content = String::new();
208        let is_binding = class.rust_impl.is_some();
209
210        // Class header (h3 since Classes is h2) - use full namespace path, no "class" prefix
211        content.push_str(&format!("### `{}.{}`\n\n", module_path, class.name));
212
213        // Base classes
214        if !class.bases.is_empty() {
215            content.push_str(&format!(
216                "**Inherits from:** {}\n\n",
217                class.bases.join(", ")
218            ));
219        }
220
221        // For bindings, add link to Rust implementation
222        if is_binding
223            && let Some(link) = self
224                .linker
225                .rust_link_for_python_class(module_path, &class.name)
226        {
227            content.push_str(&link);
228        }
229
230        // Docstring - parse and render properly
231        if let Some(ref docstring) = class.docstring {
232            let parsed = parse_docstring(docstring);
233            content.push_str(&render_docstring(&parsed));
234            content.push_str("\n\n");
235        }
236
237        // Check if this is an Enum class
238        let is_enum = class.bases.iter().any(|b| {
239            b == "Enum" || b.ends_with(".Enum") || b == "IntEnum" || b.ends_with(".IntEnum")
240        });
241
242        // For enum classes, render variants as a list (unified format with Rust)
243        if is_enum && !class.attributes.is_empty() {
244            content.push_str("#### Variants\n\n");
245            for attr in &class.attributes {
246                content.push_str(&format!("- **`{}`**", attr.name));
247                // Add value if present
248                if let Some(ref value) = attr.value {
249                    content.push_str(&format!(" = `{}`", value));
250                }
251                // Add description if present
252                if let Some(ref doc) = attr.docstring {
253                    content.push_str(&format!(" - {}", doc));
254                }
255                content.push('\n');
256            }
257            content.push('\n');
258        } else if !class.attributes.is_empty() {
259            // For regular classes, render attributes
260            content.push_str("#### Attributes\n\n");
261            content.push_str("| Name | Type | Description |\n");
262            content.push_str("|------|------|-------------|\n");
263            for attr in &class.attributes {
264                let ty = attr.ty.as_deref().unwrap_or("-");
265                let desc = attr.docstring.as_deref().unwrap_or("");
266                content.push_str(&format!("| `{}` | `{}` | {} |\n", attr.name, ty, desc));
267            }
268            content.push('\n');
269        }
270
271        // Class methods (h4 since class is h3)
272        if !class.methods.is_empty() {
273            content.push_str("#### Methods\n\n");
274            for method in &class.methods {
275                let parent_class = if is_binding {
276                    Some(class.name.as_str())
277                } else {
278                    None
279                };
280                content.push_str(&self.render_python_function_with_context(
281                    method,
282                    5,
283                    module_path,
284                    parent_class,
285                )?);
286                content.push_str("\n\n");
287            }
288        }
289
290        Ok(content)
291    }
292
293    /// Render a Python function inline (for single-page module format).
294    fn render_python_function_inline(
295        &self,
296        func: &PythonFunction,
297        module_path: &str,
298    ) -> Result<String, tera::Error> {
299        let mut content = String::new();
300        let is_binding = func.rust_impl.is_some();
301
302        // Function header (h3 since Functions is h2) - use full namespace path
303        content.push_str(&format!("### `{}.{}`", module_path, func.name));
304
305        // Additional badges
306        if func.is_async {
307            content.push(' ');
308            content.push_str(&self.renderer.badge_async()?);
309        }
310        content.push_str("\n\n");
311
312        // Signature
313        content.push_str(&self.renderer.render_signature(
314            &func.name,
315            &self.format_python_params(&func.signature.params),
316            func.signature.return_type.as_deref(),
317            func.is_async,
318        )?);
319        content.push_str("\n\n");
320
321        // For bindings, add link to Rust implementation
322        if is_binding
323            && let Some(link) = self
324                .linker
325                .rust_link_for_python_function(module_path, &func.name)
326        {
327            content.push_str(&link);
328        }
329
330        // Docstring - parse and render with merged signature params for types
331        if let Some(ref docstring) = func.docstring {
332            content.push_str(&Self::render_docstring_with_merged_params(
333                &func.signature.params,
334                docstring,
335                is_binding,
336            ));
337            content.push_str("\n\n");
338        }
339
340        // Source code (collapsible)
341        if !func.source.source.is_empty() {
342            content.push_str("<details>\n<summary>Source</summary>\n\n");
343            content.push_str("```python\n");
344            content.push_str(&func.source.source);
345            content.push_str("\n```\n\n</details>\n\n");
346        }
347
348        Ok(content)
349    }
350
351    /// Render a Python module index page with class cards
352    fn render_python_module_index(
353        &self,
354        module: &PythonModule,
355        classes: &[&PythonClass],
356        functions: &[&PythonFunction],
357        variables: &[&PythonVariable],
358        module_dir: &str,
359    ) -> Result<RenderedPage, tera::Error> {
360        let mut content = String::new();
361
362        // Module header with source badge
363        let source_badge = self.source_badge(&module.source_type)?;
364        content.push_str(&format!("# {} {}\n\n", module.path, source_badge));
365
366        // Module docstring - parse and render properly
367        if let Some(ref docstring) = module.docstring {
368            let parsed = parse_docstring(docstring);
369            content.push_str(&render_docstring(&parsed));
370            content.push_str("\n\n");
371        }
372
373        // Module-level variables (constants)
374        if !variables.is_empty() {
375            content.push_str("## Variables\n\n");
376            content.push_str("| Name | Type | Description |\n");
377            content.push_str("|------|------|-------------|\n");
378            for var in variables {
379                let ty = var.ty.as_deref().unwrap_or("-");
380                let desc = var.docstring.as_deref().unwrap_or("");
381                content.push_str(&format!("| `{}` | `{}` | {} |\n", var.name, ty, desc));
382            }
383            content.push('\n');
384        }
385
386        // Classes section with cards linking to individual pages
387        if !classes.is_empty() {
388            content.push_str("## Classes\n\n");
389            for class in classes {
390                let is_binding = class.rust_impl.is_some();
391                let badge = if is_binding {
392                    format!("{} ", self.renderer.badge_source("binding")?)
393                } else {
394                    String::new()
395                };
396
397                // Get first line of docstring as summary
398                let summary = class
399                    .docstring
400                    .as_ref()
401                    .map(|d| d.lines().next().unwrap_or("").to_string())
402                    .unwrap_or_default();
403
404                content.push_str(&format!(
405                    "### {}[`{}`]({}.md)\n\n{}\n\n",
406                    badge, class.name, class.name, summary
407                ));
408            }
409        }
410
411        // Functions section with links to individual function pages
412        if !functions.is_empty() {
413            content.push_str("## Functions\n\n");
414
415            // List functions briefly with links to their individual pages
416            for func in functions {
417                let is_binding = func.rust_impl.is_some();
418                let badge = if is_binding {
419                    format!("{} ", self.renderer.badge_source("binding")?)
420                } else {
421                    String::new()
422                };
423
424                let summary = func
425                    .docstring
426                    .as_ref()
427                    .map(|d| d.lines().next().unwrap_or("").to_string())
428                    .unwrap_or_default();
429
430                content.push_str(&format!("- {}[`{}`]({}.md)", badge, func.name, func.name));
431                if !summary.is_empty() {
432                    content.push_str(&format!(" - {}", summary));
433                }
434                content.push('\n');
435            }
436            content.push('\n');
437        }
438
439        let path = PathBuf::from(format!("{}/index.md", module_dir));
440        Ok(RenderedPage { path, content })
441    }
442
443    /// Render a single Python class as its own page
444    fn render_python_class_page(
445        &self,
446        class: &PythonClass,
447        module_path: &str,
448        module_dir: &str,
449    ) -> Result<RenderedPage, tera::Error> {
450        let mut content = String::new();
451        let is_binding = class.rust_impl.is_some();
452
453        // Class header with badges (h1 since it's the page title)
454        let badge = if is_binding {
455            format!("{} ", self.renderer.badge_source("binding")?)
456        } else {
457            String::new()
458        };
459        // Use full namespace path for class page, no "class" prefix
460        content.push_str(&format!("# {}`{}.{}`\n\n", badge, module_path, class.name));
461
462        // Breadcrumb back to module
463        content.push_str(&format!("*Module: [{}](index.md)*\n\n", module_path));
464
465        // Base classes
466        if !class.bases.is_empty() {
467            content.push_str(&format!(
468                "**Inherits from:** {}\n\n",
469                class.bases.join(", ")
470            ));
471        }
472
473        // For bindings, add link to Rust implementation
474        if is_binding
475            && let Some(link) = self
476                .linker
477                .rust_link_for_python_class(module_path, &class.name)
478        {
479            content.push_str(&link);
480        }
481
482        // Docstring - parse and render properly
483        if let Some(ref docstring) = class.docstring {
484            let parsed = parse_docstring(docstring);
485            content.push_str(&render_docstring(&parsed));
486            content.push_str("\n\n");
487        }
488
489        // Check if this is an Enum class
490        let is_enum = class.bases.iter().any(|b| {
491            b == "Enum" || b.ends_with(".Enum") || b == "IntEnum" || b.ends_with(".IntEnum")
492        });
493
494        // For enum classes, render members as a table
495        if is_enum && !class.attributes.is_empty() {
496            content.push_str("### Members\n\n");
497            content.push_str("| Name | Value |\n");
498            content.push_str("|------|-------|\n");
499            for attr in &class.attributes {
500                let value = attr.value.as_deref().unwrap_or("-");
501                content.push_str(&format!("| `{}` | `{}` |\n", attr.name, value));
502            }
503            content.push('\n');
504        } else if !class.attributes.is_empty() {
505            // For regular classes, render attributes
506            content.push_str("### Attributes\n\n");
507            content.push_str("| Name | Type | Description |\n");
508            content.push_str("|------|------|-------------|\n");
509            for attr in &class.attributes {
510                let ty = attr.ty.as_deref().unwrap_or("-");
511                let desc = attr.docstring.as_deref().unwrap_or("");
512                content.push_str(&format!("| `{}` | `{}` | {} |\n", attr.name, ty, desc));
513            }
514            content.push('\n');
515        }
516
517        // Class methods
518        if !class.methods.is_empty() {
519            content.push_str("### Methods\n\n");
520            for method in &class.methods {
521                // Pass parent class name for method-level cross-links
522                let parent_class = if is_binding {
523                    Some(class.name.as_str())
524                } else {
525                    None
526                };
527                content.push_str(&self.render_python_function_for_class_page(
528                    method,
529                    module_path,
530                    &class.name,
531                    parent_class,
532                )?);
533                content.push_str("\n\n");
534            }
535        }
536
537        let path = PathBuf::from(format!("{}/{}.md", module_dir, class.name));
538        Ok(RenderedPage { path, content })
539    }
540
541    /// Render Python module-level functions as their own page
542    /// Render a single Python function to its own page
543    fn render_python_function_page(
544        &self,
545        func: &PythonFunction,
546        module_path: &str,
547        module_dir: &str,
548    ) -> Result<RenderedPage, tera::Error> {
549        let mut content = String::new();
550        let is_binding = func.rust_impl.is_some();
551
552        // Function header with badges (h1 since it's the page title)
553        let badge = if is_binding {
554            format!("{} ", self.renderer.badge_source("binding")?)
555        } else {
556            String::new()
557        };
558        // Use full namespace path for function page
559        content.push_str(&format!("# {}`{}.{}`", badge, module_path, func.name));
560
561        // Additional badges
562        if func.is_async {
563            content.push(' ');
564            content.push_str(&self.renderer.badge_async()?);
565        }
566        content.push_str("\n\n");
567
568        // Breadcrumb back to module
569        content.push_str(&format!("*Module: [{}](index.md)*\n\n", module_path));
570
571        // For bindings, add link to Rust implementation
572        if is_binding
573            && let Some(link) = self
574                .linker
575                .rust_link_for_python_function(module_path, &func.name)
576        {
577            content.push_str(&link);
578        }
579
580        // Signature
581        content.push_str(&self.renderer.render_signature(
582            &func.name,
583            &self.format_python_params(&func.signature.params),
584            func.signature.return_type.as_deref(),
585            func.is_async,
586        )?);
587        content.push_str("\n\n");
588
589        // Docstring - parse and render with merged signature params for types
590        if let Some(ref docstring) = func.docstring {
591            content.push_str(&Self::render_docstring_with_merged_params(
592                &func.signature.params,
593                docstring,
594                is_binding,
595            ));
596            content.push_str("\n\n");
597        }
598
599        // Source code (collapsible)
600        if !func.source.source.is_empty() {
601            content.push_str("<details>\n<summary>Source</summary>\n\n");
602            content.push_str("```python\n");
603            content.push_str(&func.source.source);
604            content.push_str("\n```\n\n</details>\n\n");
605        }
606
607        let path = PathBuf::from(format!("{}/{}.md", module_dir, func.name));
608        Ok(RenderedPage { path, content })
609    }
610
611    /// Render a Python function/method for a class page (uses h4 for methods)
612    fn render_python_function_for_class_page(
613        &self,
614        func: &PythonFunction,
615        module_path: &str,
616        _class_name: &str,
617        parent_class: Option<&str>,
618    ) -> Result<String, tera::Error> {
619        self.render_python_function_with_context(func, 4, module_path, parent_class)
620    }
621
622    /// Render a Python function/method with class context
623    ///
624    /// `parent_class` is used for method-level cross-links - when set, we look up
625    /// the parent class's cross-ref and link to the method within it
626    fn render_python_function_with_context(
627        &self,
628        func: &PythonFunction,
629        heading_level: usize,
630        module_path: &str,
631        parent_class: Option<&str>,
632    ) -> Result<String, tera::Error> {
633        let mut content = String::new();
634        let is_binding = func.rust_impl.is_some();
635
636        // Function heading with badges
637        let heading_prefix = "#".repeat(heading_level);
638        content.push_str(&format!("{} `{}`", heading_prefix, func.name));
639
640        // Badges
641        if func.is_async {
642            content.push(' ');
643            content.push_str(&self.renderer.badge_async()?);
644        }
645        if func.is_property {
646            content.push(' ');
647            content.push_str(&self.renderer.render_badge("property", "gray", "property")?);
648        }
649        if func.is_staticmethod {
650            content.push(' ');
651            content.push_str(&self.renderer.render_badge(
652                "staticmethod",
653                "gray",
654                "staticmethod",
655            )?);
656        }
657        if func.is_classmethod {
658            content.push(' ');
659            content.push_str(
660                &self
661                    .renderer
662                    .render_badge("classmethod", "gray", "classmethod")?,
663            );
664        }
665        // Note: Binding badge intentionally omitted at method level
666        // The binding nature is evident from the Rust cross-ref link
667        content.push_str("\n\n");
668
669        // Signature
670        content.push_str(&self.renderer.render_signature(
671            &func.name,
672            &self.format_python_params(&func.signature.params),
673            func.signature.return_type.as_deref(),
674            func.is_async,
675        )?);
676        content.push_str("\n\n");
677
678        // For bindings, add link to Rust implementation (method-level if parent_class is set)
679        if is_binding
680            && let Some(link) =
681                self.linker
682                    .rust_link_for_python_method(module_path, &func.name, parent_class)
683        {
684            content.push_str(&link);
685        }
686
687        // Docstring - parse and render with merged signature params for types
688        if let Some(ref docstring) = func.docstring {
689            content.push_str(&Self::render_docstring_with_merged_params(
690                &func.signature.params,
691                docstring,
692                is_binding,
693            ));
694            content.push_str("\n\n");
695        }
696
697        // Source code (collapsible) - only for pure Python functions
698        if !func.source.source.is_empty() {
699            content.push_str("<details>\n<summary>Source</summary>\n\n");
700            content.push_str("```python\n");
701            content.push_str(&func.source.source);
702            content.push_str("\n```\n\n</details>\n\n");
703        }
704
705        Ok(content)
706    }
707
708    /// Format Python parameters for display
709    fn format_python_params(&self, params: &[PythonParam]) -> String {
710        params
711            .iter()
712            .map(|p| {
713                let mut s = p.name.clone();
714                if let Some(ref ty) = p.ty {
715                    s.push_str(": ");
716                    s.push_str(ty);
717                }
718                if let Some(ref default) = p.default {
719                    s.push_str(" = ");
720                    s.push_str(default);
721                }
722                s
723            })
724            .collect::<Vec<_>>()
725            .join(", ")
726    }
727
728    /// Merge signature params with docstring params to get types + descriptions
729    ///
730    /// Signature params have types from Python annotations, docstring params have descriptions.
731    /// This merges them to create ParamDoc entries with both.
732    fn merge_params_with_docstring(
733        sig_params: &[PythonParam],
734        docstring_params: &[ParamDoc],
735    ) -> Vec<ParamDoc> {
736        sig_params
737            .iter()
738            .map(|sig_param| {
739                // Find matching docstring param by name
740                let doc_param = docstring_params.iter().find(|dp| dp.name == sig_param.name);
741
742                ParamDoc {
743                    name: sig_param.name.clone(),
744                    // Prefer signature type, fall back to docstring type
745                    ty: sig_param
746                        .ty
747                        .clone()
748                        .or_else(|| doc_param.and_then(|dp| dp.ty.clone())),
749                    // Use docstring description if available
750                    description: doc_param
751                        .map(|dp| dp.description.clone())
752                        .unwrap_or_default(),
753                }
754            })
755            .collect()
756    }
757
758    /// Render docstring with merged parameters from signature
759    ///
760    /// Detects whether the docstring is Rust-style (from a binding) or Python-style
761    /// and uses the appropriate parser.
762    fn render_docstring_with_merged_params(
763        sig_params: &[PythonParam],
764        docstring: &str,
765        is_binding: bool,
766    ) -> String {
767        // Use appropriate parser based on docstring style
768        // Rust-style uses `# Arguments`, Python uses `Args:` or NumPy style
769        let mut parsed = if is_binding || Self::is_rust_style_docstring(docstring) {
770            parse_rust_doc(docstring)
771        } else {
772            parse_docstring(docstring)
773        };
774
775        // Merge signature params into docstring params
776        parsed.params = Self::merge_params_with_docstring(sig_params, &parsed.params);
777
778        render_docstring(&parsed)
779    }
780
781    /// Detect if a docstring uses Rust-style markdown headers
782    fn is_rust_style_docstring(docstring: &str) -> bool {
783        // Rust doc comments use markdown headers like `# Arguments`, `# Returns`
784        docstring.contains("# Arguments")
785            || docstring.contains("# Parameters")
786            || docstring.contains("# Returns")
787            || docstring.contains("# Errors")
788            || docstring.contains("# Panics")
789            || docstring.contains("# Examples")
790            || docstring.contains("# Safety")
791    }
792
793    /// Get source type badge
794    fn source_badge(&self, source_type: &SourceType) -> Result<String, tera::Error> {
795        match source_type {
796            SourceType::Python => self.renderer.badge_source("python"),
797            SourceType::PyO3Binding => self.renderer.badge_source("binding"),
798            SourceType::Rust => self.renderer.badge_source("rust"),
799        }
800    }
801
802    // =========================================================================
803    // Rust Module Rendering
804    // =========================================================================
805
806    /// Render a Rust module to a single Markdown file with all content inline.
807    ///
808    /// This creates one file per module with structs, enums, and functions
809    /// all rendered inline rather than as separate pages.
810    pub fn render_rust_module(
811        &self,
812        module: &RustModule,
813    ) -> Result<Vec<RenderedPage>, tera::Error> {
814        // Categorize items
815        let mut structs = Vec::new();
816        let mut enums = Vec::new();
817        let mut functions = Vec::new();
818        let mut impls: HashMap<String, Vec<&RustImpl>> = HashMap::new();
819
820        for item in &module.items {
821            match item {
822                RustItem::Struct(s) => structs.push(s),
823                RustItem::Enum(e) => enums.push(e),
824                RustItem::Function(f) => functions.push(f),
825                RustItem::Impl(i) => {
826                    impls.entry(i.target.clone()).or_default().push(i);
827                }
828                _ => {} // Skip traits, consts, type aliases for MVP
829            }
830        }
831
832        // Render everything into a single page
833        let page = self.render_rust_module_inline(module, &structs, &enums, &functions, &impls)?;
834
835        Ok(vec![page])
836    }
837
838    /// Render a Rust module with all content inline in a single page.
839    fn render_rust_module_inline(
840        &self,
841        module: &RustModule,
842        structs: &[&RustStruct],
843        enums: &[&RustEnum],
844        functions: &[&RustFunction],
845        impls: &HashMap<String, Vec<&RustImpl>>,
846    ) -> Result<RenderedPage, tera::Error> {
847        let mut builder = ModulePageBuilder::new();
848        let layout = PageLayout::new();
849
850        // Module header with source badge - use full namespace path
851        let rust_badge = self.renderer.badge_source("rust")?;
852        builder.add_header(&module.path, &rust_badge);
853
854        // Module doc comment
855        if let Some(ref doc) = module.doc_comment {
856            builder.add_docstring(&parse_rust_doc(doc));
857        }
858
859        // Structs section
860        if !structs.is_empty() {
861            builder.add_section("Structs");
862            for s in structs {
863                let struct_impls = impls.get(&s.name).map(|v| v.as_slice()).unwrap_or(&[]);
864                builder.add_item(&self.render_rust_struct_inline(s, struct_impls, &module.path)?);
865            }
866        }
867
868        // Enums section
869        if !enums.is_empty() {
870            builder.add_section("Enums");
871            for e in enums {
872                builder.add_item(&self.render_rust_enum_inline(e, &module.path)?);
873            }
874        }
875
876        // Functions section
877        if !functions.is_empty() {
878            builder.add_section("Functions");
879            for func in functions {
880                builder.add_item(&self.render_rust_function_inline(func, &module.path)?);
881            }
882        }
883
884        let path = layout.rust_module_page(&module.path);
885        Ok(RenderedPage {
886            path,
887            content: builder.build(),
888        })
889    }
890
891    /// Render a Rust struct inline (for single-page module format).
892    fn render_rust_struct_inline(
893        &self,
894        s: &RustStruct,
895        impls: &[&RustImpl],
896        module_path: &str,
897    ) -> Result<String, tera::Error> {
898        let mut content = String::new();
899        let is_pyclass = s.pyclass.is_some();
900
901        // For pyclass, show as "class" (Python-style), otherwise "struct"
902        let _type_name = if is_pyclass { "class" } else { "struct" };
903
904        // Get Python name if different from Rust name
905        let display_name = s
906            .pyclass
907            .as_ref()
908            .and_then(|pc| pc.name.as_ref())
909            .unwrap_or(&s.name);
910
911        // Struct/class header (h3 since Structs is h2) - use full namespace path, no type prefix
912        // Badge on new line after heading to not affect anchor generation
913        content.push_str(&format!("### `{}::{}`", module_path, display_name));
914        if !is_pyclass && let Some(ref generics) = s.generics {
915            content.push_str(generics);
916        }
917        content.push_str("\n\n");
918
919        // Badge on its own line (won't affect heading anchor)
920        if is_pyclass {
921            content.push_str(&self.renderer.badge_source("binding")?);
922            content.push_str("\n\n");
923        } else {
924            content.push_str(&self.visibility_badge(&s.visibility)?);
925            content.push_str("\n\n");
926        }
927
928        // For pyclass, add link to Python API
929        if is_pyclass
930            && let Some(link) = self
931                .linker
932                .python_link_for_rust_struct(module_path, &s.name)
933        {
934            content.push_str(&link);
935        }
936
937        // Derives - only show for pure Rust
938        if !is_pyclass && !s.derives.is_empty() {
939            content.push_str(&format!("**Derives:** `{}`\n\n", s.derives.join("`, `")));
940        }
941
942        // Doc comment - parse and render properly
943        if let Some(ref doc) = s.doc_comment {
944            let parsed = parse_rust_doc(doc);
945            content.push_str(&render_docstring(&parsed));
946            content.push_str("\n\n");
947        }
948
949        // Fields - for pyclass, show Python-style types
950        if !s.fields.is_empty() {
951            content.push_str("#### Fields\n\n");
952            content.push_str("| Name | Type | Description |\n");
953            content.push_str("|------|------|-------------|\n");
954            for field in &s.fields {
955                let doc = field.doc_comment.as_deref().unwrap_or("");
956                content.push_str(&format!(
957                    "| `{}` | `{}` | {} |\n",
958                    field.name, field.ty, doc
959                ));
960            }
961            content.push('\n');
962        }
963
964        // Methods from impl blocks - track which are from pymethods blocks
965        let mut methods: Vec<(&RustFunction, bool)> = Vec::new(); // (method, is_pymethod)
966
967        for impl_block in impls {
968            if impl_block.trait_.is_none() {
969                // Inherent impl - mark methods based on whether block has pymethods
970                for method in &impl_block.methods {
971                    methods.push((method, impl_block.pymethods));
972                }
973            }
974        }
975
976        if !methods.is_empty() {
977            content.push_str("#### Methods\n\n");
978
979            for (method, is_pymethod) in methods {
980                // Pass parent struct name for method-level cross-links (use h5 for methods)
981                let parent_struct = if is_pymethod {
982                    Some(s.name.as_str())
983                } else {
984                    None
985                };
986                content.push_str(&self.render_rust_function_with_context(
987                    method,
988                    5,
989                    is_pymethod,
990                    module_path,
991                    parent_struct,
992                )?);
993                content.push_str("\n\n");
994            }
995        }
996
997        Ok(content)
998    }
999
1000    /// Render a Rust enum inline (for single-page module format).
1001    fn render_rust_enum_inline(
1002        &self,
1003        e: &RustEnum,
1004        module_path: &str,
1005    ) -> Result<String, tera::Error> {
1006        let mut content = String::new();
1007
1008        // Enum header (h3 since Enums is h2) - use full namespace path, no type prefix
1009        content.push_str(&format!("### `{}::{}`", module_path, e.name));
1010        if let Some(ref generics) = e.generics {
1011            content.push_str(generics);
1012        }
1013        content.push(' ');
1014        content.push_str(&self.visibility_badge(&e.visibility)?);
1015        content.push_str("\n\n");
1016
1017        // Doc comment - parse and render properly
1018        if let Some(ref doc) = e.doc_comment {
1019            let parsed = parse_rust_doc(doc);
1020            content.push_str(&render_docstring(&parsed));
1021            content.push_str("\n\n");
1022        }
1023
1024        // Variants
1025        if !e.variants.is_empty() {
1026            content.push_str("#### Variants\n\n");
1027            for variant in &e.variants {
1028                content.push_str(&format!("- **`{}`**", variant.name));
1029                if let Some(ref doc) = variant.doc_comment {
1030                    content.push_str(&format!(" - {}", doc));
1031                }
1032                content.push('\n');
1033            }
1034            content.push('\n');
1035        }
1036
1037        Ok(content)
1038    }
1039
1040    /// Render a Rust function inline (for single-page module format).
1041    fn render_rust_function_inline(
1042        &self,
1043        func: &RustFunction,
1044        module_path: &str,
1045    ) -> Result<String, tera::Error> {
1046        let mut content = String::new();
1047        let is_binding = func.pyfunction.is_some();
1048
1049        // Function header with badge on separate line for proper anchor generation
1050        // Use full namespace path, no "fn" prefix (context is obvious from section)
1051        content.push_str(&format!("### `{}::{}`\n\n", module_path, func.name));
1052        if is_binding {
1053            content.push_str(&self.renderer.badge_source("binding")?);
1054        } else {
1055            content.push_str(&self.visibility_badge(&func.visibility)?);
1056        }
1057        content.push_str("\n\n");
1058
1059        // For bindings, add link to Python API
1060        if is_binding
1061            && let Some(link) = self
1062                .linker
1063                .python_link_for_rust_function(module_path, &func.name)
1064        {
1065            content.push_str(&link);
1066        }
1067
1068        // Signature
1069        content.push_str("```rust\n");
1070        content.push_str(&func.signature_str);
1071        content.push_str("\n```\n\n");
1072
1073        // Docstring - parse and render properly
1074        if let Some(ref doc) = func.doc_comment {
1075            let parsed = parse_rust_doc(doc);
1076            content.push_str(&render_docstring(&parsed));
1077            content.push_str("\n\n");
1078        }
1079
1080        // Source code (collapsible)
1081        if !func.source.source.is_empty() {
1082            content.push_str("<details>\n<summary>Source</summary>\n\n");
1083            content.push_str("```rust\n");
1084            content.push_str(&func.source.source);
1085            content.push_str("\n```\n\n</details>\n\n");
1086        }
1087
1088        Ok(content)
1089    }
1090
1091    /// Render a Rust module index page with struct/enum cards
1092    fn render_rust_module_index(
1093        &self,
1094        module: &RustModule,
1095        structs: &[&RustStruct],
1096        enums: &[&RustEnum],
1097        functions: &[&RustFunction],
1098        module_dir: &str,
1099    ) -> Result<RenderedPage, tera::Error> {
1100        let mut content = String::new();
1101
1102        // Module header with source badge (badge on left)
1103        let rust_badge = self.renderer.badge_source("rust")?;
1104        content.push_str(&format!("# {} {}\n\n", rust_badge, module.path));
1105
1106        // Module doc comment - parse and render properly
1107        if let Some(ref doc) = module.doc_comment {
1108            let parsed = parse_rust_doc(doc);
1109            content.push_str(&render_docstring(&parsed));
1110            content.push_str("\n\n");
1111        }
1112
1113        // Structs section with cards linking to individual pages
1114        if !structs.is_empty() {
1115            content.push_str("## Structs\n\n");
1116            for s in structs {
1117                let is_pyclass = s.pyclass.is_some();
1118                let type_name = if is_pyclass { "class" } else { "struct" };
1119                let display_name = s
1120                    .pyclass
1121                    .as_ref()
1122                    .and_then(|pc| pc.name.as_ref())
1123                    .unwrap_or(&s.name);
1124
1125                let badge = if is_pyclass {
1126                    format!("{} ", self.renderer.badge_source("binding")?)
1127                } else {
1128                    format!("{} ", self.visibility_badge(&s.visibility)?)
1129                };
1130
1131                // Get first line of doc as summary
1132                let summary = s
1133                    .doc_comment
1134                    .as_ref()
1135                    .map(|d| d.lines().next().unwrap_or("").to_string())
1136                    .unwrap_or_default();
1137
1138                content.push_str(&format!(
1139                    "### {}[`{} {}`]({}.md)\n\n{}\n\n",
1140                    badge, type_name, display_name, s.name, summary
1141                ));
1142            }
1143        }
1144
1145        // Enums section with cards
1146        if !enums.is_empty() {
1147            content.push_str("## Enums\n\n");
1148            for e in enums {
1149                let badge = self.visibility_badge(&e.visibility)?;
1150                let summary = e
1151                    .doc_comment
1152                    .as_ref()
1153                    .map(|d| d.lines().next().unwrap_or("").to_string())
1154                    .unwrap_or_default();
1155
1156                content.push_str(&format!(
1157                    "### {} [`enum {}`]({}.md)\n\n{}\n\n",
1158                    badge, e.name, e.name, summary
1159                ));
1160            }
1161        }
1162
1163        // Functions section with links to individual function pages
1164        if !functions.is_empty() {
1165            content.push_str("## Functions\n\n");
1166
1167            // List functions briefly with links to their individual pages
1168            for func in functions {
1169                let is_binding = func.pyfunction.is_some();
1170                let badge = if is_binding {
1171                    format!("{} ", self.renderer.badge_source("binding")?)
1172                } else {
1173                    format!("{} ", self.visibility_badge(&func.visibility)?)
1174                };
1175
1176                let summary = func
1177                    .doc_comment
1178                    .as_ref()
1179                    .map(|d| d.lines().next().unwrap_or("").to_string())
1180                    .unwrap_or_default();
1181
1182                content.push_str(&format!("- {}[`{}`]({}.md)", badge, func.name, func.name));
1183                if !summary.is_empty() {
1184                    content.push_str(&format!(" - {}", summary));
1185                }
1186                content.push('\n');
1187            }
1188            content.push('\n');
1189        }
1190
1191        let path = PathBuf::from(format!("{}/index.md", module_dir));
1192        Ok(RenderedPage { path, content })
1193    }
1194
1195    /// Render a single Rust struct as its own page
1196    fn render_rust_struct_page(
1197        &self,
1198        s: &RustStruct,
1199        impls: &[&RustImpl],
1200        module_path: &str,
1201        module_dir: &str,
1202    ) -> Result<RenderedPage, tera::Error> {
1203        let mut content = String::new();
1204        let is_pyclass = s.pyclass.is_some();
1205
1206        // For pyclass, show as "class" (Python-style), otherwise "struct"
1207        let _type_name = if is_pyclass { "class" } else { "struct" };
1208
1209        // Get Python name if different from Rust name
1210        let display_name = s
1211            .pyclass
1212            .as_ref()
1213            .and_then(|pc| pc.name.as_ref())
1214            .unwrap_or(&s.name);
1215
1216        // Struct/class header (h1 since it's the page title) - badge on left
1217        let badge = if is_pyclass {
1218            format!("{} ", self.renderer.badge_source("binding")?)
1219        } else {
1220            format!("{} ", self.visibility_badge(&s.visibility)?)
1221        };
1222        // Use full namespace path for struct/class page, no type prefix
1223        content.push_str(&format!("# {}`{}::{}`", badge, module_path, display_name));
1224        if !is_pyclass && let Some(ref generics) = s.generics {
1225            content.push_str(generics);
1226        }
1227        content.push_str("\n\n");
1228
1229        // Breadcrumb back to module
1230        content.push_str(&format!("*Module: [{}](index.md)*\n\n", module_path));
1231
1232        // For pyclass, add link to Python API
1233        if is_pyclass
1234            && let Some(link) = self
1235                .linker
1236                .python_link_for_rust_struct(module_path, &s.name)
1237        {
1238            content.push_str(&link);
1239        }
1240
1241        // Derives - only show for pure Rust
1242        if !is_pyclass && !s.derives.is_empty() {
1243            content.push_str(&format!("**Derives:** `{}`\n\n", s.derives.join("`, `")));
1244        }
1245
1246        // Doc comment - parse and render properly
1247        if let Some(ref doc) = s.doc_comment {
1248            let parsed = parse_rust_doc(doc);
1249            content.push_str(&render_docstring(&parsed));
1250            content.push_str("\n\n");
1251        }
1252
1253        // Fields - for pyclass, show Python-style types
1254        if !s.fields.is_empty() {
1255            content.push_str("## Fields\n\n");
1256            content.push_str("| Name | Type | Description |\n");
1257            content.push_str("|------|------|-------------|\n");
1258            for field in &s.fields {
1259                let doc = field.doc_comment.as_deref().unwrap_or("");
1260                content.push_str(&format!(
1261                    "| `{}` | `{}` | {} |\n",
1262                    field.name, field.ty, doc
1263                ));
1264            }
1265            content.push('\n');
1266        }
1267
1268        // Methods from impl blocks - track which are from pymethods blocks
1269        let mut methods: Vec<(&RustFunction, bool)> = Vec::new(); // (method, is_pymethod)
1270
1271        for impl_block in impls {
1272            if impl_block.trait_.is_none() {
1273                // Inherent impl - mark methods based on whether block has pymethods
1274                for method in &impl_block.methods {
1275                    methods.push((method, impl_block.pymethods));
1276                }
1277            }
1278        }
1279
1280        if !methods.is_empty() {
1281            content.push_str("### Methods\n\n");
1282
1283            for (method, is_pymethod) in methods {
1284                // Pass parent struct name for method-level cross-links (use h4 for methods on struct page)
1285                let parent_struct = if is_pymethod {
1286                    Some(s.name.as_str())
1287                } else {
1288                    None
1289                };
1290                content.push_str(&self.render_rust_function_with_context(
1291                    method,
1292                    4,
1293                    is_pymethod,
1294                    module_path,
1295                    parent_struct,
1296                )?);
1297                content.push_str("\n\n");
1298            }
1299        }
1300
1301        let path = PathBuf::from(format!("{}/{}.md", module_dir, s.name));
1302        Ok(RenderedPage { path, content })
1303    }
1304
1305    /// Render a single Rust enum as its own page
1306    fn render_rust_enum_page(
1307        &self,
1308        e: &RustEnum,
1309        module_path: &str,
1310        module_dir: &str,
1311    ) -> Result<RenderedPage, tera::Error> {
1312        let mut content = String::new();
1313
1314        // Enum header (h1 since it's the page title) - use full namespace path, no type prefix
1315        content.push_str(&format!("# `{}::{}`", module_path, e.name));
1316        if let Some(ref generics) = e.generics {
1317            content.push_str(generics);
1318        }
1319        content.push(' ');
1320        content.push_str(&self.visibility_badge(&e.visibility)?);
1321        content.push_str("\n\n");
1322
1323        // Breadcrumb back to module
1324        content.push_str(&format!("*Module: [{}](index.md)*\n\n", module_path));
1325
1326        // Doc comment - parse and render properly
1327        if let Some(ref doc) = e.doc_comment {
1328            let parsed = parse_rust_doc(doc);
1329            content.push_str(&render_docstring(&parsed));
1330            content.push_str("\n\n");
1331        }
1332
1333        // Variants
1334        if !e.variants.is_empty() {
1335            content.push_str("## Variants\n\n");
1336            for variant in &e.variants {
1337                content.push_str(&format!("- **`{}`**", variant.name));
1338                if let Some(ref doc) = variant.doc_comment {
1339                    content.push_str(&format!(" - {}", doc));
1340                }
1341                content.push('\n');
1342            }
1343            content.push('\n');
1344        }
1345
1346        let path = PathBuf::from(format!("{}/{}.md", module_dir, e.name));
1347        Ok(RenderedPage { path, content })
1348    }
1349
1350    /// Render a single Rust function to its own page
1351    fn render_rust_function_page(
1352        &self,
1353        func: &RustFunction,
1354        module_path: &str,
1355        module_dir: &str,
1356    ) -> Result<RenderedPage, tera::Error> {
1357        let mut content = String::new();
1358        let is_binding = func.pyfunction.is_some();
1359
1360        // Function header with badges (h1 since it's the page title)
1361        let badge = if is_binding {
1362            format!("{} ", self.renderer.badge_source("binding")?)
1363        } else {
1364            format!("{} ", self.visibility_badge(&func.visibility)?)
1365        };
1366        // Use full namespace path for function page, no "fn" prefix
1367        content.push_str(&format!("# {}`{}::{}`\n\n", badge, module_path, func.name));
1368
1369        // Breadcrumb back to module
1370        content.push_str(&format!("*Module: [{}](index.md)*\n\n", module_path));
1371
1372        // For bindings, add link to Python API
1373        if is_binding
1374            && let Some(link) = self
1375                .linker
1376                .python_link_for_rust_function(module_path, &func.name)
1377        {
1378            content.push_str(&link);
1379        }
1380
1381        // Signature
1382        content.push_str("```rust\n");
1383        content.push_str(&func.signature_str);
1384        content.push_str("\n```\n\n");
1385
1386        // Docstring - parse and render properly
1387        if let Some(ref doc) = func.doc_comment {
1388            let parsed = parse_rust_doc(doc);
1389            content.push_str(&render_docstring(&parsed));
1390            content.push_str("\n\n");
1391        }
1392
1393        // Source code (collapsible)
1394        if !func.source.source.is_empty() {
1395            content.push_str("<details>\n<summary>Source</summary>\n\n");
1396            content.push_str("```rust\n");
1397            content.push_str(&func.source.source);
1398            content.push_str("\n```\n\n</details>\n\n");
1399        }
1400
1401        let path = PathBuf::from(format!("{}/{}.md", module_dir, func.name));
1402        Ok(RenderedPage { path, content })
1403    }
1404
1405    /// Render a Rust function with pymethod context
1406    ///
1407    /// `parent_struct` is used for method-level cross-links - when set, we look up
1408    /// the parent struct's cross-ref and link to the method within it
1409    fn render_rust_function_with_context(
1410        &self,
1411        f: &RustFunction,
1412        heading_level: usize,
1413        is_pymethod: bool,
1414        module_path: &str,
1415        parent_struct: Option<&str>,
1416    ) -> Result<String, tera::Error> {
1417        let mut content = String::new();
1418        // A function is a binding if it has #[pyfunction] or is in a #[pymethods] block
1419        let is_binding = f.pyfunction.is_some() || is_pymethod;
1420
1421        // Function heading with badges
1422        let heading_prefix = "#".repeat(heading_level);
1423        content.push_str(&format!("{} `{}`", heading_prefix, f.name));
1424
1425        // For bindings, no badge at method level (evident from cross-ref link)
1426        // For pure Rust methods, show visibility
1427        if !is_binding {
1428            content.push(' ');
1429            content.push_str(&self.visibility_badge(&f.visibility)?);
1430        }
1431        if f.is_async {
1432            content.push(' ');
1433            content.push_str(&self.renderer.badge_async()?);
1434        }
1435        if f.is_unsafe {
1436            content.push(' ');
1437            content.push_str(&self.renderer.badge_unsafe()?);
1438        }
1439        if f.is_const {
1440            content.push(' ');
1441            content.push_str(&self.renderer.render_badge("const", "blue", "const")?);
1442        }
1443        content.push_str("\n\n");
1444
1445        // Signature - always show Rust-style for Rust docs
1446        content.push_str("```rust\n");
1447        content.push_str(&f.signature_str);
1448        content.push_str("\n```\n\n");
1449
1450        // For bindings, add link to Python API (method-level if parent_struct is set)
1451        if is_binding
1452            && let Some(link) =
1453                self.linker
1454                    .python_link_for_rust_method(module_path, &f.name, parent_struct)
1455        {
1456            content.push_str(&link);
1457        }
1458
1459        // Doc comment - parse and render with proper tables
1460        // Use parse_rust_doc for Rust doc comments (uses # Headers for sections)
1461        if let Some(ref doc) = f.doc_comment {
1462            let parsed = parse_rust_doc(doc);
1463            content.push_str(&render_docstring(&parsed));
1464            content.push_str("\n\n");
1465        }
1466
1467        // Show source code in collapsible for all methods (bindings and pure Rust)
1468        if !f.source.source.is_empty() {
1469            content.push_str("<details>\n<summary>Source</summary>\n\n");
1470            content.push_str("```rust\n");
1471            content.push_str(&f.source.source);
1472            content.push_str("\n```\n\n</details>\n\n");
1473        }
1474
1475        Ok(content)
1476    }
1477
1478    /// Get visibility badge
1479    fn visibility_badge(&self, vis: &Visibility) -> Result<String, tera::Error> {
1480        match vis {
1481            Visibility::Public => self.renderer.badge_visibility("pub"),
1482            Visibility::PubCrate => self.renderer.badge_visibility("pub(crate)"),
1483            Visibility::PubSuper => self.renderer.badge_visibility("pub(super)"),
1484            Visibility::Private => self.renderer.badge_visibility("private"),
1485        }
1486    }
1487
1488    // =========================================================================
1489    // Batch Rendering
1490    // =========================================================================
1491
1492    /// Render all Python modules from a list
1493    pub fn render_python_modules(
1494        &self,
1495        modules: &[PythonModule],
1496    ) -> Result<Vec<RenderedPage>, tera::Error> {
1497        let mut all_pages = Vec::new();
1498        for module in modules {
1499            let pages = self.render_python_module(module)?;
1500            all_pages.extend(pages);
1501        }
1502        Ok(all_pages)
1503    }
1504
1505    /// Render all Rust modules from a list
1506    pub fn render_rust_modules(
1507        &self,
1508        modules: &[RustModule],
1509    ) -> Result<Vec<RenderedPage>, tera::Error> {
1510        let mut all_pages = Vec::new();
1511        for module in modules {
1512            let pages = self.render_rust_module(module)?;
1513            all_pages.extend(pages);
1514        }
1515        Ok(all_pages)
1516    }
1517
1518    // =========================================================================
1519    // SSG Adapter Integration
1520    // =========================================================================
1521
1522    /// Generate navigation using an SSG adapter.
1523    ///
1524    /// This method provides a unified interface for generating navigation
1525    /// regardless of the target SSG. Use [`super::ssg::get_ssg_adapter`] to
1526    /// get the appropriate adapter for your template.
1527    ///
1528    /// # Example
1529    ///
1530    /// ```ignore
1531    /// use plissken_core::render::ssg::get_ssg_adapter;
1532    ///
1533    /// let adapter = get_ssg_adapter(Some("mkdocs-material"));
1534    /// let nav = module_renderer.generate_nav(&adapter, &python_modules, &rust_modules);
1535    /// ```
1536    pub fn generate_nav(
1537        &self,
1538        adapter: &dyn super::ssg::SSGAdapter,
1539        python_modules: &[PythonModule],
1540        rust_modules: &[RustModule],
1541        prefix: Option<&str>,
1542    ) -> String {
1543        adapter.generate_nav(python_modules, rust_modules, prefix)
1544    }
1545
1546    /// Generate SSG config file using an adapter.
1547    ///
1548    /// Returns `None` for SSGs that don't need generated config (e.g., MkDocs).
1549    pub fn generate_config(
1550        &self,
1551        adapter: &dyn super::ssg::SSGAdapter,
1552        title: &str,
1553        authors: &[String],
1554    ) -> Option<String> {
1555        adapter.generate_config(title, authors)
1556    }
1557
1558    /// Generate custom CSS using an adapter.
1559    ///
1560    /// Returns `None` for SSGs that don't need custom CSS.
1561    pub fn generate_custom_css(&self, adapter: &dyn super::ssg::SSGAdapter) -> Option<String> {
1562        adapter.generate_custom_css()
1563    }
1564
1565    // =========================================================================
1566    // MkDocs Output Generation (legacy methods - use SSG adapter for new code)
1567    // =========================================================================
1568
1569    /// Generate navigation YAML for mkdocs.yml
1570    ///
1571    /// Creates a nav structure with inline format (one file per module):
1572    /// ```yaml
1573    /// nav:
1574    ///   - Python:
1575    ///     - pysnake: pysnake.md
1576    ///   - Rust:
1577    ///     - rustscale: rust/rustscale.md
1578    ///     - rustscale::config: rust/rustscale/config.md
1579    /// ```
1580    pub fn generate_nav_yaml(
1581        &self,
1582        python_modules: &[PythonModule],
1583        rust_modules: &[RustModule],
1584        prefix: Option<&str>,
1585    ) -> String {
1586        use crate::render::ssg::MkDocsAdapter;
1587        use crate::render::ssg::SSGAdapter;
1588        MkDocsAdapter.generate_nav(python_modules, rust_modules, prefix)
1589    }
1590
1591    // =========================================================================
1592    // mdBook Output Generation
1593    // =========================================================================
1594
1595    /// Generate SUMMARY.md for mdBook navigation
1596    ///
1597    /// Creates a hierarchical navigation with inline format (one file per module):
1598    /// ```markdown
1599    /// # Summary
1600    ///
1601    /// # Python
1602    ///
1603    /// - [pysnake](pysnake.md)
1604    ///   - [pysnake.handlers](pysnake/handlers.md)
1605    ///
1606    /// # Rust
1607    ///
1608    /// - [rustscale](rust/rustscale.md)
1609    ///   - [rustscale::config](rust/rustscale/config.md)
1610    /// ```
1611    pub fn generate_mdbook_summary(
1612        &self,
1613        python_modules: &[PythonModule],
1614        rust_modules: &[RustModule],
1615        prefix: Option<&str>,
1616    ) -> String {
1617        use crate::render::ssg::MdBookAdapter;
1618        use crate::render::ssg::SSGAdapter;
1619        MdBookAdapter.generate_nav(python_modules, rust_modules, prefix)
1620    }
1621
1622    /// Generate book.toml configuration for mdBook
1623    ///
1624    /// Includes fold configuration for collapsible sidebar sections.
1625    pub fn generate_mdbook_config(&self, title: &str, authors: &[String]) -> String {
1626        let authors_toml = if authors.is_empty() {
1627            String::from("[]")
1628        } else {
1629            format!(
1630                "[{}]",
1631                authors
1632                    .iter()
1633                    .map(|a| format!("\"{}\"", a))
1634                    .collect::<Vec<_>>()
1635                    .join(", ")
1636            )
1637        };
1638
1639        format!(
1640            r#"[book]
1641title = "{}"
1642authors = {}
1643language = "en"
1644src = "src"
1645
1646[build]
1647build-dir = "book"
1648
1649[output.html]
1650default-theme = "rust"
1651preferred-dark-theme = "coal"
1652additional-css = ["theme/custom.css"]
1653
1654[output.html.fold]
1655enable = true
1656level = 1
1657"#,
1658            title, authors_toml
1659        )
1660    }
1661
1662    /// Generate custom CSS for mdBook
1663    ///
1664    /// Hides chapter numbering for reference documentation sections (python/, rust/)
1665    /// while preserving numbering for other documentation sections.
1666    pub fn generate_mdbook_css(&self) -> String {
1667        r#"/* Hide chapter numbering for reference documentation sections only.
1668   Scoped to python/ and rust/ paths to avoid affecting other documentation.
1669   Uses *= (contains) since mdbook prepends path_to_root to hrefs. */
1670.chapter-item a[href*="python/"] strong[aria-hidden="true"],
1671.chapter-item a[href*="rust/"] strong[aria-hidden="true"] {
1672    display: none !important;
1673}
1674"#
1675        .to_string()
1676    }
1677}
1678
1679#[cfg(test)]
1680mod tests {
1681    use super::*;
1682    use crate::model::*;
1683
1684    fn test_renderer() -> Renderer {
1685        Renderer::new(None, None).unwrap()
1686    }
1687
1688    /// Helper to find a page by path suffix
1689    fn find_page<'a>(pages: &'a [RenderedPage], suffix: &str) -> Option<&'a RenderedPage> {
1690        pages
1691            .iter()
1692            .find(|p| p.path.to_string_lossy().ends_with(suffix))
1693    }
1694
1695    #[test]
1696    fn test_render_simple_python_module() {
1697        let renderer = test_renderer();
1698        let module_renderer = ModuleRenderer::new(&renderer);
1699
1700        let module = PythonModule::test("my_module")
1701            .with_docstring("A simple test module.")
1702            .with_item(PythonItem::Function(
1703                PythonFunction::test("greet")
1704                    .with_docstring("Say hello")
1705                    .with_param(PythonParam::test("name").with_type("str"))
1706                    .with_return_type("str"),
1707            ));
1708
1709        let pages = module_renderer.render_python_module(&module).unwrap();
1710
1711        // Inline format: single page per module
1712        assert_eq!(pages.len(), 1, "Should produce a single page");
1713        let page = &pages[0];
1714        assert!(page.path.to_string_lossy().ends_with("my_module.md"));
1715        assert!(page.content.contains("# my_module"));
1716        assert!(page.content.contains("A simple test module."));
1717        assert!(page.content.contains("## Functions"));
1718        // Function is rendered inline in the same file
1719        assert!(page.content.contains("greet"));
1720    }
1721
1722    #[test]
1723    fn test_render_python_module_with_class() {
1724        let renderer = test_renderer();
1725        let module_renderer = ModuleRenderer::new(&renderer);
1726
1727        let module = PythonModule::test("myapp.models").with_item(PythonItem::Class(
1728            PythonClass::test("User")
1729                .with_docstring("A user model")
1730                .with_base("BaseModel")
1731                .with_method(
1732                    PythonFunction::test("get_name")
1733                        .with_return_type("str")
1734                        .with_docstring("Get user name"),
1735                ),
1736        ));
1737
1738        let pages = module_renderer.render_python_module(&module).unwrap();
1739
1740        // Inline format: single page per module
1741        assert_eq!(pages.len(), 1, "Should produce a single page");
1742        let page = &pages[0];
1743        assert!(page.path.to_string_lossy().ends_with("models.md"));
1744        assert!(page.content.contains("## Classes"));
1745        // Class rendered inline (h3 since Classes is h2) - now with full namespace path (no type prefix)
1746        assert!(page.content.contains("### `myapp.models.User`"));
1747        assert!(page.content.contains("**Inherits from:** BaseModel"));
1748        assert!(page.content.contains("#### Methods"));
1749        assert!(page.content.contains("`get_name`"));
1750    }
1751
1752    #[test]
1753    fn test_render_python_module_pyo3_binding() {
1754        let renderer = test_renderer();
1755        let module_renderer = ModuleRenderer::new(&renderer);
1756
1757        let module = PythonModule::test("binding_module")
1758            .pyo3_binding()
1759            .with_item(PythonItem::Class(
1760                PythonClass::test("NativeClass")
1761                    .with_rust_impl(RustItemRef::new("crate::native", "NativeClass")),
1762            ));
1763
1764        let pages = module_renderer.render_python_module(&module).unwrap();
1765
1766        // Inline format: single page with binding badge in module header
1767        assert_eq!(pages.len(), 1, "Should produce a single page");
1768        let page = &pages[0];
1769        assert!(page.content.contains("Binding"));
1770        // Now with full namespace path (no type prefix)
1771        assert!(page.content.contains("binding_module.NativeClass"));
1772    }
1773
1774    #[test]
1775    fn test_render_python_module_with_cross_refs() {
1776        let renderer = test_renderer();
1777
1778        // Create cross-refs for the binding
1779        let cross_refs = vec![CrossRef {
1780            python_path: "binding_module.NativeClass".to_string(),
1781            rust_path: "crate::native::NativeClass".to_string(),
1782            relationship: crate::model::CrossRefKind::Binding,
1783        }];
1784
1785        let module_renderer = ModuleRenderer::with_cross_refs(&renderer, cross_refs);
1786
1787        let module = PythonModule::test("binding_module")
1788            .pyo3_binding()
1789            .with_item(PythonItem::Class(
1790                PythonClass::test("NativeClass")
1791                    .with_rust_impl(RustItemRef::new("crate::native", "NativeClass")),
1792            ));
1793
1794        let pages = module_renderer.render_python_module(&module).unwrap();
1795
1796        // Inline format: single page with binding badge and cross-ref link
1797        assert_eq!(pages.len(), 1, "Should produce a single page");
1798        let page = &pages[0];
1799        assert!(page.content.contains("Binding"));
1800        // Should have Rust implementation link as blockquote
1801        assert!(page.content.contains("**Rust Implementation**"));
1802        assert!(page.content.contains("crate::native::NativeClass"));
1803    }
1804
1805    #[test]
1806    fn test_render_simple_rust_module() {
1807        let renderer = test_renderer();
1808        let module_renderer = ModuleRenderer::new(&renderer);
1809
1810        let module = RustModule::test("crate::utils")
1811            .with_doc("Utility functions")
1812            .with_item(RustItem::Function(
1813                RustFunction::test("process")
1814                    .with_doc("Process some data")
1815                    .with_signature("pub fn process(data: &[u8]) -> Vec<u8>"),
1816            ));
1817
1818        let pages = module_renderer.render_rust_module(&module).unwrap();
1819
1820        // Inline format: single page per module
1821        assert_eq!(pages.len(), 1, "Should produce a single page");
1822        let page = &pages[0];
1823        assert!(page.content.contains("utils")); // module name in header
1824        assert!(page.content.contains("Rust")); // source badge
1825        assert!(page.content.contains("## Functions"));
1826        // Function is rendered inline
1827        assert!(page.content.contains("process"));
1828        assert!(page.content.contains("```rust"));
1829    }
1830
1831    #[test]
1832    fn test_render_rust_module_with_struct() {
1833        let renderer = test_renderer();
1834        let module_renderer = ModuleRenderer::new(&renderer);
1835
1836        let module = RustModule::test("crate::models")
1837            .with_item(RustItem::Struct(
1838                RustStruct::test("Config")
1839                    .with_doc("Application configuration")
1840                    .with_generics("<T>")
1841                    .with_field(RustField::test("value", "T").with_doc("The stored value"))
1842                    .with_derive("Debug")
1843                    .with_derive("Clone"),
1844            ))
1845            .with_item(RustItem::Impl(RustImpl {
1846                generics: Some("<T>".to_string()),
1847                target: "Config".to_string(),
1848                trait_: None,
1849                where_clause: None,
1850                methods: vec![
1851                    RustFunction::test("new").with_signature("pub fn new(value: T) -> Self"),
1852                ],
1853                pymethods: false,
1854                source: SourceSpan::test("test.rs", 1, 10),
1855            }));
1856
1857        let pages = module_renderer.render_rust_module(&module).unwrap();
1858
1859        // Inline format: single page per module
1860        assert_eq!(pages.len(), 1, "Should produce a single page");
1861        let page = &pages[0];
1862        assert!(page.content.contains("## Structs"));
1863        // Struct rendered inline (h3 since Structs is h2) - now with full namespace path (no type prefix)
1864        assert!(page.content.contains("### `crate::models::Config`"));
1865        assert!(page.content.contains("<T>")); // generics
1866        assert!(page.content.contains("**Derives:** `Debug`, `Clone`"));
1867        assert!(page.content.contains("#### Fields"));
1868        assert!(
1869            page.content
1870                .contains("| `value` | `T` | The stored value |")
1871        );
1872        assert!(page.content.contains("#### Methods"));
1873        assert!(page.content.contains("`new`"));
1874    }
1875
1876    #[test]
1877    fn test_render_rust_struct_with_pyclass() {
1878        let renderer = test_renderer();
1879        let module_renderer = ModuleRenderer::new(&renderer);
1880
1881        let module = RustModule::test("crate::bindings")
1882            .with_item(RustItem::Struct(
1883                RustStruct::test("PyData")
1884                    .with_pyclass(PyClassMeta::new().with_name("Data"))
1885                    .with_doc("Python-exposed data structure"),
1886            ))
1887            .with_item(RustItem::Impl(RustImpl {
1888                generics: None,
1889                target: "PyData".to_string(),
1890                trait_: None,
1891                where_clause: None,
1892                methods: vec![
1893                    RustFunction::test("value").with_signature("pub fn value(&self) -> i32"),
1894                ],
1895                pymethods: true,
1896                source: SourceSpan::test("test.rs", 1, 10),
1897            }));
1898
1899        let pages = module_renderer.render_rust_module(&module).unwrap();
1900
1901        // Inline format: single page per module
1902        assert_eq!(pages.len(), 1, "Should produce a single page");
1903        let page = &pages[0];
1904        // PyClass struct should display with full namespace path and Python name (no type prefix)
1905        assert!(
1906            page.content.contains("`crate::bindings::Data`"),
1907            "Should render pyclass with namespace path and Python name, got: {}",
1908            page.content
1909        );
1910        assert!(page.content.contains("Binding"));
1911        // Methods section should also have binding badge
1912        assert!(page.content.contains("#### Methods"));
1913    }
1914
1915    #[test]
1916    fn test_render_rust_function_badges() {
1917        let renderer = test_renderer();
1918        let module_renderer = ModuleRenderer::new(&renderer);
1919
1920        let module = RustModule::test("crate::async_utils").with_item(RustItem::Function(
1921            RustFunction::test("dangerous_async")
1922                .async_()
1923                .unsafe_()
1924                .with_signature("pub async unsafe fn dangerous_async()"),
1925        ));
1926
1927        let pages = module_renderer.render_rust_module(&module).unwrap();
1928
1929        // Inline format: single page with function inline
1930        assert_eq!(pages.len(), 1, "Should produce a single page");
1931        let page = &pages[0];
1932        assert!(page.content.contains("async")); // badge
1933        assert!(page.content.contains("unsafe")); // badge
1934        assert!(page.content.contains("pub")); // visibility badge
1935    }
1936
1937    #[test]
1938    fn test_render_rust_enum() {
1939        let renderer = test_renderer();
1940        let module_renderer = ModuleRenderer::new(&renderer);
1941
1942        let module = RustModule::test("crate::types").with_item(RustItem::Enum(RustEnum {
1943            name: "Status".to_string(),
1944            visibility: Visibility::Public,
1945            doc_comment: Some("Request status".to_string()),
1946            parsed_doc: None,
1947            generics: None,
1948            variants: vec![
1949                RustVariant {
1950                    name: "Pending".to_string(),
1951                    doc_comment: Some("Waiting to start".to_string()),
1952                    fields: vec![],
1953                },
1954                RustVariant {
1955                    name: "Complete".to_string(),
1956                    doc_comment: Some("Finished successfully".to_string()),
1957                    fields: vec![],
1958                },
1959            ],
1960            source: SourceSpan::test("test.rs", 1, 10),
1961        }));
1962
1963        let pages = module_renderer.render_rust_module(&module).unwrap();
1964
1965        // Inline format: single page per module
1966        assert_eq!(pages.len(), 1, "Should produce a single page");
1967        let page = &pages[0];
1968        assert!(page.content.contains("## Enums"));
1969        // Enum rendered inline - now with full namespace path (no type prefix)
1970        assert!(page.content.contains("### `crate::types::Status`"));
1971        assert!(page.content.contains("#### Variants"));
1972        assert!(page.content.contains("**`Pending`** - Waiting to start"));
1973        assert!(
1974            page.content
1975                .contains("**`Complete`** - Finished successfully")
1976        );
1977    }
1978
1979    #[test]
1980    fn test_batch_render_modules() {
1981        let renderer = test_renderer();
1982        let module_renderer = ModuleRenderer::new(&renderer);
1983
1984        // Empty modules still produce pages
1985        let python_modules = vec![PythonModule::test("mod1"), PythonModule::test("mod2")];
1986        let rust_modules = vec![RustModule::test("crate::a"), RustModule::test("crate::b")];
1987
1988        let py_pages = module_renderer
1989            .render_python_modules(&python_modules)
1990            .unwrap();
1991        let rs_pages = module_renderer.render_rust_modules(&rust_modules).unwrap();
1992
1993        // Inline format: each module produces a single page
1994        assert_eq!(py_pages.len(), 2);
1995        assert_eq!(rs_pages.len(), 2);
1996
1997        // Check paths are module_name.md format (inline)
1998        assert!(find_page(&py_pages, "mod1.md").is_some());
1999        assert!(find_page(&py_pages, "mod2.md").is_some());
2000        // For Rust, crate root = crate_name.md, submodule = crate/submod.md
2001        assert!(find_page(&rs_pages, "crate/a.md").is_some());
2002        assert!(find_page(&rs_pages, "crate/b.md").is_some());
2003    }
2004
2005    #[test]
2006    fn test_python_async_function() {
2007        let renderer = test_renderer();
2008        let module_renderer = ModuleRenderer::new(&renderer);
2009
2010        let module = PythonModule::test("async_mod").with_item(PythonItem::Function(
2011            PythonFunction::test("fetch")
2012                .async_()
2013                .with_param(PythonParam::test("url").with_type("str"))
2014                .with_return_type("bytes"),
2015        ));
2016
2017        let pages = module_renderer.render_python_module(&module).unwrap();
2018
2019        // Inline format: single page with async badge inline
2020        assert_eq!(pages.len(), 1, "Should produce a single page");
2021        let page = &pages[0];
2022        assert!(page.content.contains("async"));
2023    }
2024
2025    #[test]
2026    fn test_python_class_methods_types() {
2027        let renderer = test_renderer();
2028        let module_renderer = ModuleRenderer::new(&renderer);
2029
2030        let module = PythonModule::test("methods_mod").with_item(PythonItem::Class(
2031            PythonClass::test("MyClass")
2032                .with_method(PythonFunction::test("regular_method"))
2033                .with_method(PythonFunction::test("static_method").staticmethod())
2034                .with_method(PythonFunction::test("class_method").classmethod())
2035                .with_method(PythonFunction::test("prop").property()),
2036        ));
2037
2038        let pages = module_renderer.render_python_module(&module).unwrap();
2039
2040        // Inline format: single page with all method types inline
2041        assert_eq!(pages.len(), 1, "Should produce a single page");
2042        let page = &pages[0];
2043        assert!(page.content.contains("staticmethod"));
2044        assert!(page.content.contains("classmethod"));
2045        assert!(page.content.contains("property"));
2046    }
2047}
2048
2049/// Snapshot tests for rendered output using insta.
2050///
2051/// These tests capture the exact rendered Markdown output to detect
2052/// regressions in formatting, badges, headings, and other output details.
2053///
2054/// To update snapshots after intentional changes:
2055/// ```bash
2056/// cargo insta review
2057/// ```
2058/// or run tests with `INSTA_UPDATE=always cargo test`
2059#[cfg(test)]
2060mod snapshot_tests {
2061    use super::*;
2062    use crate::model::*;
2063    use insta::assert_snapshot;
2064
2065    fn test_renderer() -> Renderer {
2066        Renderer::new(None, None).unwrap()
2067    }
2068
2069    // =========================================================================
2070    // Python Module Snapshots
2071    // =========================================================================
2072
2073    #[test]
2074    fn snapshot_python_module_simple() {
2075        let renderer = test_renderer();
2076        let module_renderer = ModuleRenderer::new(&renderer);
2077
2078        let module = PythonModule::test("mymodule")
2079            .with_docstring("A simple Python module.\n\nThis module provides basic utilities.");
2080
2081        let pages = module_renderer.render_python_module(&module).unwrap();
2082        assert_snapshot!("python_module_simple", &pages[0].content);
2083    }
2084
2085    #[test]
2086    fn snapshot_python_module_with_function() {
2087        let renderer = test_renderer();
2088        let module_renderer = ModuleRenderer::new(&renderer);
2089
2090        let module = PythonModule::test("utils")
2091            .with_docstring("Utility functions")
2092            .with_item(PythonItem::Function(
2093                PythonFunction::test("process")
2094                    .with_docstring("Process the input data.\n\nArgs:\n    data: The input data to process\n\nReturns:\n    The processed result")
2095                    .with_param(PythonParam::test("data").with_type("bytes"))
2096                    .with_return_type("str"),
2097            ));
2098
2099        let pages = module_renderer.render_python_module(&module).unwrap();
2100        assert_snapshot!("python_module_with_function", &pages[0].content);
2101    }
2102
2103    #[test]
2104    fn snapshot_python_class_with_methods() {
2105        let renderer = test_renderer();
2106        let module_renderer = ModuleRenderer::new(&renderer);
2107
2108        let module = PythonModule::test("models").with_item(PythonItem::Class(
2109            PythonClass::test("User")
2110                .with_docstring("A user model.\n\nRepresents a system user with authentication.")
2111                .with_base("BaseModel")
2112                .with_attribute(
2113                    PythonVariable::test("id")
2114                        .with_type("int")
2115                        .with_docstring("User ID"),
2116                )
2117                .with_attribute(
2118                    PythonVariable::test("name")
2119                        .with_type("str")
2120                        .with_docstring("User name"),
2121                )
2122                .with_method(
2123                    PythonFunction::test("__init__")
2124                        .with_param(PythonParam::test("self"))
2125                        .with_param(PythonParam::test("name").with_type("str"))
2126                        .with_param(PythonParam::test("id").with_type("int").with_default("0")),
2127                )
2128                .with_method(
2129                    PythonFunction::test("get_display_name")
2130                        .with_docstring("Get formatted display name")
2131                        .with_param(PythonParam::test("self"))
2132                        .with_return_type("str"),
2133                )
2134                .with_method(
2135                    PythonFunction::test("from_dict")
2136                        .classmethod()
2137                        .with_docstring("Create user from dictionary")
2138                        .with_param(PythonParam::test("cls"))
2139                        .with_param(PythonParam::test("data").with_type("dict"))
2140                        .with_return_type("User"),
2141                ),
2142        ));
2143
2144        let pages = module_renderer.render_python_module(&module).unwrap();
2145        assert_snapshot!("python_class_with_methods", &pages[0].content);
2146    }
2147
2148    #[test]
2149    fn snapshot_python_async_function() {
2150        let renderer = test_renderer();
2151        let module_renderer = ModuleRenderer::new(&renderer);
2152
2153        let module = PythonModule::test("async_utils")
2154            .with_item(PythonItem::Function(
2155                PythonFunction::test("fetch")
2156                    .async_()
2157                    .with_docstring("Fetch data from URL.\n\nArgs:\n    url: The URL to fetch\n    timeout: Request timeout in seconds\n\nReturns:\n    Response bytes")
2158                    .with_param(PythonParam::test("url").with_type("str"))
2159                    .with_param(PythonParam::test("timeout").with_type("float").with_default("30.0"))
2160                    .with_return_type("bytes"),
2161            ));
2162
2163        let pages = module_renderer.render_python_module(&module).unwrap();
2164        assert_snapshot!("python_async_function", &pages[0].content);
2165    }
2166
2167    #[test]
2168    fn snapshot_python_pyo3_binding() {
2169        let renderer = test_renderer();
2170
2171        let cross_refs = vec![CrossRef {
2172            python_path: "native.DataProcessor".to_string(),
2173            rust_path: "crate::data::DataProcessor".to_string(),
2174            relationship: CrossRefKind::Binding,
2175        }];
2176
2177        let module_renderer = ModuleRenderer::with_cross_refs(&renderer, cross_refs);
2178
2179        let module = PythonModule::test("native")
2180            .pyo3_binding()
2181            .with_docstring("Native bindings for data processing")
2182            .with_item(PythonItem::Class(
2183                PythonClass::test("DataProcessor")
2184                    .with_docstring(
2185                        "High-performance data processor.\n\nWraps native Rust implementation.",
2186                    )
2187                    .with_rust_impl(RustItemRef::new("crate::data", "DataProcessor"))
2188                    .with_method(
2189                        PythonFunction::test("process")
2190                            .with_docstring("Process data efficiently")
2191                            .with_param(PythonParam::test("self"))
2192                            .with_param(PythonParam::test("data").with_type("bytes"))
2193                            .with_return_type("bytes")
2194                            .with_rust_impl(RustItemRef::new(
2195                                "crate::data::DataProcessor",
2196                                "process",
2197                            )),
2198                    ),
2199            ));
2200
2201        let pages = module_renderer.render_python_module(&module).unwrap();
2202        assert_snapshot!("python_pyo3_binding", &pages[0].content);
2203    }
2204
2205    #[test]
2206    fn snapshot_python_enum_class() {
2207        let renderer = test_renderer();
2208        let module_renderer = ModuleRenderer::new(&renderer);
2209
2210        let module = PythonModule::test("enums").with_item(PythonItem::Class(
2211            PythonClass::test("Status")
2212                .with_docstring("Request status enumeration")
2213                .with_base("Enum")
2214                .with_attribute(PythonVariable::test("PENDING").with_value("\"pending\""))
2215                .with_attribute(PythonVariable::test("RUNNING").with_value("\"running\""))
2216                .with_attribute(PythonVariable::test("COMPLETED").with_value("\"completed\""))
2217                .with_attribute(PythonVariable::test("FAILED").with_value("\"failed\"")),
2218        ));
2219
2220        let pages = module_renderer.render_python_module(&module).unwrap();
2221        assert_snapshot!("python_enum_class", &pages[0].content);
2222    }
2223
2224    #[test]
2225    fn snapshot_python_module_variables() {
2226        let renderer = test_renderer();
2227        let module_renderer = ModuleRenderer::new(&renderer);
2228
2229        let module = PythonModule::test("constants")
2230            .with_docstring("Module-level constants")
2231            .with_item(PythonItem::Variable(
2232                PythonVariable::test("VERSION")
2233                    .with_type("str")
2234                    .with_value("\"1.0.0\"")
2235                    .with_docstring("Package version"),
2236            ))
2237            .with_item(PythonItem::Variable(
2238                PythonVariable::test("MAX_RETRIES")
2239                    .with_type("int")
2240                    .with_value("3")
2241                    .with_docstring("Maximum retry attempts"),
2242            ));
2243
2244        let pages = module_renderer.render_python_module(&module).unwrap();
2245        assert_snapshot!("python_module_variables", &pages[0].content);
2246    }
2247
2248    // =========================================================================
2249    // Rust Module Snapshots
2250    // =========================================================================
2251
2252    #[test]
2253    fn snapshot_rust_module_simple() {
2254        let renderer = test_renderer();
2255        let module_renderer = ModuleRenderer::new(&renderer);
2256
2257        let module = RustModule::test("crate::utils")
2258            .with_doc("Utility functions for common operations.\n\nThis module provides helpers for data manipulation.");
2259
2260        let pages = module_renderer.render_rust_module(&module).unwrap();
2261        assert_snapshot!("rust_module_simple", &pages[0].content);
2262    }
2263
2264    #[test]
2265    fn snapshot_rust_struct_with_fields() {
2266        let renderer = test_renderer();
2267        let module_renderer = ModuleRenderer::new(&renderer);
2268
2269        let module = RustModule::test("crate::config")
2270            .with_item(RustItem::Struct(
2271                RustStruct::test("Config")
2272                    .with_doc("Application configuration.\n\nStores all runtime settings.")
2273                    .with_generics("<T: Default>")
2274                    .with_field(RustField::test("name", "String").with_doc("Configuration name"))
2275                    .with_field(RustField::test("value", "T").with_doc("Configuration value"))
2276                    .with_field(
2277                        RustField::test("enabled", "bool").with_doc("Whether config is active"),
2278                    )
2279                    .with_derive("Debug")
2280                    .with_derive("Clone")
2281                    .with_derive("Serialize"),
2282            ))
2283            .with_item(RustItem::Impl(RustImpl {
2284                generics: Some("<T: Default>".to_string()),
2285                target: "Config".to_string(),
2286                trait_: None,
2287                where_clause: None,
2288                methods: vec![
2289                    RustFunction::test("new")
2290                        .with_doc("Create a new Config with default value")
2291                        .with_signature("pub fn new(name: impl Into<String>) -> Self"),
2292                    RustFunction::test("with_value")
2293                        .with_doc("Set the configuration value")
2294                        .with_signature("pub fn with_value(mut self, value: T) -> Self"),
2295                ],
2296                pymethods: false,
2297                source: SourceSpan::test("test.rs", 1, 20),
2298            }));
2299
2300        let pages = module_renderer.render_rust_module(&module).unwrap();
2301        assert_snapshot!("rust_struct_with_fields", &pages[0].content);
2302    }
2303
2304    #[test]
2305    fn snapshot_rust_enum_with_variants() {
2306        let renderer = test_renderer();
2307        let module_renderer = ModuleRenderer::new(&renderer);
2308
2309        let module = RustModule::test("crate::types").with_item(RustItem::Enum(RustEnum {
2310            name: "Result".to_string(),
2311            visibility: Visibility::Public,
2312            doc_comment: Some(
2313                "Operation result type.\n\nRepresents success or failure of an operation."
2314                    .to_string(),
2315            ),
2316            parsed_doc: None,
2317            generics: Some("<T, E>".to_string()),
2318            variants: vec![
2319                RustVariant {
2320                    name: "Ok".to_string(),
2321                    doc_comment: Some("Operation succeeded with value".to_string()),
2322                    fields: vec![RustField::test("0", "T")],
2323                },
2324                RustVariant {
2325                    name: "Err".to_string(),
2326                    doc_comment: Some("Operation failed with error".to_string()),
2327                    fields: vec![RustField::test("0", "E")],
2328                },
2329            ],
2330            source: SourceSpan::test("test.rs", 1, 15),
2331        }));
2332
2333        let pages = module_renderer.render_rust_module(&module).unwrap();
2334        assert_snapshot!("rust_enum_with_variants", &pages[0].content);
2335    }
2336
2337    #[test]
2338    fn snapshot_rust_function_with_generics() {
2339        let renderer = test_renderer();
2340        let module_renderer = ModuleRenderer::new(&renderer);
2341
2342        let module = RustModule::test("crate::convert")
2343            .with_item(RustItem::Function(
2344                RustFunction::test("transform")
2345                    .with_doc("Transform input to output.\n\n# Arguments\n\n* `input` - The input value\n* `mapper` - Transformation function\n\n# Returns\n\nThe transformed value")
2346                    .with_generics("<T, U>")
2347                    .with_signature("pub fn transform<T, U>(input: T, mapper: impl Fn(T) -> U) -> U"),
2348            ));
2349
2350        let pages = module_renderer.render_rust_module(&module).unwrap();
2351        assert_snapshot!("rust_function_with_generics", &pages[0].content);
2352    }
2353
2354    #[test]
2355    fn snapshot_rust_async_unsafe_function() {
2356        let renderer = test_renderer();
2357        let module_renderer = ModuleRenderer::new(&renderer);
2358
2359        let module = RustModule::test("crate::low_level")
2360            .with_item(RustItem::Function(
2361                RustFunction::test("dangerous_read")
2362                    .async_()
2363                    .unsafe_()
2364                    .with_doc("Read from raw pointer asynchronously.\n\n# Safety\n\nPointer must be valid and properly aligned.")
2365                    .with_signature("pub async unsafe fn dangerous_read(ptr: *const u8, len: usize) -> Vec<u8>"),
2366            ));
2367
2368        let pages = module_renderer.render_rust_module(&module).unwrap();
2369        assert_snapshot!("rust_async_unsafe_function", &pages[0].content);
2370    }
2371
2372    #[test]
2373    fn snapshot_rust_pyclass_struct() {
2374        let renderer = test_renderer();
2375
2376        let cross_refs = vec![CrossRef {
2377            python_path: "native.Buffer".to_string(),
2378            rust_path: "crate::buffer::RustBuffer".to_string(),
2379            relationship: CrossRefKind::Binding,
2380        }];
2381
2382        let module_renderer = ModuleRenderer::with_cross_refs(&renderer, cross_refs);
2383
2384        let module = RustModule::test("crate::buffer")
2385            .with_item(RustItem::Struct(
2386                RustStruct::test("RustBuffer")
2387                    .with_doc(
2388                        "High-performance buffer for Python.\n\nExposed to Python as `Buffer`.",
2389                    )
2390                    .with_pyclass(PyClassMeta::new().with_name("Buffer").with_module("native"))
2391                    .with_field(
2392                        RustField::test("data", "Vec<u8>").with_doc("Internal data storage"),
2393                    ),
2394            ))
2395            .with_item(RustItem::Impl(RustImpl {
2396                generics: None,
2397                target: "RustBuffer".to_string(),
2398                trait_: None,
2399                where_clause: None,
2400                methods: vec![
2401                    RustFunction::test("new")
2402                        .with_doc("Create a new empty buffer")
2403                        .with_signature("#[new]\npub fn new() -> Self"),
2404                    RustFunction::test("len")
2405                        .with_doc("Get buffer length")
2406                        .with_signature("pub fn len(&self) -> usize"),
2407                ],
2408                pymethods: true,
2409                source: SourceSpan::test("test.rs", 1, 20),
2410            }));
2411
2412        let pages = module_renderer.render_rust_module(&module).unwrap();
2413        assert_snapshot!("rust_pyclass_struct", &pages[0].content);
2414    }
2415
2416    #[test]
2417    fn snapshot_rust_pyfunction() {
2418        let renderer = test_renderer();
2419
2420        let cross_refs = vec![CrossRef {
2421            python_path: "native.compute".to_string(),
2422            rust_path: "crate::compute::compute".to_string(),
2423            relationship: CrossRefKind::Binding,
2424        }];
2425
2426        let module_renderer = ModuleRenderer::with_cross_refs(&renderer, cross_refs);
2427
2428        let module = RustModule::test("crate::compute").with_item(RustItem::Function(
2429            RustFunction::test("compute")
2430                .with_doc("Compute result from input.\n\nExposed to Python as `compute()`.")
2431                .with_pyfunction(PyFunctionMeta::new().with_name("compute"))
2432                .with_signature("#[pyfunction]\npub fn compute(value: i64) -> i64"),
2433        ));
2434
2435        let pages = module_renderer.render_rust_module(&module).unwrap();
2436        assert_snapshot!("rust_pyfunction", &pages[0].content);
2437    }
2438
2439    // =========================================================================
2440    // Complex Module Snapshots
2441    // =========================================================================
2442
2443    #[test]
2444    fn snapshot_complex_python_module() {
2445        let renderer = test_renderer();
2446        let module_renderer = ModuleRenderer::new(&renderer);
2447
2448        let module = PythonModule::test("mypackage.api")
2449            .with_docstring("API module for external interactions.\n\nProvides client classes and utility functions.")
2450            .with_item(PythonItem::Variable(
2451                PythonVariable::test("API_VERSION").with_type("str").with_value("\"v2\"").with_docstring("Current API version"),
2452            ))
2453            .with_item(PythonItem::Class(
2454                PythonClass::test("Client")
2455                    .with_docstring("HTTP client for API requests.\n\nHandles authentication and rate limiting.")
2456                    .with_attribute(PythonVariable::test("base_url").with_type("str"))
2457                    .with_attribute(PythonVariable::test("timeout").with_type("float"))
2458                    .with_method(
2459                        PythonFunction::test("__init__")
2460                            .with_param(PythonParam::test("self"))
2461                            .with_param(PythonParam::test("base_url").with_type("str"))
2462                            .with_param(PythonParam::test("timeout").with_type("float").with_default("30.0")),
2463                    )
2464                    .with_method(
2465                        PythonFunction::test("get")
2466                            .async_()
2467                            .with_docstring("Perform GET request")
2468                            .with_param(PythonParam::test("self"))
2469                            .with_param(PythonParam::test("path").with_type("str"))
2470                            .with_return_type("Response"),
2471                    )
2472                    .with_method(
2473                        PythonFunction::test("post")
2474                            .async_()
2475                            .with_docstring("Perform POST request")
2476                            .with_param(PythonParam::test("self"))
2477                            .with_param(PythonParam::test("path").with_type("str"))
2478                            .with_param(PythonParam::test("data").with_type("dict"))
2479                            .with_return_type("Response"),
2480                    ),
2481            ))
2482            .with_item(PythonItem::Function(
2483                PythonFunction::test("create_client")
2484                    .with_docstring("Factory function to create a configured client")
2485                    .with_param(PythonParam::test("config").with_type("Config"))
2486                    .with_return_type("Client"),
2487            ));
2488
2489        let pages = module_renderer.render_python_module(&module).unwrap();
2490        assert_snapshot!("complex_python_module", &pages[0].content);
2491    }
2492
2493    #[test]
2494    fn snapshot_complex_rust_module() {
2495        let renderer = test_renderer();
2496        let module_renderer = ModuleRenderer::new(&renderer);
2497
2498        let module = RustModule::test("crate::engine")
2499            .with_doc("Core processing engine.\n\nProvides the main computation pipeline.")
2500            .with_item(RustItem::Struct(
2501                RustStruct::test("Engine")
2502                    .with_doc("Main processing engine")
2503                    .with_generics("<T: Process>")
2504                    .with_field(RustField::test("state", "State").with_doc("Current engine state"))
2505                    .with_field(RustField::test("processor", "T").with_doc("Item processor"))
2506                    .with_derive("Debug"),
2507            ))
2508            .with_item(RustItem::Impl(RustImpl {
2509                generics: Some("<T: Process>".to_string()),
2510                target: "Engine".to_string(),
2511                trait_: None,
2512                where_clause: None,
2513                methods: vec![
2514                    RustFunction::test("new")
2515                        .with_doc("Create new engine")
2516                        .with_signature("pub fn new(processor: T) -> Self"),
2517                    RustFunction::test("run")
2518                        .async_()
2519                        .with_doc("Run the engine")
2520                        .with_signature("pub async fn run(&mut self) -> Result<(), Error>"),
2521                ],
2522                pymethods: false,
2523                source: SourceSpan::test("test.rs", 1, 30),
2524            }))
2525            .with_item(RustItem::Enum(RustEnum {
2526                name: "State".to_string(),
2527                visibility: Visibility::Public,
2528                doc_comment: Some("Engine state".to_string()),
2529                parsed_doc: None,
2530                generics: None,
2531                variants: vec![
2532                    RustVariant {
2533                        name: "Idle".to_string(),
2534                        doc_comment: Some("Engine is idle".to_string()),
2535                        fields: vec![],
2536                    },
2537                    RustVariant {
2538                        name: "Running".to_string(),
2539                        doc_comment: Some("Engine is running".to_string()),
2540                        fields: vec![],
2541                    },
2542                    RustVariant {
2543                        name: "Error".to_string(),
2544                        doc_comment: Some("Engine encountered an error".to_string()),
2545                        fields: vec![RustField::test("message", "String")],
2546                    },
2547                ],
2548                source: SourceSpan::test("test.rs", 31, 45),
2549            }))
2550            .with_item(RustItem::Function(
2551                RustFunction::test("default_engine")
2552                    .with_doc("Create engine with default processor")
2553                    .with_signature("pub fn default_engine() -> Engine<DefaultProcessor>"),
2554            ));
2555
2556        let pages = module_renderer.render_rust_module(&module).unwrap();
2557        assert_snapshot!("complex_rust_module", &pages[0].content);
2558    }
2559}