1use std::path::{Path, PathBuf};
20
21use crate::formats::{FileFormat, RemoteRead, Stored};
22use crate::settings;
23
24mod site;
25
26type Render = fn(&dyn Fn(&str) -> String) -> String;
29
30pub struct Generated {
32 pub file: &'static str,
34 pub region: Option<&'static str>,
36 pub render: Render,
37}
38
39pub const GENERATED: &[Generated] = &[
41 Generated {
42 file: "docs/reference/command-line-options.md",
43 region: None,
44 render: |_| crate::render_options_markdown(),
45 },
46 Generated {
47 file: "docs/reference/settings.md",
48 region: None,
49 render: |_| settings::render_settings_markdown(),
50 },
51 Generated {
52 file: "docs/reference/environment.md",
53 region: None,
54 render: |_| settings::render_environment_markdown(),
55 },
56 Generated {
57 file: "docs/reference/keyboard-shortcuts.md",
58 region: Some("keys"),
59 render: |_| crate::keys::render_markdown(),
60 },
61 Generated {
62 file: "docs/formats/index.md",
63 region: Some("formats"),
64 render: |_| render_formats_markdown(),
65 },
66 Generated {
67 file: "docs/formats/index.md",
68 region: Some("format-count"),
69 render: |_| format_count_sentence(),
70 },
71 Generated {
72 file: "docs/introduction.md",
73 region: Some("pitch"),
74 render: |_| site::PITCH.to_string(),
75 },
76 Generated {
77 file: "README.md",
78 region: Some("pitch"),
79 render: |_| site::PITCH.to_string(),
80 },
81 Generated {
82 file: "docs/reference/python-api.md",
83 region: Some("options"),
84 render: |_| render_python_options_markdown(),
85 },
86 Generated {
87 file: "docs/reference/catalogs.md",
88 region: Some("public-catalog"),
89 render: |read| render_public_catalog(&read("crates/datui-lib/src/public_catalog.toml")),
90 },
91 Generated {
92 file: "docs/reference/manual-pages.md",
93 region: Some("pages"),
94 render: |_| crate::man::render_markdown_index(),
95 },
96 Generated {
97 file: "README.md",
98 region: Some("install"),
99 render: site::readme_install,
100 },
101 Generated {
102 file: "docs/getting-started/installation.md",
103 region: Some("install-script"),
104 render: site::docs_install_script,
105 },
106 Generated {
107 file: "docs/getting-started/installation.md",
108 region: Some("install-table"),
109 render: site::docs_install_table,
110 },
111 Generated {
112 file: "docs/getting-started/installation.md",
113 region: Some("install-apt"),
114 render: |read| site::channel_block(read, "apt"),
115 },
116 Generated {
117 file: "docs/getting-started/installation.md",
118 region: Some("install-dnf"),
119 render: |read| site::channel_block(read, "dnf"),
120 },
121 Generated {
122 file: "scripts/docs/index.html.j2",
123 region: Some("formats"),
124 render: |_| site::landing_formats(),
125 },
126 Generated {
127 file: "scripts/docs/index.html.j2",
128 region: Some("install"),
129 render: site::landing_install,
130 },
131 Generated {
132 file: "Cargo.toml",
133 region: Some("description"),
134 render: |_| format!("description = {}", site::toml_string(&site::summary())),
135 },
136 Generated {
137 file: "Cargo.toml",
138 region: Some("deb-description"),
139 render: |_| {
140 format!(
141 "extended-description = {}",
142 site::toml_string(&site::description())
143 )
144 },
145 },
146 Generated {
147 file: "python/pyproject.toml",
148 region: Some("description"),
149 render: |_| format!("description = {}", site::toml_string(&site::summary())),
150 },
151 Generated {
152 file: "scripts/packaging/homebrew-formula.rb.template",
153 region: Some("desc"),
154 render: |_| format!(" desc {}", site::toml_string(&site::summary())),
155 },
156 Generated {
157 file: "scripts/packaging/datui.desktop",
158 region: Some("comment"),
159 render: |_| format!("Comment={}", site::TAGLINE),
160 },
161];
162
163fn markers(file: &str, name: &str) -> (String, String) {
165 if file.ends_with(".j2") {
166 (
167 format!("{{# generated: {name} #}}"),
168 format!("{{# end generated: {name} #}}"),
169 )
170 } else if file.ends_with(".md") {
171 (
172 format!("<!-- generated: {name} -->"),
173 format!("<!-- end generated: {name} -->"),
174 )
175 } else {
176 (
177 format!("# generated: {name}"),
178 format!("# end generated: {name}"),
179 )
180 }
181}
182
183pub fn splice(file: &str, text: &str, name: &str, content: &str) -> Result<String, String> {
186 let (open, close) = markers(file, name);
187 let start = text.find(&open).ok_or_else(|| format!("no `{open}`"))? + open.len();
188 let end = text[start..]
189 .find(&close)
190 .ok_or_else(|| format!("no `{close}`"))?
191 + start;
192 Ok(format!(
193 "{}\n{}\n{}",
194 &text[..start],
195 content.trim_matches('\n'),
196 &text[end..]
197 ))
198}
199
200pub fn repo_root() -> PathBuf {
202 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
203 root.canonicalize().unwrap_or(root)
204}
205
206pub fn read_file(root: &Path, path: &str) -> String {
208 let full = root.join(path);
209 std::fs::read_to_string(&full)
210 .unwrap_or_else(|e| panic!("{}: {e}", full.display()))
211 .replace("\r\n", "\n")
212}
213
214pub fn render_all(root: &Path) -> Result<Vec<(PathBuf, String)>, String> {
217 let read = |path: &str| read_file(root, path);
218 let mut out: Vec<(PathBuf, String)> = Vec::new();
219 for part in GENERATED {
220 let path = root.join(part.file);
221 let content = (part.render)(&read);
222 let current = match out.iter().position(|(p, _)| *p == path) {
223 Some(i) => out.remove(i).1,
224 None if part.region.is_none() => String::new(),
225 None => std::fs::read_to_string(&path)
226 .map_err(|e| format!("{}: {e}", part.file))?
227 .replace("\r\n", "\n"),
229 };
230 let text = match part.region {
231 None => content,
232 Some(name) => splice(part.file, ¤t, name, &content)
233 .map_err(|e| format!("{}: {e}", part.file))?,
234 };
235 out.push((path, text));
236 }
237 for page in crate::man::PAGES {
238 out.push((root.join(page.path()), page.render(&read)));
239 }
240 Ok(out)
241}
242
243pub fn write_all(root: &Path) -> Result<Vec<PathBuf>, String> {
245 let mut changed = Vec::new();
246 for (path, text) in render_all(root)? {
247 let old = std::fs::read_to_string(&path).unwrap_or_default();
248 if old.replace("\r\n", "\n") != text {
249 std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
250 changed.push(path);
251 }
252 }
253 Ok(changed)
254}
255
256pub fn render_public_catalog(text: &str) -> String {
260 let lines: Vec<&str> = text.lines().collect();
261 let header = lines.iter().take_while(|l| l.starts_with('#')).count();
262 let mut out = String::new();
263 for line in &lines[..header] {
264 let said = line.trim_start_matches('#').trim();
265 if said.is_empty() {
266 out.push_str(">\n");
267 } else {
268 out.push_str(&format!("> {said}\n"));
269 }
270 }
271 let body = lines[header..].join("\n");
272 out.push_str(&format!(
273 "\n```toml,output\n{}\n```",
274 body.trim_matches('\n')
275 ));
276 out
277}
278
279pub fn format_page(format: FileFormat) -> &'static str {
281 use FileFormat::*;
282 match format {
283 Parquet => "columnar-and-json.md#parquet",
284 Csv | Tsv | Psv => "delimited-text.md#csv-tsv-and-psv",
285 Text => "delimited-text.md#text-and-logs",
286 Json | Jsonl => "columnar-and-json.md#json-and-ndjson",
287 Arrow => "columnar-and-json.md#arrow-ipc",
288 Avro | Orc => "columnar-and-json.md#avro-and-orc",
289 Excel => "columnar-and-json.md#excel",
290 Sqlite => "databases-and-arrays.md#sqlite",
291 Numpy => "databases-and-arrays.md#numpy",
292 Safetensors | Gguf => "model-files.md",
293 Audio => "signals-and-logs.md#audio",
294 Midi => "signals-and-logs.md#midi",
295 Vcd => "signals-and-logs.md#vcd",
296 Nmea | Gpx => "signals-and-logs.md#gps-logs",
297 Ulog | Dataflash => "signals-and-logs.md#flight-logs",
298 Candump => "signals-and-logs.md#can-logs",
299 Fix => "signals-and-logs.md#fix-logs",
300 Sdf => "signals-and-logs.md#sdf",
301 Elf => "signals-and-logs.md#elf",
302 Journal => "signals-and-logs.md#systemd-journal",
303 }
304}
305
306pub fn doc_title(format: FileFormat) -> &'static str {
308 match format {
309 FileFormat::Audio => "WAV, BWF, RF64, AIFF",
310 FileFormat::Text => "Text",
311 _ => format.title(),
312 }
313}
314
315pub fn short_title(format: FileFormat) -> &'static str {
317 match format {
318 FileFormat::Audio => "WAV/AIFF audio",
319 FileFormat::Text => "plain text",
320 _ => format.title(),
321 }
322}
323
324pub fn format_count_sentence() -> String {
327 let titles: Vec<&str> = FileFormat::ALL.into_iter().map(short_title).collect();
328 format!(
329 "datui reads {} formats: {}, and binary formats you describe in a format spec.",
330 titles.len(),
331 titles.join(", ")
332 )
333}
334
335pub fn render_formats_markdown() -> String {
338 let mut out = String::from(
339 "| Format | `--format` | Extensions | Read | Compressed | HTTP(S) | In a bucket | Bucket prefix |\n\
340 |---|---|---|---|---|---|---|---|\n",
341 );
342 let said = |mode: Option<crate::formats::ReadMode>| mode.map_or("no", |m| m.label());
343 for format in FileFormat::ALL {
344 let d = format.descriptor();
345 let mut names: Vec<String> = d.extensions.iter().map(|e| format!("`.{e}`")).collect();
346 names.extend(d.name_endings.iter().map(|n| format!("`{n}`")));
347 let extensions = if names.is_empty() {
348 "none: by content".to_string()
349 } else {
350 names.join(", ")
351 };
352 out.push_str(&format!(
353 "| [{}]({}) | `{}` | {} | {} | {} | {} | {} | {} |\n",
354 doc_title(format),
355 format_page(format),
356 format.name(),
357 extensions,
358 said(format.read_mode(Stored::Plain)),
359 said(format.read_mode(Stored::Compressed { in_memory: false })),
360 format.http_file().label(),
361 format.bucket_object(Stored::Plain).label(),
362 format
363 .bucket_prefix(Stored::Plain)
364 .map_or("no", RemoteRead::label),
365 ));
366 }
367 let arrow = FileFormat::Arrow;
368 out.push_str(&format!(
369 "| [Arrow IPC stream](columnar-and-json.md#arrow-ipc) | `arrow` | as Arrow IPC | {} | {} | {} | {} | {} |\n",
370 said(arrow.read_mode(Stored::Stream)),
371 said(arrow.read_mode(Stored::Compressed { in_memory: false })),
372 arrow.http_file().label(),
373 arrow.bucket_object(Stored::Stream).label(),
374 arrow.bucket_prefix(Stored::Stream).map_or("no", RemoteRead::label),
375 ));
376 let spec = crate::FormatChoice::Spec("a.spec".into());
377 out.push_str(&format!(
378 "| [Format spec](format-specs.md) | its name | its `match` | {} | {} | {} | {} | {} |\n",
379 said(spec.read_mode(Stored::Plain)),
380 said(spec.read_mode(Stored::Compressed { in_memory: false })),
381 spec.http_file().label(),
382 spec.bucket_object(Stored::Plain).label(),
383 spec.bucket_prefix(Stored::Plain)
384 .map_or("no", RemoteRead::label),
385 ));
386 out
387}
388
389pub fn render_python_options_markdown() -> String {
392 use clap::CommandFactory;
393 let cmd = crate::Args::command();
394 let flag_help = |flag: &str| -> String {
395 cmd.get_arguments()
396 .find(|a| a.get_long() == Some(flag))
397 .and_then(|a| a.get_help())
398 .map(|h| h.to_string())
399 .unwrap_or_default()
400 };
401 let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
402 let mut out =
403 String::from("| Keyword | Takes | Command line | What it does |\n|---|---|---|---|\n");
404 for open in settings::OPEN {
405 out.push_str(&format!(
406 "| `{}` | {} | `--{}` | {} |\n",
407 open.kwarg,
408 open.kind.describe(),
409 open.flag,
410 cell(&flag_help(open.flag)),
411 ));
412 }
413 for setting in settings::SETTINGS.iter().filter(|s| s.kwarg.is_some()) {
414 let line = match setting.flag {
415 Some(flag) => format!("`--{flag}`"),
416 None => format!("`-c {}=...`", setting.key),
417 };
418 out.push_str(&format!(
419 "| `{}` | {} | {} | {} |\n",
420 setting.kwarg.unwrap_or_default(),
421 setting.kind.describe(),
422 line,
423 cell(setting.doc),
424 ));
425 }
426 out.push_str(
427 "| `config` | dict | `-c KEY=VALUE` | Any config key to its value, as `-c` sets it: `config={\"display.row_numbers\": True}` |\n",
428 );
429 out
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
440 fn the_generated_docs_are_current() {
441 let root = repo_root();
442 let stale: Vec<String> = render_all(&root)
443 .expect("every generated region has its markers")
444 .into_iter()
445 .filter(|(path, text)| {
446 std::fs::read_to_string(path)
447 .map(|t| t.replace("\r\n", "\n"))
448 .ok()
449 .as_deref()
450 != Some(text.as_str())
451 })
452 .map(|(path, _)| path.display().to_string())
453 .collect();
454 assert!(
455 stale.is_empty(),
456 "stale: {}. Run `cargo run -p datui-cli --bin gen_docs -- write`",
457 stale.join(", ")
458 );
459 }
460
461 #[test]
462 fn a_region_is_replaced_between_its_markers() {
463 let text = "a\n<!-- generated: x -->\nold\n<!-- end generated: x -->\nb\n";
464 assert_eq!(
465 splice("a.md", text, "x", "new").unwrap(),
466 "a\n<!-- generated: x -->\nnew\n<!-- end generated: x -->\nb\n"
467 );
468 assert!(splice("a.md", text, "y", "new").is_err());
469 let toml = "a\n# generated: x\nold\n# end generated: x\nb\n";
470 assert_eq!(
471 splice("a.toml", toml, "x", "new").unwrap(),
472 "a\n# generated: x\nnew\n# end generated: x\nb\n"
473 );
474 }
475
476 #[test]
477 fn the_format_count_counts_every_format() {
478 let sentence = format_count_sentence();
479 assert!(sentence.starts_with(&format!("datui reads {} formats", FileFormat::ALL.len())));
480 assert_eq!(sentence.matches(", ").count(), FileFormat::ALL.len());
481 }
482}