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