⚠️ VeridianOS Kernel Documentation - This is low-level kernel code. All functions are unsafe unless explicitly marked otherwise. no_std

veridian_kernel/pkg/ports/
rustdoc.rs

1//! Rustdoc Generation on Target
2//!
3//! Native documentation generation for VeridianOS packages. Manages
4//! `cargo doc` invocation, search index construction, theme configuration,
5//! and HTML output path management with conceptual VFS integration.
6//!
7//! Supports single-package, workspace-wide, and cross-referenced
8//! documentation builds with configurable themes and a simple HTTP
9//! serving configuration for local doc browsing.
10
11use alloc::{
12    string::{String, ToString},
13    vec::Vec,
14};
15
16use crate::error::KernelError;
17
18// ---------------------------------------------------------------------------
19// Constants
20// ---------------------------------------------------------------------------
21
22/// Default output directory for generated documentation
23pub const DEFAULT_DOC_OUTPUT: &str = "/usr/share/doc/rust";
24
25/// Default HTTP port for documentation server
26pub const DEFAULT_DOC_SERVER_PORT: u16 = 8080;
27
28/// Maximum number of items in a search index before compaction
29pub const MAX_SEARCH_INDEX_ITEMS: usize = 100_000;
30
31// ---------------------------------------------------------------------------
32// Theme
33// ---------------------------------------------------------------------------
34
35/// Documentation theme variants
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum DocTheme {
38    /// Rustdoc default light theme
39    Default,
40    /// Dark theme (ayu)
41    Dark,
42    /// High-contrast for accessibility
43    HighContrast,
44}
45
46impl DocTheme {
47    /// Return the rustdoc CLI flag value for this theme.
48    pub fn flag_value(self) -> &'static str {
49        match self {
50            Self::Default => "light",
51            Self::Dark => "ayu",
52            Self::HighContrast => "high-contrast",
53        }
54    }
55}
56
57// ---------------------------------------------------------------------------
58// DocItem — a single documented entity
59// ---------------------------------------------------------------------------
60
61/// Kind of a documented item (function, struct, etc.)
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum DocItemKind {
64    Function,
65    Struct,
66    Enum,
67    Trait,
68    Module,
69    Macro,
70    Const,
71    Type,
72}
73
74impl DocItemKind {
75    /// Short label used in search results.
76    pub fn label(self) -> &'static str {
77        match self {
78            Self::Function => "fn",
79            Self::Struct => "struct",
80            Self::Enum => "enum",
81            Self::Trait => "trait",
82            Self::Module => "mod",
83            Self::Macro => "macro",
84            Self::Const => "const",
85            Self::Type => "type",
86        }
87    }
88}
89
90/// A single documented item stored in the search index.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct DocItem {
93    /// Simple name (e.g. `HashMap`)
94    pub name: String,
95    /// Item kind
96    pub kind: DocItemKind,
97    /// Fully-qualified path (e.g. `std::collections::HashMap`)
98    pub path: String,
99    /// One-line description extracted from `///` doc comment
100    pub description: String,
101    /// Whether the item is `pub`
102    pub is_public: bool,
103}
104
105impl DocItem {
106    /// Create a new documented item.
107    pub fn new(
108        name: &str,
109        kind: DocItemKind,
110        path: &str,
111        description: &str,
112        is_public: bool,
113    ) -> Self {
114        Self {
115            name: name.to_string(),
116            kind,
117            path: path.to_string(),
118            description: description.to_string(),
119            is_public,
120        }
121    }
122
123    /// Return the relative HTML file path for this item.
124    ///
125    /// E.g. `std/collections/struct.HashMap.html`
126    pub fn html_path(&self) -> String {
127        let module_path = self.path.replace("::", "/");
128        alloc::format!("{}/{}.{}.html", module_path, self.kind.label(), self.name)
129    }
130}
131
132// ---------------------------------------------------------------------------
133// DocIndex — searchable index of documented items
134// ---------------------------------------------------------------------------
135
136/// Searchable index of all documented items across one or more crates.
137#[derive(Debug, Clone)]
138pub struct DocIndex {
139    items: Vec<DocItem>,
140}
141
142impl Default for DocIndex {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148impl DocIndex {
149    /// Create an empty index.
150    pub fn new() -> Self {
151        Self { items: Vec::new() }
152    }
153
154    /// Add an item to the index.
155    pub fn add(&mut self, item: DocItem) -> Result<(), KernelError> {
156        if self.items.len() >= MAX_SEARCH_INDEX_ITEMS {
157            return Err(KernelError::ResourceExhausted {
158                resource: "doc_search_index",
159            });
160        }
161        self.items.push(item);
162        Ok(())
163    }
164
165    /// Number of indexed items.
166    pub fn len(&self) -> usize {
167        self.items.len()
168    }
169
170    /// Whether the index is empty.
171    pub fn is_empty(&self) -> bool {
172        self.items.is_empty()
173    }
174
175    /// Search by name substring (case-insensitive).
176    pub fn search_by_name(&self, query: &str) -> Vec<&DocItem> {
177        let query_lower = query.to_ascii_lowercase();
178        self.items
179            .iter()
180            .filter(|item| {
181                let name_lower = item.name.to_ascii_lowercase();
182                name_lower.contains(&query_lower)
183            })
184            .collect()
185    }
186
187    /// Search by fully-qualified path prefix.
188    pub fn search_by_path(&self, prefix: &str) -> Vec<&DocItem> {
189        self.items
190            .iter()
191            .filter(|item| item.path.starts_with(prefix))
192            .collect()
193    }
194
195    /// Return counts per item kind.
196    pub fn statistics(&self) -> DocIndexStats {
197        let mut stats = DocIndexStats::default();
198        for item in &self.items {
199            match item.kind {
200                DocItemKind::Function => stats.functions += 1,
201                DocItemKind::Struct => stats.structs += 1,
202                DocItemKind::Enum => stats.enums += 1,
203                DocItemKind::Trait => stats.traits += 1,
204                DocItemKind::Module => stats.modules += 1,
205                DocItemKind::Macro => stats.macros += 1,
206                DocItemKind::Const => stats.consts += 1,
207                DocItemKind::Type => stats.types += 1,
208            }
209        }
210        stats.total = self.items.len();
211        stats
212    }
213}
214
215/// Per-kind counts for a [`DocIndex`].
216#[derive(Debug, Clone, Default, PartialEq, Eq)]
217pub struct DocIndexStats {
218    pub total: usize,
219    pub functions: usize,
220    pub structs: usize,
221    pub enums: usize,
222    pub traits: usize,
223    pub modules: usize,
224    pub macros: usize,
225    pub consts: usize,
226    pub types: usize,
227}
228
229// ---------------------------------------------------------------------------
230// RustdocConfig — what to document
231// ---------------------------------------------------------------------------
232
233/// Configuration for a rustdoc generation run.
234#[derive(Debug, Clone)]
235pub struct RustdocConfig {
236    /// Root path of the source crate or workspace
237    pub source_path: String,
238    /// Output directory for generated HTML
239    pub output_dir: String,
240    /// Documentation theme
241    pub theme: DocTheme,
242    /// Enable cross-reference linking between crates
243    pub cross_references: bool,
244    /// Extra features to pass via `--features`
245    pub features: Vec<String>,
246    /// Extra `--cfg` flags
247    pub cfg_flags: Vec<String>,
248    /// Whether to include private items (`--document-private-items`)
249    pub document_private: bool,
250}
251
252impl Default for RustdocConfig {
253    fn default() -> Self {
254        Self {
255            source_path: String::from("/src"),
256            output_dir: DEFAULT_DOC_OUTPUT.to_string(),
257            theme: DocTheme::Default,
258            cross_references: true,
259            features: Vec::new(),
260            cfg_flags: Vec::new(),
261            document_private: false,
262        }
263    }
264}
265
266impl RustdocConfig {
267    /// Create a config pointing at a specific crate directory.
268    pub fn for_crate(crate_path: &str, output_dir: &str) -> Self {
269        Self {
270            source_path: crate_path.to_string(),
271            output_dir: output_dir.to_string(),
272            ..Self::default()
273        }
274    }
275}
276
277// ---------------------------------------------------------------------------
278// DocServerConfig — simple HTTP serving
279// ---------------------------------------------------------------------------
280
281/// Minimal configuration for serving docs over HTTP.
282#[derive(Debug, Clone)]
283pub struct DocServerConfig {
284    /// Directory containing generated HTML
285    pub doc_root: String,
286    /// Port to listen on
287    pub port: u16,
288    /// Bind address (e.g. `0.0.0.0` or `127.0.0.1`)
289    pub bind_address: String,
290}
291
292impl Default for DocServerConfig {
293    fn default() -> Self {
294        Self {
295            doc_root: DEFAULT_DOC_OUTPUT.to_string(),
296            port: DEFAULT_DOC_SERVER_PORT,
297            bind_address: "127.0.0.1".to_string(),
298        }
299    }
300}
301
302impl DocServerConfig {
303    /// Generate the URL where documentation will be served.
304    pub fn url(&self) -> String {
305        alloc::format!("http://{}:{}", self.bind_address, self.port)
306    }
307}
308
309// ---------------------------------------------------------------------------
310// RustdocBuilder — orchestrates doc generation
311// ---------------------------------------------------------------------------
312
313/// Orchestrates `cargo doc` invocations for packages and workspaces.
314pub struct RustdocBuilder {
315    config: RustdocConfig,
316    index: DocIndex,
317}
318
319impl RustdocBuilder {
320    /// Create a new builder from a configuration.
321    pub fn new(config: RustdocConfig) -> Self {
322        Self {
323            config,
324            index: DocIndex::new(),
325        }
326    }
327
328    /// Access the current configuration.
329    pub fn config(&self) -> &RustdocConfig {
330        &self.config
331    }
332
333    /// Access the documentation index built so far.
334    pub fn index(&self) -> &DocIndex {
335        &self.index
336    }
337
338    /// Set the documentation theme.
339    pub fn configure_theme(&mut self, theme: DocTheme) {
340        self.config.theme = theme;
341    }
342
343    // -- Command generation -------------------------------------------------
344
345    /// Generate the base `cargo doc` command line for the current config.
346    fn base_command(&self) -> String {
347        let mut cmd = String::from("cargo doc --no-deps");
348
349        if self.config.document_private {
350            cmd.push_str(" --document-private-items");
351        }
352
353        if !self.config.features.is_empty() {
354            cmd.push_str(&alloc::format!(
355                " --features {}",
356                self.config.features.join(",")
357            ));
358        }
359
360        for cfg in &self.config.cfg_flags {
361            cmd.push_str(&alloc::format!(" --cfg {}", cfg));
362        }
363
364        cmd.push_str(&alloc::format!(" --target-dir {}", self.config.output_dir));
365
366        cmd
367    }
368
369    /// Generate the command to build documentation for a single package.
370    pub fn generate_docs(&self) -> String {
371        self.base_command()
372    }
373
374    /// Generate the command to build documentation for a specific package
375    /// within a workspace.
376    pub fn generate_package_docs(&self, package_name: &str) -> String {
377        let base = self.base_command();
378        alloc::format!("{} -p {}", base, package_name)
379    }
380
381    /// Generate the command to build documentation for the entire workspace.
382    pub fn generate_workspace_docs(&self) -> String {
383        let base = self.base_command();
384        alloc::format!("{} --workspace", base)
385    }
386
387    // -- Search index -------------------------------------------------------
388
389    /// Build the search index from a list of discovered items.
390    ///
391    /// In a real implementation this would parse the generated
392    /// `search-index.js` or walk the source AST; here we accept a
393    /// pre-collected list for testability.
394    pub fn generate_search_index(&mut self, items: Vec<DocItem>) -> Result<&DocIndex, KernelError> {
395        self.index = DocIndex::new();
396        for item in items {
397            self.index.add(item)?;
398        }
399        Ok(&self.index)
400    }
401
402    // -- Output path helpers ------------------------------------------------
403
404    /// Return the HTML output root for a given crate.
405    pub fn crate_doc_path(&self, crate_name: &str) -> String {
406        alloc::format!("{}/doc/{}", self.config.output_dir, crate_name)
407    }
408
409    /// Return the full path to the top-level `index.html`.
410    pub fn index_html_path(&self) -> String {
411        alloc::format!("{}/doc/index.html", self.config.output_dir)
412    }
413
414    /// Create a [`DocServerConfig`] for serving the generated docs.
415    pub fn server_config(&self, port: u16) -> DocServerConfig {
416        DocServerConfig {
417            doc_root: alloc::format!("{}/doc", self.config.output_dir),
418            port,
419            bind_address: "127.0.0.1".to_string(),
420        }
421    }
422
423    /// Generate cross-reference extern flags for linking between crates
424    /// in a workspace.
425    pub fn cross_reference_flags(&self, crate_names: &[&str]) -> Vec<String> {
426        if !self.config.cross_references {
427            return Vec::new();
428        }
429        crate_names
430            .iter()
431            .map(|name| {
432                alloc::format!(
433                    "--extern-html-root-url {}={}/doc/{}",
434                    name,
435                    self.config.output_dir,
436                    name
437                )
438            })
439            .collect()
440    }
441}
442
443// ---------------------------------------------------------------------------
444// Helper: lowercase conversion without std
445// ---------------------------------------------------------------------------
446
447/// Ascii-lowercase a `&str` into a new `String`.
448#[allow(dead_code)]
449trait AsciiLowerExt {
450    fn to_ascii_lowercase(&self) -> String;
451}
452
453impl AsciiLowerExt for str {
454    fn to_ascii_lowercase(&self) -> String {
455        let mut s = String::with_capacity(self.len());
456        for c in self.chars() {
457            if c.is_ascii_uppercase() {
458                s.push((c as u8 + 32) as char);
459            } else {
460                s.push(c);
461            }
462        }
463        s
464    }
465}
466
467// ---------------------------------------------------------------------------
468// Tests
469// ---------------------------------------------------------------------------
470
471#[cfg(test)]
472mod tests {
473    #[allow(unused_imports)]
474    use alloc::vec;
475
476    use super::*;
477
478    #[test]
479    fn test_rustdoc_config_default() {
480        let config = RustdocConfig::default();
481        assert_eq!(config.output_dir, DEFAULT_DOC_OUTPUT);
482        assert_eq!(config.theme, DocTheme::Default);
483        assert!(config.cross_references);
484        assert!(!config.document_private);
485        assert!(config.features.is_empty());
486    }
487
488    #[test]
489    fn test_generate_docs_command() {
490        let builder = RustdocBuilder::new(RustdocConfig::default());
491        let cmd = builder.generate_docs();
492        assert!(cmd.contains("cargo doc --no-deps"));
493        assert!(cmd.contains("--target-dir"));
494        assert!(cmd.contains(DEFAULT_DOC_OUTPUT));
495    }
496
497    #[test]
498    fn test_generate_package_docs() {
499        let builder = RustdocBuilder::new(RustdocConfig::default());
500        let cmd = builder.generate_package_docs("veridian-kernel");
501        assert!(cmd.contains("-p veridian-kernel"));
502        assert!(cmd.contains("cargo doc"));
503    }
504
505    #[test]
506    fn test_generate_workspace_docs() {
507        let builder = RustdocBuilder::new(RustdocConfig::default());
508        let cmd = builder.generate_workspace_docs();
509        assert!(cmd.contains("--workspace"));
510    }
511
512    #[test]
513    fn test_search_index_build_and_query() {
514        let mut builder = RustdocBuilder::new(RustdocConfig::default());
515        let items = vec![
516            DocItem::new(
517                "HashMap",
518                DocItemKind::Struct,
519                "std::collections",
520                "A hash map",
521                true,
522            ),
523            DocItem::new(
524                "hash_map",
525                DocItemKind::Module,
526                "std::collections",
527                "Hash map module",
528                true,
529            ),
530            DocItem::new(
531                "Vec",
532                DocItemKind::Struct,
533                "alloc::vec",
534                "A growable array",
535                true,
536            ),
537        ];
538        let index = builder.generate_search_index(items).unwrap();
539        assert_eq!(index.len(), 3);
540
541        let results = index.search_by_name("hash");
542        assert_eq!(results.len(), 2);
543
544        let results = index.search_by_path("std::collections");
545        assert_eq!(results.len(), 2);
546    }
547
548    #[test]
549    fn test_doc_index_statistics() {
550        let mut index = DocIndex::new();
551        index
552            .add(DocItem::new(
553                "foo",
554                DocItemKind::Function,
555                "crate",
556                "desc",
557                true,
558            ))
559            .unwrap();
560        index
561            .add(DocItem::new(
562                "Bar",
563                DocItemKind::Struct,
564                "crate",
565                "desc",
566                true,
567            ))
568            .unwrap();
569        index
570            .add(DocItem::new(
571                "Baz",
572                DocItemKind::Enum,
573                "crate",
574                "desc",
575                true,
576            ))
577            .unwrap();
578        let stats = index.statistics();
579        assert_eq!(stats.total, 3);
580        assert_eq!(stats.functions, 1);
581        assert_eq!(stats.structs, 1);
582        assert_eq!(stats.enums, 1);
583    }
584
585    #[test]
586    fn test_configure_theme_and_flags() {
587        let mut builder = RustdocBuilder::new(RustdocConfig::default());
588        builder.configure_theme(DocTheme::Dark);
589        assert_eq!(builder.config().theme, DocTheme::Dark);
590        assert_eq!(DocTheme::Dark.flag_value(), "ayu");
591        assert_eq!(DocTheme::HighContrast.flag_value(), "high-contrast");
592    }
593
594    #[test]
595    fn test_doc_server_config() {
596        let builder = RustdocBuilder::new(RustdocConfig::default());
597        let server = builder.server_config(9090);
598        assert_eq!(server.port, 9090);
599        assert_eq!(server.url(), "http://127.0.0.1:9090");
600        assert!(server.doc_root.contains("doc"));
601    }
602}