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 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 (name, prop_schema) in props {
160 let value = get_enum(prop_schema);
161 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
328pub 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}