syncdoc_migrate/
write.rs

1//! List all the files we expect to be produced from code with omnidoc attributes.
2
3use crate::discover::ParsedFile;
4use crate::extract::extract_doc_content;
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8use syncdoc_core::parse::{
9    EnumSig, EnumVariantData, ImplBlockSig, ModuleItem, ModuleSig, StructSig, TraitSig,
10};
11
12mod expected;
13pub use expected::find_expected_doc_paths;
14
15/// Represents a documentation extraction with its target path and metadata
16#[derive(Debug, Clone, PartialEq)]
17pub struct DocExtraction {
18    /// Path where the markdown file should be written
19    pub markdown_path: PathBuf,
20    /// The documentation content to write
21    pub content: String,
22    /// Source location in file:line format
23    pub source_location: String,
24}
25
26impl DocExtraction {
27    /// Creates a new DocExtraction and ensures content ends with a newline
28    pub fn new(markdown_path: PathBuf, mut content: String, source_location: String) -> Self {
29        if !content.ends_with('\n') {
30            content.push('\n');
31        }
32        Self {
33            markdown_path,
34            content,
35            source_location,
36        }
37    }
38}
39
40/// Report of write operation results
41#[derive(Debug, Default)]
42pub struct WriteReport {
43    pub files_written: usize,
44    pub files_skipped: usize,
45    pub errors: Vec<String>,
46}
47
48/// Extracts all documentation from a parsed file
49///
50/// Returns a vector of `DocExtraction` structs, each representing a documentation
51/// comment that should be written to a markdown file.
52pub fn extract_all_docs(parsed: &ParsedFile, docs_root: &str) -> Vec<DocExtraction> {
53    let mut extractions = Vec::new();
54
55    // Extract module path from the source file
56    let module_path = syncdoc_core::path_utils::extract_module_path(&parsed.path.to_string_lossy());
57
58    // Extract module-level (inner) documentation
59    if let Some(inner_doc) = crate::extract::extract_inner_doc_content(&parsed.content.inner_attrs)
60    {
61        // For lib.rs -> docs/lib.md, for main.rs -> docs/main.md, etc.
62        let file_stem = parsed
63            .path
64            .file_stem()
65            .and_then(|s| s.to_str())
66            .unwrap_or("module");
67
68        let path = if module_path.is_empty() {
69            format!("{}/{}.md", docs_root, file_stem)
70        } else {
71            format!("{}/{}.md", docs_root, module_path)
72        };
73
74        extractions.push(DocExtraction::new(
75            PathBuf::from(path),
76            inner_doc,
77            format!("{}:1", parsed.path.display()),
78        ));
79    }
80
81    // Start context with module path if not empty
82    let mut context = Vec::new();
83    if !module_path.is_empty() {
84        context.push(module_path);
85    }
86
87    for item_delimited in &parsed.content.items.0 {
88        let item = &item_delimited.value;
89        extractions.extend(extract_item_docs(
90            item,
91            context.clone(),
92            docs_root,
93            &parsed.path,
94        ));
95    }
96
97    extractions
98}
99
100/// Recursively extracts documentation from a single module item
101fn extract_item_docs(
102    item: &ModuleItem,
103    context: Vec<String>,
104    base_path: &str,
105    source_file: &Path,
106) -> Vec<DocExtraction> {
107    let mut extractions = Vec::new();
108
109    match item {
110        ModuleItem::Function(func_sig) => {
111            if let Some(content) = extract_doc_content(&func_sig.attributes) {
112                let path = build_path(base_path, &context, &func_sig.name.to_string());
113                let location = format!(
114                    "{}:{}",
115                    source_file.display(),
116                    func_sig.name.span().start().line
117                );
118                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
119            }
120        }
121
122        ModuleItem::ImplBlock(impl_block) => {
123            extractions.extend(extract_impl_docs(
124                impl_block,
125                context,
126                base_path,
127                source_file,
128            ));
129        }
130
131        ModuleItem::Module(module) => {
132            extractions.extend(extract_module_docs(module, context, base_path, source_file));
133        }
134
135        ModuleItem::Trait(trait_def) => {
136            extractions.extend(extract_trait_docs(
137                trait_def,
138                context,
139                base_path,
140                source_file,
141            ));
142        }
143
144        ModuleItem::Enum(enum_sig) => {
145            extractions.extend(extract_enum_docs(enum_sig, context, base_path, source_file));
146        }
147
148        ModuleItem::Struct(struct_sig) => {
149            extractions.extend(extract_struct_docs(
150                struct_sig,
151                context,
152                base_path,
153                source_file,
154            ));
155        }
156
157        ModuleItem::TypeAlias(type_alias) => {
158            if let Some(content) = extract_doc_content(&type_alias.attributes) {
159                let path = build_path(base_path, &context, &type_alias.name.to_string());
160                let location = format!(
161                    "{}:{}",
162                    source_file.display(),
163                    type_alias.name.span().start().line
164                );
165                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
166            }
167        }
168
169        ModuleItem::Const(const_sig) => {
170            if let Some(content) = extract_doc_content(&const_sig.attributes) {
171                let path = build_path(base_path, &context, &const_sig.name.to_string());
172                let location = format!(
173                    "{}:{}",
174                    source_file.display(),
175                    const_sig.name.span().start().line
176                );
177                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
178            }
179        }
180
181        ModuleItem::Static(static_sig) => {
182            if let Some(content) = extract_doc_content(&static_sig.attributes) {
183                let path = build_path(base_path, &context, &static_sig.name.to_string());
184                let location = format!(
185                    "{}:{}",
186                    source_file.display(),
187                    static_sig.name.span().start().line
188                );
189                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
190            }
191        }
192
193        // No documentation to extract from other items
194        ModuleItem::Other(_) => {}
195    }
196
197    extractions
198}
199
200/// Extracts documentation from an impl block and its methods
201fn extract_impl_docs(
202    impl_block: &ImplBlockSig,
203    context: Vec<String>,
204    base_path: &str,
205    source_file: &Path,
206) -> Vec<DocExtraction> {
207    let mut extractions = Vec::new();
208
209    // Determine the context path for the impl block
210    // If this is `impl Trait for Type`, context is [Type, Trait]
211    // If this is `impl Type`, context is [Type]
212    let impl_context = if let Some(for_trait) = &impl_block.for_trait {
213        // This is `impl Trait for Type`
214        // target_type contains the TRAIT name (before "for")
215        let trait_name = if let Some(first) = impl_block.target_type.0.first() {
216            if let proc_macro2::TokenTree::Ident(ident) = &first.value.second {
217                ident.to_string()
218            } else {
219                "Unknown".to_string()
220            }
221        } else {
222            "Unknown".to_string()
223        };
224
225        // for_trait.second contains the TYPE name (after "for")
226        let type_name = if let Some(first) = for_trait.second.0.first() {
227            if let proc_macro2::TokenTree::Ident(ident) = &first.value.second {
228                ident.to_string()
229            } else {
230                "Unknown".to_string()
231            }
232        } else {
233            "Unknown".to_string()
234        };
235
236        // Context is Type/Trait
237        vec![type_name, trait_name]
238    } else {
239        // This is `impl Type`, extract Type from target_type
240        let type_name = if let Some(first) = impl_block.target_type.0.first() {
241            if let proc_macro2::TokenTree::Ident(ident) = &first.value.second {
242                ident.to_string()
243            } else {
244                "Unknown".to_string()
245            }
246        } else {
247            "Unknown".to_string()
248        };
249        vec![type_name]
250    };
251
252    let mut new_context = context;
253    new_context.extend(impl_context);
254
255    // Access parsed items directly
256    let module_content = &impl_block.items.content;
257    for item_delimited in &module_content.items.0 {
258        extractions.extend(extract_item_docs(
259            &item_delimited.value,
260            new_context.clone(),
261            base_path,
262            source_file,
263        ));
264    }
265
266    extractions
267}
268
269/// Extracts documentation from a module and its contents
270fn extract_module_docs(
271    module: &ModuleSig,
272    context: Vec<String>,
273    base_path: &str,
274    source_file: &Path,
275) -> Vec<DocExtraction> {
276    let mut extractions = Vec::new();
277
278    // Extract module's own documentation if present
279    if let Some(content) = extract_doc_content(&module.attributes) {
280        let path = build_path(base_path, &context, &module.name.to_string());
281        let location = format!(
282            "{}:{}",
283            source_file.display(),
284            module.name.span().start().line
285        );
286        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
287    }
288
289    // Update context with module name
290    let mut new_context = context;
291    new_context.push(module.name.to_string());
292
293    // Access parsed items directly
294    let module_content = &module.items.content;
295    for item_delimited in &module_content.items.0 {
296        extractions.extend(extract_item_docs(
297            &item_delimited.value,
298            new_context.clone(),
299            base_path,
300            source_file,
301        ));
302    }
303
304    extractions
305}
306
307/// Extracts documentation from a trait and its methods
308fn extract_trait_docs(
309    trait_def: &TraitSig,
310    context: Vec<String>,
311    base_path: &str,
312    source_file: &Path,
313) -> Vec<DocExtraction> {
314    let mut extractions = Vec::new();
315
316    // Extract trait's own documentation if present
317    if let Some(content) = extract_doc_content(&trait_def.attributes) {
318        let path = build_path(base_path, &context, &trait_def.name.to_string());
319        let location = format!(
320            "{}:{}",
321            source_file.display(),
322            trait_def.name.span().start().line
323        );
324        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
325    }
326
327    // Update context with trait name
328    let mut new_context = context;
329    new_context.push(trait_def.name.to_string());
330
331    // Access parsed items directly
332    let module_content = &trait_def.items.content;
333    for item_delimited in &module_content.items.0 {
334        extractions.extend(extract_item_docs(
335            &item_delimited.value,
336            new_context.clone(),
337            base_path,
338            source_file,
339        ));
340    }
341
342    extractions
343}
344
345/// Extracts documentation from an enum and its variants
346fn extract_enum_docs(
347    enum_sig: &EnumSig,
348    context: Vec<String>,
349    base_path: &str,
350    source_file: &Path,
351) -> Vec<DocExtraction> {
352    let mut extractions = Vec::new();
353    let enum_name = enum_sig.name.to_string();
354
355    // Extract enum's own documentation
356    if let Some(content) = extract_doc_content(&enum_sig.attributes) {
357        let path = build_path(base_path, &context, &enum_name);
358        let location = format!(
359            "{}:{}",
360            source_file.display(),
361            enum_sig.name.span().start().line
362        );
363        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
364    }
365
366    // Access parsed variants directly
367    if let Some(variants_cdv) = enum_sig.variants.content.as_ref() {
368        for variant_delimited in &variants_cdv.0 {
369            let variant = &variant_delimited.value;
370            if let Some(content) = extract_doc_content(&variant.attributes) {
371                let path = build_path(
372                    base_path,
373                    &context,
374                    &format!("{}/{}", enum_name, variant.name),
375                );
376                extractions.push(DocExtraction::new(
377                    PathBuf::from(path),
378                    content,
379                    format!(
380                        "{}:{}",
381                        source_file.display(),
382                        variant.name.span().start().line
383                    ),
384                ));
385            }
386
387            // Handle struct variant fields (Issue #34!)
388            if let Some(EnumVariantData::Struct(fields_containing)) = &variant.data {
389                if let Some(fields_cdv) = fields_containing.content.as_ref() {
390                    for field_delimited in &fields_cdv.0 {
391                        let field = &field_delimited.value;
392                        if let Some(content) = extract_doc_content(&field.attributes) {
393                            let path = build_path(
394                                base_path,
395                                &context,
396                                &format!("{}/{}/{}", enum_name, variant.name, field.name),
397                            );
398                            extractions.push(DocExtraction::new(
399                                PathBuf::from(path),
400                                content,
401                                format!(
402                                    "{}:{}",
403                                    source_file.display(),
404                                    field.name.span().start().line
405                                ),
406                            ));
407                        }
408                    }
409                }
410            }
411        }
412    }
413
414    extractions
415}
416
417/// Extracts documentation from a struct and its fields
418fn extract_struct_docs(
419    struct_sig: &StructSig,
420    context: Vec<String>,
421    base_path: &str,
422    source_file: &Path,
423) -> Vec<DocExtraction> {
424    let mut extractions = Vec::new();
425    let struct_name = struct_sig.name.to_string();
426
427    // Extract struct's own documentation
428    if let Some(content) = extract_doc_content(&struct_sig.attributes) {
429        let path = build_path(base_path, &context, &struct_name);
430        let location = format!(
431            "{}:{}",
432            source_file.display(),
433            struct_sig.name.span().start().line
434        );
435        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
436    }
437
438    // Extract field documentation (only for named fields)
439    if let syncdoc_core::parse::StructBody::Named(fields_containing) = &struct_sig.body {
440        if let Some(fields_cdv) = fields_containing.content.as_ref() {
441            for field_delimited in &fields_cdv.0 {
442                let field = &field_delimited.value;
443                if let Some(content) = extract_doc_content(&field.attributes) {
444                    let path = build_path(
445                        base_path,
446                        &context,
447                        &format!("{}/{}", struct_name, field.name),
448                    );
449                    extractions.push(DocExtraction::new(
450                        PathBuf::from(path),
451                        content,
452                        format!(
453                            "{}:{}",
454                            source_file.display(),
455                            field.name.span().start().line
456                        ),
457                    ));
458                }
459            }
460        }
461    }
462
463    extractions
464}
465
466/// Writes documentation extractions to markdown files
467///
468/// If `dry_run` is true, validates paths and reports what would be written
469/// without actually creating files.
470pub fn write_extractions(
471    extractions: &[DocExtraction],
472    dry_run: bool,
473) -> std::io::Result<WriteReport> {
474    let mut report = WriteReport::default();
475
476    // Group by parent directory for efficient directory creation
477    let mut dirs: HashMap<PathBuf, Vec<&DocExtraction>> = HashMap::new();
478    for extraction in extractions {
479        if let Some(parent) = extraction.markdown_path.parent() {
480            dirs.entry(parent.to_path_buf())
481                .or_default()
482                .push(extraction);
483        }
484    }
485
486    // Create directories
487    for dir in dirs.keys() {
488        if !dry_run {
489            if let Err(e) = fs::create_dir_all(dir) {
490                report.errors.push(format!(
491                    "Failed to create directory {}: {}",
492                    dir.display(),
493                    e
494                ));
495                continue;
496            }
497        }
498    }
499
500    // Write files
501    for extraction in extractions {
502        if dry_run {
503            println!("Would write: {}", extraction.markdown_path.display());
504            report.files_written += 1;
505        } else {
506            match fs::write(&extraction.markdown_path, &extraction.content) {
507                Ok(_) => {
508                    report.files_written += 1;
509                }
510                Err(e) => {
511                    report.errors.push(format!(
512                        "Failed to write {}: {}",
513                        extraction.markdown_path.display(),
514                        e
515                    ));
516                    report.files_skipped += 1;
517                }
518            }
519        }
520    }
521
522    Ok(report)
523}
524
525// Helper functions
526
527fn build_path(base_path: &str, context: &[String], item_name: &str) -> String {
528    let mut parts = vec![base_path.to_string()];
529    parts.extend(context.iter().cloned());
530    parts.push(format!("{}.md", item_name));
531    parts.join("/")
532}
533
534#[cfg(test)]
535mod tests;