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;
7pub(crate) use std::path::{Path, PathBuf};
8use syncdoc_core::parse::{
9    EnumSig, EnumVariantData, ImplBlockSig, ModuleItem, ModuleSig, StructSig, TraitSig,
10};
11
12pub(crate) mod 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
101pub(crate) fn 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::TraitMethod(method_sig) => {
111            if let Some(content) = extract_doc_content(&method_sig.attributes) {
112                let path = build_path(base_path, &context, &method_sig.name.to_string());
113                let location = format!(
114                    "{}:{}",
115                    source_file.display(),
116                    method_sig.name.span().start().line
117                );
118                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
119            }
120        }
121
122        ModuleItem::Function(func_sig) => {
123            if let Some(content) = extract_doc_content(&func_sig.attributes) {
124                let path = build_path(base_path, &context, &func_sig.name.to_string());
125                let location = format!(
126                    "{}:{}",
127                    source_file.display(),
128                    func_sig.name.span().start().line
129                );
130                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
131            }
132        }
133
134        ModuleItem::ImplBlock(impl_block) => {
135            extractions.extend(extract_impl_docs(
136                impl_block,
137                context,
138                base_path,
139                source_file,
140            ));
141        }
142
143        ModuleItem::Module(module) => {
144            extractions.extend(extract_module_docs(module, context, base_path, source_file));
145        }
146
147        ModuleItem::Trait(trait_def) => {
148            extractions.extend(extract_trait_docs(
149                trait_def,
150                context,
151                base_path,
152                source_file,
153            ));
154        }
155
156        ModuleItem::Enum(enum_sig) => {
157            extractions.extend(extract_enum_docs(enum_sig, context, base_path, source_file));
158        }
159
160        ModuleItem::Struct(struct_sig) => {
161            extractions.extend(extract_struct_docs(
162                struct_sig,
163                context,
164                base_path,
165                source_file,
166            ));
167        }
168
169        ModuleItem::TypeAlias(type_alias) => {
170            if let Some(content) = extract_doc_content(&type_alias.attributes) {
171                let path = build_path(base_path, &context, &type_alias.name.to_string());
172                let location = format!(
173                    "{}:{}",
174                    source_file.display(),
175                    type_alias.name.span().start().line
176                );
177                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
178            }
179        }
180
181        ModuleItem::Const(const_sig) => {
182            if let Some(content) = extract_doc_content(&const_sig.attributes) {
183                let path = build_path(base_path, &context, &const_sig.name.to_string());
184                let location = format!(
185                    "{}:{}",
186                    source_file.display(),
187                    const_sig.name.span().start().line
188                );
189                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
190            }
191        }
192
193        ModuleItem::Static(static_sig) => {
194            if let Some(content) = extract_doc_content(&static_sig.attributes) {
195                let path = build_path(base_path, &context, &static_sig.name.to_string());
196                let location = format!(
197                    "{}:{}",
198                    source_file.display(),
199                    static_sig.name.span().start().line
200                );
201                extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
202            }
203        }
204
205        // No documentation to extract from other items
206        ModuleItem::Other(_) => {}
207    }
208
209    extractions
210}
211
212/// Extracts documentation from an impl block and its methods
213pub(crate) fn extract_impl_docs(
214    impl_block: &ImplBlockSig,
215    context: Vec<String>,
216    base_path: &str,
217    source_file: &Path,
218) -> Vec<DocExtraction> {
219    let mut extractions = Vec::new();
220
221    // Determine the context path for the impl block
222    // If this is `impl Trait for Type`, context is [Type, Trait]
223    // If this is `impl Type`, context is [Type]
224    let impl_context = if let Some(for_trait) = &impl_block.for_trait {
225        // This is `impl Trait for Type`
226        // target_type contains the TRAIT name (before "for")
227        let trait_name = if let Some(first) = impl_block.target_type.0.first() {
228            if let proc_macro2::TokenTree::Ident(ident) = &first.value.second {
229                ident.to_string()
230            } else {
231                "Unknown".to_string()
232            }
233        } else {
234            "Unknown".to_string()
235        };
236
237        // for_trait.second contains the TYPE name (after "for")
238        let type_name = if let Some(first) = for_trait.second.0.first() {
239            if let proc_macro2::TokenTree::Ident(ident) = &first.value.second {
240                ident.to_string()
241            } else {
242                "Unknown".to_string()
243            }
244        } else {
245            "Unknown".to_string()
246        };
247
248        // Context is Type/Trait
249        vec![type_name, trait_name]
250    } else {
251        // This is `impl Type`, extract Type from target_type
252        let type_name = if let Some(first) = impl_block.target_type.0.first() {
253            if let proc_macro2::TokenTree::Ident(ident) = &first.value.second {
254                ident.to_string()
255            } else {
256                "Unknown".to_string()
257            }
258        } else {
259            "Unknown".to_string()
260        };
261        vec![type_name]
262    };
263
264    let mut new_context = context;
265    new_context.extend(impl_context);
266
267    // Access parsed items directly
268    let module_content = &impl_block.items.content;
269    for item_delimited in &module_content.items.0 {
270        extractions.extend(extract_item_docs(
271            &item_delimited.value,
272            new_context.clone(),
273            base_path,
274            source_file,
275        ));
276    }
277
278    extractions
279}
280
281/// Extracts documentation from a module and its contents
282pub(crate) fn extract_module_docs(
283    module: &ModuleSig,
284    context: Vec<String>,
285    base_path: &str,
286    source_file: &Path,
287) -> Vec<DocExtraction> {
288    let mut extractions = Vec::new();
289
290    // Extract module's own documentation if present
291    if let Some(content) = extract_doc_content(&module.attributes) {
292        let path = build_path(base_path, &context, &module.name.to_string());
293        let location = format!(
294            "{}:{}",
295            source_file.display(),
296            module.name.span().start().line
297        );
298        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
299    }
300
301    // Update context with module name
302    let mut new_context = context;
303    new_context.push(module.name.to_string());
304
305    // Access parsed items directly
306    let module_content = &module.items.content;
307    for item_delimited in &module_content.items.0 {
308        extractions.extend(extract_item_docs(
309            &item_delimited.value,
310            new_context.clone(),
311            base_path,
312            source_file,
313        ));
314    }
315
316    extractions
317}
318
319/// Extracts documentation from a trait and its methods
320pub(crate) fn extract_trait_docs(
321    trait_def: &TraitSig,
322    context: Vec<String>,
323    base_path: &str,
324    source_file: &Path,
325) -> Vec<DocExtraction> {
326    let mut extractions = Vec::new();
327
328    // Extract trait's own documentation if present
329    if let Some(content) = extract_doc_content(&trait_def.attributes) {
330        let path = build_path(base_path, &context, &trait_def.name.to_string());
331        let location = format!(
332            "{}:{}",
333            source_file.display(),
334            trait_def.name.span().start().line
335        );
336        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
337    }
338
339    // Update context with trait name
340    let mut new_context = context;
341    new_context.push(trait_def.name.to_string());
342
343    // Access parsed items directly
344    let module_content = &trait_def.items.content;
345    for item_delimited in &module_content.items.0 {
346        extractions.extend(extract_item_docs(
347            &item_delimited.value,
348            new_context.clone(),
349            base_path,
350            source_file,
351        ));
352    }
353
354    extractions
355}
356
357/// Extracts documentation from an enum and its variants
358pub(crate) fn extract_enum_docs(
359    enum_sig: &EnumSig,
360    context: Vec<String>,
361    base_path: &str,
362    source_file: &Path,
363) -> Vec<DocExtraction> {
364    let mut extractions = Vec::new();
365    let enum_name = enum_sig.name.to_string();
366
367    // Extract enum's own documentation
368    if let Some(content) = extract_doc_content(&enum_sig.attributes) {
369        let path = build_path(base_path, &context, &enum_name);
370        let location = format!(
371            "{}:{}",
372            source_file.display(),
373            enum_sig.name.span().start().line
374        );
375        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
376    }
377
378    // Access parsed variants directly
379    if let Some(variants_cdv) = enum_sig.variants.content.as_ref() {
380        for variant_delimited in &variants_cdv.0 {
381            let variant = &variant_delimited.value;
382            if let Some(content) = extract_doc_content(&variant.attributes) {
383                let path = build_path(
384                    base_path,
385                    &context,
386                    &format!("{}/{}", enum_name, variant.name),
387                );
388                extractions.push(DocExtraction::new(
389                    PathBuf::from(path),
390                    content,
391                    format!(
392                        "{}:{}",
393                        source_file.display(),
394                        variant.name.span().start().line
395                    ),
396                ));
397            }
398
399            // Handle struct variant fields (Issue #34!)
400            if let Some(EnumVariantData::Struct(fields_containing)) = &variant.data {
401                if let Some(fields_cdv) = fields_containing.content.as_ref() {
402                    for field_delimited in &fields_cdv.0 {
403                        let field = &field_delimited.value;
404                        if let Some(content) = extract_doc_content(&field.attributes) {
405                            let path = build_path(
406                                base_path,
407                                &context,
408                                &format!("{}/{}/{}", enum_name, variant.name, field.name),
409                            );
410                            extractions.push(DocExtraction::new(
411                                PathBuf::from(path),
412                                content,
413                                format!(
414                                    "{}:{}",
415                                    source_file.display(),
416                                    field.name.span().start().line
417                                ),
418                            ));
419                        }
420                    }
421                }
422            }
423        }
424    }
425
426    extractions
427}
428
429/// Extracts documentation from a struct and its fields
430pub(crate) fn extract_struct_docs(
431    struct_sig: &StructSig,
432    context: Vec<String>,
433    base_path: &str,
434    source_file: &Path,
435) -> Vec<DocExtraction> {
436    let mut extractions = Vec::new();
437    let struct_name = struct_sig.name.to_string();
438
439    // Extract struct's own documentation
440    if let Some(content) = extract_doc_content(&struct_sig.attributes) {
441        let path = build_path(base_path, &context, &struct_name);
442        let location = format!(
443            "{}:{}",
444            source_file.display(),
445            struct_sig.name.span().start().line
446        );
447        extractions.push(DocExtraction::new(PathBuf::from(path), content, location));
448    }
449
450    // Extract field documentation (only for named fields)
451    if let syncdoc_core::parse::StructBody::Named(fields_containing) = &struct_sig.body {
452        if let Some(fields_cdv) = fields_containing.content.as_ref() {
453            for field_delimited in &fields_cdv.0 {
454                let field = &field_delimited.value;
455                if let Some(content) = extract_doc_content(&field.attributes) {
456                    let path = build_path(
457                        base_path,
458                        &context,
459                        &format!("{}/{}", struct_name, field.name),
460                    );
461                    extractions.push(DocExtraction::new(
462                        PathBuf::from(path),
463                        content,
464                        format!(
465                            "{}:{}",
466                            source_file.display(),
467                            field.name.span().start().line
468                        ),
469                    ));
470                }
471            }
472        }
473    }
474
475    extractions
476}
477
478/// Writes documentation extractions to markdown files
479///
480/// If `dry_run` is true, validates paths and reports what would be written
481/// without actually creating files.
482pub fn write_extractions(
483    extractions: &[DocExtraction],
484    dry_run: bool,
485) -> std::io::Result<WriteReport> {
486    let mut report = WriteReport::default();
487
488    // Group by parent directory for efficient directory creation
489    let mut dirs: HashMap<PathBuf, Vec<&DocExtraction>> = HashMap::new();
490    for extraction in extractions {
491        if let Some(parent) = extraction.markdown_path.parent() {
492            dirs.entry(parent.to_path_buf())
493                .or_default()
494                .push(extraction);
495        }
496    }
497
498    // Create directories
499    for dir in dirs.keys() {
500        if !dry_run {
501            if let Err(e) = fs::create_dir_all(dir) {
502                report.errors.push(format!(
503                    "Failed to create directory {}: {}",
504                    dir.display(),
505                    e
506                ));
507                continue;
508            }
509        }
510    }
511
512    // Write files
513    for extraction in extractions {
514        if dry_run {
515            println!("Would write: {}", extraction.markdown_path.display());
516            report.files_written += 1;
517        } else {
518            match fs::write(&extraction.markdown_path, &extraction.content) {
519                Ok(_) => {
520                    report.files_written += 1;
521                }
522                Err(e) => {
523                    report.errors.push(format!(
524                        "Failed to write {}: {}",
525                        extraction.markdown_path.display(),
526                        e
527                    ));
528                    report.files_skipped += 1;
529                }
530            }
531        }
532    }
533
534    Ok(report)
535}
536
537// Helper functions
538
539pub(crate) fn build_path(base_path: &str, context: &[String], item_name: &str) -> String {
540    let mut parts = vec![base_path.to_string()];
541    parts.extend(context.iter().cloned());
542    parts.push(format!("{}.md", item_name));
543    parts.join("/")
544}