Skip to main content

mdbook_rash/
lib.rs

1use rash_core::jinja::lookup::LOOKUPS;
2use rash_core::modules::MODULES;
3
4use std::sync::LazyLock;
5
6use mdbook_core::book::{Book, BookItem, Chapter};
7use mdbook_driver::builtin_preprocessors::LinkPreprocessor;
8use mdbook_preprocessor::errors::Error;
9use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
10use prettytable::{Table, format, row};
11use regex::{Match, Regex};
12use schemars::Schema;
13
14#[macro_use]
15extern crate log;
16
17pub const SUPPORTED_RENDERER: &[&str] = &["markdown"];
18
19const DOCS_BASE_URL: &str = "https://rash-sh.github.io/docs/rash/latest";
20const GITHUB_URL: &str = "https://github.com/rash-sh/rash";
21
22static RE: LazyLock<Regex> = LazyLock::new(|| {
23    Regex::new(
24        r#"(?x)                                                   # insignificant whitespace mode
25        \{\s*                                                     # link opening parens and whitespace
26        \$([a-zA-Z0-9_]+)                                         # link type
27        (?:\s+                                                    # separating whitespace
28        ([a-zA-Z0-9\s_.,\*\{\}\[\]\(\)\|'\-\\/`"\#+=:/\\%^?]+))?  # all doc
29        \s*\}                                                     # whitespace and link closing parens"#
30    )
31    .unwrap()
32});
33
34static FORMAT: LazyLock<format::TableFormat> = LazyLock::new(|| {
35    format::FormatBuilder::new()
36        .padding(1, 1)
37        .borders('|')
38        .separator(
39            format::LinePosition::Title,
40            format::LineSeparator::new('-', '|', '|', '|'),
41        )
42        .column_separator('|')
43        .build()
44});
45
46fn get_matches(ch: &Chapter) -> Option<Vec<(Match<'_>, Option<String>, String)>> {
47    RE.captures_iter(&ch.content)
48        .map(|cap| match (cap.get(0), cap.get(1), cap.get(2)) {
49            (Some(origin), Some(typ), rest) => match (typ.as_str(), rest) {
50                ("include_doc", Some(content)) => Some((
51                    origin,
52                    Some(content.as_str().replace("/// ", "").replace("///", "")),
53                    typ.as_str().to_owned(),
54                )),
55                ("include_module_index" | "include_doc" | "include_lookup_index", _) => {
56                    Some((origin, None, typ.as_str().to_owned()))
57                }
58                _ => None,
59            },
60            _ => None,
61        })
62        .collect::<Option<Vec<(Match, Option<String>, String)>>>()
63}
64
65fn get_type(val: &serde_json::Value) -> String {
66    match val.get("type") {
67        Some(t) => {
68            if t.is_array() {
69                t.as_array()
70                    .unwrap()
71                    .first()
72                    .and_then(|v| v.as_str())
73                    .unwrap_or("")
74                    .to_string()
75            } else {
76                t.as_str().unwrap_or("").to_string()
77            }
78        }
79        None => "".to_string(),
80    }
81}
82
83fn get_enum(val: &serde_json::Value) -> String {
84    val.get("enum")
85        .and_then(|e| e.as_array())
86        .map(|arr| {
87            arr.iter()
88                .filter_map(|v| v.as_str().map(|s| s.to_string()))
89                .collect::<Vec<_>>()
90                .join("<br>")
91        })
92        .unwrap_or_default()
93}
94
95fn get_description(val: &serde_json::Value) -> String {
96    use regex::Regex;
97    let re = Regex::new(r"\s+").unwrap();
98    val.get("description")
99        .and_then(|d| d.as_str())
100        .map(|s| {
101            // Replace all whitespace sequences (including newlines) with single spaces
102            // and remove any remaining line breaks or carriage returns
103            re.replace_all(s, " ")
104                .replace(['\n', '\r'], " ")
105                .replace("  ", " ")
106                .trim()
107                .to_string()
108        })
109        .unwrap_or_default()
110}
111
112fn format_schema(schema: &Schema) -> String {
113    let mut table = Table::new();
114    table.set_format(*FORMAT);
115    table.set_titles(row![
116        "Parameter",
117        "Required",
118        "Type",
119        "Values",
120        "Description"
121    ]);
122
123    let root = schema;
124    let properties = root.get("properties").and_then(|p| p.as_object());
125    let required = root
126        .get("required")
127        .and_then(|r| r.as_array())
128        .map(|arr| {
129            arr.iter()
130                .filter_map(|v| v.as_str().map(|s| s.to_string()))
131                .collect::<Vec<_>>()
132        })
133        .unwrap_or_default();
134
135    let add_properties = |table: &mut Table, props: &serde_json::Map<String, serde_json::Value>| {
136        for (name, prop_schema) in props {
137            let value = get_enum(prop_schema);
138            let description = get_description(prop_schema);
139
140            table.add_row(row![
141                name,
142                if required.contains(name) {
143                    "true".to_string()
144                } else {
145                    "".to_string()
146                },
147                get_type(prop_schema),
148                value,
149                description
150            ]);
151        }
152    };
153
154    if let Some(one_of) = root.get("oneOf").and_then(|o| o.as_array()) {
155        for schema in one_of {
156            let variant_description = get_description(schema);
157            if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
158                // For oneOf variants, we need to apply the variant's description to its properties
159                for (name, prop_schema) in props {
160                    let value = get_enum(prop_schema);
161                    // Use the variant's description if the property doesn't have one
162                    let mut prop_description = get_description(prop_schema);
163                    if prop_description.is_empty() && !variant_description.is_empty() {
164                        prop_description = variant_description.clone();
165                    }
166
167                    table.add_row(row![
168                        name,
169                        if required.contains(name) {
170                            "true".to_string()
171                        } else {
172                            "".to_string()
173                        },
174                        get_type(prop_schema),
175                        value,
176                        prop_description
177                    ]);
178                }
179            }
180        }
181    }
182
183    if let Some(props) = properties {
184        add_properties(&mut table, props);
185    }
186
187    format!("{table}")
188}
189
190fn replace_matches(captures: Vec<(Match, Option<String>, String)>, ch: &mut Chapter) {
191    for capture in captures.iter() {
192        if capture.2 == "include_module_index" {
193            let mut indexes_vec = MODULES
194                .keys()
195                .map(|name| format!("- [{name}](./module_{name}.html)"))
196                .collect::<Vec<String>>();
197            indexes_vec.sort();
198            let indexes_body = indexes_vec.join("\n");
199
200            let mut modules = MODULES.iter().collect::<Vec<_>>();
201            modules.sort_by_key(|x| x.0);
202
203            for module in modules {
204                let mut new_section_number = ch.number.clone().unwrap();
205                new_section_number.push((ch.sub_items.len() + 1) as u32);
206
207                let schema = module.1.get_json_schema();
208                let name = module.0;
209
210                let parameters = schema.map(|s| format_schema(&s)).unwrap_or_else(|| format!("{{$include_doc {{{{#include ../../rash_core/src/modules/{name}.rs:parameters}}}}}}"));
211                let content_header = format!(
212                    r#"---
213title: {name}
214weight: {weight}
215indent: true
216---
217
218{{$include_doc {{{{#include ../../rash_core/src/modules/{name}.rs:module}}}}}}
219
220## Parameters
221
222{parameters}
223{{$include_doc {{{{#include ../../rash_core/src/modules/{name}.rs:examples}}}}}}
224
225"#,
226                    name = name,
227                    weight = new_section_number.first().unwrap() * 1000
228                        + ((ch.sub_items.len() + 1) * 10) as u32,
229                    parameters = parameters,
230                )
231                .to_owned();
232
233                let mut new_ch = Chapter::new(
234                    name,
235                    content_header,
236                    format!("module_{}.md", &name),
237                    vec![ch.name.clone()],
238                );
239                new_ch.number = Some(new_section_number);
240                info!("Add {} module", &name);
241                ch.sub_items.push(BookItem::Chapter(new_ch));
242            }
243            return ch.content = RE.replace(&ch.content, &indexes_body).to_string();
244        } else if capture.2 == "include_lookup_index" {
245            let mut indexes_vec = LOOKUPS
246                .iter()
247                .map(|name| format!("- [{name}](./lookup_{name}.html)"))
248                .collect::<Vec<String>>();
249            indexes_vec.sort();
250            let indexes_body = indexes_vec.join("\n");
251
252            let mut lookups = LOOKUPS.iter().collect::<Vec<_>>();
253            lookups.sort();
254
255            for lookup_name in lookups {
256                let mut new_section_number = ch.number.clone().unwrap();
257                new_section_number.push((ch.sub_items.len() + 1) as u32);
258
259                let content_header = format!(
260                    r#"---
261title: {name}
262weight: {weight}
263indent: true
264---
265
266{{$include_doc {{{{#include ../../rash_core/src/jinja/lookup/{name}.rs:lookup}}}}}}
267
268{{$include_doc {{{{#include ../../rash_core/src/jinja/lookup/{name}.rs:examples}}}}}}
269
270"#,
271                    name = lookup_name,
272                    weight = new_section_number.first().unwrap() * 1000
273                        + ((ch.sub_items.len() + 1) * 100) as u32,
274                )
275                .to_owned();
276
277                let mut new_ch = Chapter::new(
278                    lookup_name,
279                    content_header,
280                    format!("lookup_{}.md", &lookup_name),
281                    vec![ch.name.clone()],
282                );
283                new_ch.number = Some(new_section_number);
284                info!("Add {} lookup", &lookup_name);
285                ch.sub_items.push(BookItem::Chapter(new_ch));
286            }
287            return ch.content = RE.replace(&ch.content, &indexes_body).to_string();
288        };
289        info!("Replace in chapter {}", &ch.name);
290        let other_content = &capture
291            .1
292            .clone()
293            .unwrap_or_else(|| panic!("Empty include doc in {}.md", &ch.name));
294        ch.content = RE.replace(&ch.content, other_content).to_string();
295    }
296}
297
298fn escape_jekyll(ch: &mut Chapter) {
299    let mut new_content = ch.content.replace("\n---\n", "\n---\n\n{% raw %}");
300    new_content.push_str("{% endraw %}");
301    ch.content = new_content;
302}
303
304fn preprocess_rash(book: &mut Book, is_escape_jekyll: bool) {
305    book.for_each_mut(|section: &mut BookItem| {
306        if let BookItem::Chapter(ref mut ch) = *section {
307            let ch_copy = ch.clone();
308            if let Some(captures) = get_matches(&ch_copy) {
309                replace_matches(captures, ch);
310            };
311            if is_escape_jekyll {
312                escape_jekyll(ch);
313            };
314        };
315    });
316}
317
318pub fn run(_ctx: &PreprocessorContext, book: Book) -> Result<Book, Error> {
319    let mut new_book = book;
320    preprocess_rash(&mut new_book, false);
321
322    let mut processed_book = LinkPreprocessor::new().run(_ctx, new_book.clone())?;
323
324    preprocess_rash(&mut processed_book, true);
325    Ok(processed_book)
326}
327
328/// Generate llms.txt content for LLM discoverability.
329///
330/// Returns a markdown-formatted string containing:
331/// - Project overview and features
332/// - Installation instructions
333/// - Quick start example
334/// - Module list (all available modules)
335/// - Lookup list
336/// - Built-in variables
337/// - Links to full documentation
338pub fn generate_llms_txt() -> String {
339    let sections: Vec<String> = vec![
340        section_header(),
341        section_what_is_rash(),
342        section_installation(),
343        section_quick_start(),
344        section_modules(),
345        section_lookups(),
346        section_builtins(),
347        section_links(),
348    ];
349    sections.join("\n")
350}
351
352fn section_header() -> String {
353    let mut output = String::from("# Rash\n\n");
354    output.push_str(
355        "> Rash is a declarative shell scripting language using Ansible-like YAML syntax, ",
356    );
357    output.push_str(
358        "compiled to a single Rust binary. Designed for container entrypoints, IoT devices, ",
359    );
360    output.push_str("and local scripting with zero dependencies.\n");
361    output
362}
363
364fn section_what_is_rash() -> String {
365    let mut output = String::from("\n## What is Rash\n\nRash provides:\n");
366    output.push_str("- A **simple syntax** to maintain low complexity\n");
367    output.push_str("- One static binary to be **container oriented**\n");
368    output.push_str("- A **declarative** syntax to be idempotent\n");
369    output.push_str("- **Clear output** to log properly\n");
370    output.push_str("- **Security** by design\n");
371    output.push_str("- **Speed and efficiency**\n");
372    output.push_str("- **Modular** design\n");
373    output.push_str("- Support of [MiniJinja](https://docs.rs/minijinja/latest/minijinja/syntax/index.html) **templates**\n");
374    output
375}
376
377fn section_installation() -> String {
378    let mut output = String::from("\n## Installation\n\n```bash\n");
379    output.push_str("# Download latest binary (Linux/macOS)\n");
380    output.push_str("curl -s https://api.github.com/repos/rash-sh/rash/releases/latest \\\n");
381    output.push_str("    | grep browser_download_url \\\n");
382    output.push_str("    | grep -v sha256 \\\n");
383    output.push_str("    | grep $(uname -m) \\\n");
384    output.push_str("    | grep $(uname | tr '[:upper:]' '[:lower:]') \\\n");
385    output.push_str("    | grep -v musl \\\n");
386    output.push_str("    | cut -d '\"' -f 4 \\\n");
387    output.push_str("    | xargs curl -s -L \\\n");
388    output.push_str("    | sudo tar xvz -C /usr/local/bin\n");
389    output.push_str("```\n");
390    output
391}
392
393fn section_quick_start() -> String {
394    let mut output =
395        String::from("\n## Quick Start\n\nCreate an `entrypoint.rh` file:\n\n```yaml\n");
396    output.push_str("- name: Ensure directory exists\n");
397    output.push_str("  file:\n");
398    output.push_str("    path: /app/data\n");
399    output.push_str("    state: directory\n\n");
400    output.push_str("- name: Copy configuration\n");
401    output.push_str("  copy:\n");
402    output.push_str("    content: \"{{ env.APP_CONFIG }}\"\n");
403    output.push_str("    dest: /app/config.yml\n\n");
404    output.push_str("- name: Run application\n");
405    output.push_str("  command:\n");
406    output.push_str("    cmd: /app/bin/start\n");
407    output.push_str("```\n");
408    output
409}
410
411fn section_modules() -> String {
412    let mut output =
413        String::from("\n## Modules\n\nRash modules are idempotent operations like Ansible.\n\n");
414
415    let mut modules: Vec<_> = MODULES.keys().collect();
416    modules.sort();
417
418    for name in modules {
419        output.push_str(&format!("- {name}\n"));
420    }
421
422    output.push_str(&format!(
423        "\nSee {DOCS_BASE_URL}/modules.html for full documentation.\n"
424    ));
425    output
426}
427
428fn section_lookups() -> String {
429    let mut output =
430        String::from("\n## Lookups\n\nLookups allow fetching data from external sources.\n\n");
431
432    let mut lookups: Vec<_> = LOOKUPS.iter().collect();
433    lookups.sort();
434
435    for name in lookups {
436        output.push_str(&format!("- {name}\n"));
437    }
438
439    output.push_str(&format!(
440        "\nSee {DOCS_BASE_URL}/lookups.html for full documentation.\n"
441    ));
442    output
443}
444
445fn section_builtins() -> String {
446    let mut output = String::from("\n## Built-in Variables\n\n");
447    output.push_str("- `rash.path` - Path to the current script\n");
448    output.push_str("- `rash.dir` - Directory of the current script\n");
449    output.push_str("- `rash.cwd` - Current working directory\n");
450    output.push_str("- `rash.arch` - System architecture\n");
451    output.push_str("- `rash.check_mode` - Boolean indicating check mode\n");
452    output.push_str("- `env.VAR_NAME` - Access environment variables\n");
453    output.push_str(&format!(
454        "\nSee {DOCS_BASE_URL}/builtins.html for full documentation.\n"
455    ));
456    output
457}
458
459fn section_links() -> String {
460    format!("\n## Links\n\n- GitHub: {GITHUB_URL}\n- Documentation: {DOCS_BASE_URL}/\n")
461}
462
463#[cfg(test)]
464mod llms_txt_test {
465    use super::*;
466
467    #[test]
468    fn test_generate_llms_txt_contains_expected_sections() {
469        let output = generate_llms_txt();
470        assert!(output.contains("# Rash"), "Missing main header");
471        assert!(
472            output.contains("## What is Rash"),
473            "Missing What is Rash section"
474        );
475        assert!(
476            output.contains("## Installation"),
477            "Missing Installation section"
478        );
479        assert!(
480            output.contains("## Quick Start"),
481            "Missing Quick Start section"
482        );
483        assert!(output.contains("## Modules"), "Missing Modules section");
484        assert!(output.contains("## Lookups"), "Missing Lookups section");
485        assert!(
486            output.contains("## Built-in Variables"),
487            "Missing Built-in Variables section"
488        );
489        assert!(output.contains("## Links"), "Missing Links section");
490    }
491
492    #[test]
493    fn test_generate_llms_txt_contains_all_modules() {
494        let output = generate_llms_txt();
495        for name in MODULES.keys() {
496            assert!(
497                output.contains(&format!("- {name}\n")),
498                "Missing module: {name}"
499            );
500        }
501    }
502
503    #[test]
504    fn test_generate_llms_txt_contains_all_lookups() {
505        let output = generate_llms_txt();
506        for name in LOOKUPS.iter() {
507            assert!(
508                output.contains(&format!("- {name}\n")),
509                "Missing lookup: {name}"
510            );
511        }
512    }
513
514    #[test]
515    fn test_generate_llms_txt_links_are_valid() {
516        let output = generate_llms_txt();
517        assert!(output.contains(DOCS_BASE_URL), "Missing docs base URL");
518        assert!(output.contains(GITHUB_URL), "Missing GitHub URL");
519    }
520
521    #[test]
522    fn test_generate_llms_txt_quick_start_is_valid_yaml() {
523        let output = generate_llms_txt();
524        let start = output
525            .find("```yaml\n")
526            .expect("Could not find yaml block start");
527        let end = output[start..]
528            .find("\n```\n")
529            .expect("Could not find yaml block end");
530        let yaml_content = &output[start + 8..start + end];
531        serde_norway::from_str::<serde_norway::Value>(yaml_content)
532            .expect("Quick start example is not valid YAML");
533    }
534}
535
536#[cfg(test)]
537mod prettytable_wrap_test {
538    use prettytable::{Table, row};
539
540    #[test]
541    fn test_long_description_row() {
542        let mut table = Table::new();
543        table.set_titles(row![
544            "Parameter",
545            "Required",
546            "Type",
547            "Values",
548            "Description"
549        ]);
550        table.add_row(row![
551            "argv",
552            "",
553            "array",
554            "",
555            "Passes the command arguments as a list rather than a string. Only the string or the list form can be provided, not both."
556        ]);
557        table.add_row(row![
558            "transfer_pid",
559            "",
560            "boolean",
561            "",
562            "Execute command as PID 1. Note: from this point on, your rash script execution is transferred to the command."
563        ]);
564        println!("{table}");
565    }
566}
567
568#[cfg(test)]
569mod schema_debug_test {
570    use super::*;
571
572    #[test]
573    fn debug_command_schema() {
574        if let Some(command_module) = MODULES.get("command")
575            && let Some(schema) = command_module.get_json_schema()
576        {
577            println!("=== COMMAND SCHEMA ===");
578            println!("{}", serde_json::to_string_pretty(&schema).unwrap());
579            println!("=== END COMMAND SCHEMA ===");
580
581            let table_output = format_schema(&schema);
582            println!("=== TABLE OUTPUT ===");
583            println!("{table_output}");
584            println!("=== END TABLE OUTPUT ===");
585        }
586    }
587}