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