1use std::collections::HashSet;
12use std::sync::Arc;
13
14use ratatui::buffer::Buffer;
15use ratatui::layout::Rect;
16use ratatui::style::{Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::{Paragraph, Widget};
19use unicode_width::UnicodeWidthStr;
20
21use crate::catalog::{ColumnNote, Dataset};
22use crate::formats::SpecDocs;
23use crate::glyphs;
24use crate::render::context::RenderContext;
25use crate::widgets::ui::{HintBar, SectionRule, Surface};
26
27const MEASURE: u16 = 110;
30
31#[derive(Debug, Clone, Default)]
34pub struct Documented {
35 pub catalog: Option<(String, Arc<Dataset>)>,
37 pub spec: Option<Arc<SpecDocs>>,
39 pub name: String,
41}
42
43impl Documented {
44 pub fn new(
47 catalog: Option<(String, Arc<Dataset>)>,
48 spec: Option<Arc<SpecDocs>>,
49 name: String,
50 ) -> Option<Self> {
51 (catalog.is_some() || spec.is_some()).then_some(Self {
52 catalog,
53 spec,
54 name,
55 })
56 }
57
58 pub fn title(&self) -> &str {
60 match &self.catalog {
61 Some((_, entry)) => &entry.name,
62 None => &self.name,
63 }
64 }
65
66 pub fn columns(&self) -> Vec<(String, ColumnNote)> {
70 let catalog: &[(String, ColumnNote)] = self
71 .catalog
72 .as_ref()
73 .map_or(&[], |(_, entry)| &entry.columns);
74 let spec: &[(String, ColumnNote)] = self.spec.as_ref().map_or(&[], |s| &s.columns);
75 let noted = |name: &str| catalog.iter().find(|(n, _)| n == name).map(|(_, n)| n);
76 let mut out: Vec<(String, ColumnNote)> = spec
77 .iter()
78 .map(|(name, note)| {
79 let note = match noted(name) {
80 Some(over) => layered(over, note),
81 None => note.clone(),
82 };
83 (name.clone(), note)
84 })
85 .collect();
86 out.extend(
87 catalog
88 .iter()
89 .filter(|(name, _)| !spec.iter().any(|(n, _)| n == name))
90 .cloned(),
91 );
92 out
93 }
94}
95
96fn layered(over: &ColumnNote, under: &ColumnNote) -> ColumnNote {
98 let pick = |a: &String, b: &String| if a.is_empty() { b } else { a }.clone();
99 ColumnNote {
100 description: pick(&over.description, &under.description),
101 unit: pick(&over.unit, &under.unit),
102 values: if over.values.is_empty() {
103 under.values.clone()
104 } else {
105 over.values.clone()
106 },
107 ty: pick(&over.ty, &under.ty),
108 }
109}
110
111fn field_section(out: &mut Vec<DocLine>, title: &'static str, fields: &[(String, ColumnNote)]) {
113 if fields.is_empty() {
114 return;
115 }
116 out.push(DocLine::Blank);
117 out.push(DocLine::Section(title, fields.len()));
118 for (name, note) in fields {
119 out.push(DocLine::Column {
120 name: name.clone(),
121 about: note.about(),
122 values: 0,
123 });
124 }
125}
126
127#[derive(Debug, Clone, PartialEq)]
129pub enum DocLine {
130 About(String),
132 Field(&'static str, String),
134 Link(&'static str, String),
136 Section(&'static str, usize),
138 Column {
140 name: String,
141 about: String,
142 values: usize,
143 },
144 Legend(String, String),
146 RecordType(String, String),
149 Bookmark(String, String),
151 Blank,
152}
153
154impl DocLine {
155 fn focusable(&self) -> bool {
157 !matches!(self, DocLine::Blank | DocLine::Section(..))
158 }
159
160 pub fn copy_text(&self) -> Option<&str> {
162 match self {
163 DocLine::Link(_, url) => Some(url),
164 DocLine::Field(_, value) => Some(value),
165 DocLine::Bookmark(_, path) => Some(path),
166 DocLine::Legend(code, _) => Some(code),
167 DocLine::Column { name, .. } => Some(name),
168 DocLine::RecordType(name, _) => Some(name),
169 DocLine::About(text) => Some(text),
170 DocLine::Section(..) | DocLine::Blank => None,
171 }
172 }
173}
174
175pub fn lines(doc: &Documented, expanded: &HashSet<String>, measured: Option<u64>) -> Vec<DocLine> {
178 let mut out = Vec::new();
179 let entry = doc.catalog.as_ref().map(|(_, entry)| entry.as_ref());
180 let spec = doc.spec.as_deref();
181 let first_said = |of_entry: Option<&str>, of_spec: Option<&str>| {
183 [of_entry, of_spec]
184 .into_iter()
185 .flatten()
186 .find(|text| !text.is_empty())
187 .unwrap_or_default()
188 .to_string()
189 };
190 let description = first_said(
191 entry.map(|e| e.description.as_str()),
192 spec.map(|s| s.description.as_str()),
193 );
194 if !description.is_empty() {
195 out.push(DocLine::About(description));
196 out.push(DocLine::Blank);
197 }
198 if let Some((catalog, entry)) = &doc.catalog {
199 catalog_fields(&mut out, entry, catalog, measured);
200 }
201 if let Some(spec) = spec {
202 out.push(DocLine::Field("format spec", spec.spec.clone()));
203 if let Some(file) = &spec.file {
204 out.push(DocLine::Field("spec file", crate::home::display_path(file)));
205 }
206 }
207 let documentation = first_said(
208 entry.map(|e| e.documentation.as_str()),
209 spec.map(|s| s.documentation.as_str()),
210 );
211 let links: Vec<(&'static str, String)> = [
212 (
213 "homepage",
214 entry.map(|e| e.homepage.clone()).unwrap_or_default(),
215 ),
216 ("documentation", documentation),
217 ]
218 .into_iter()
219 .filter(|(_, url)| !url.is_empty())
220 .collect();
221 if !links.is_empty() {
222 out.push(DocLine::Blank);
223 out.push(DocLine::Section("LINKS", links.len()));
224 for (label, url) in links {
225 out.push(DocLine::Link(label, url));
226 }
227 }
228 if let Some(spec) = spec {
229 field_section(&mut out, "HEADER", &spec.header);
230 }
231 if let Some(spec) = spec.filter(|s| !s.record_types.is_empty()) {
232 let middot = glyphs::get().middot;
233 out.push(DocLine::Blank);
234 out.push(DocLine::Section("RECORD TYPES", spec.record_types.len()));
235 for record in &spec.record_types {
236 let columns = match record.columns {
237 1 => "1 column".to_string(),
238 n => format!("{n} columns"),
239 };
240 let mut about = format!("{} {middot} {columns}", record.picked_by);
241 if !record.description.is_empty() {
242 about = format!("{about} {middot} {}", record.description);
243 }
244 out.push(DocLine::RecordType(record.name.clone(), about));
245 }
246 }
247 let columns = doc.columns();
248 if !columns.is_empty() {
249 out.push(DocLine::Blank);
250 out.push(DocLine::Section("COLUMNS", columns.len()));
251 for (name, note) in &columns {
252 out.push(DocLine::Column {
253 name: name.clone(),
254 about: note.about(),
255 values: note.values.len(),
256 });
257 if expanded.contains(name) {
258 for (code, meaning) in ¬e.values {
259 let code = if code.is_empty() { "blank" } else { code };
260 out.push(DocLine::Legend(code.to_string(), meaning.clone()));
261 }
262 }
263 }
264 }
265 if let Some(spec) = spec {
266 field_section(&mut out, "FOOTER", &spec.footer);
267 }
268 if let Some(entry) = entry.filter(|e| !e.bookmarks.is_empty()) {
269 out.push(DocLine::Blank);
270 out.push(DocLine::Section("BOOKMARKS", entry.bookmarks.len()));
271 for (name, path) in &entry.bookmarks {
272 out.push(DocLine::Bookmark(name.clone(), path.clone()));
273 }
274 }
275 out
276}
277
278fn catalog_fields(out: &mut Vec<DocLine>, entry: &Dataset, catalog: &str, measured: Option<u64>) {
280 out.push(DocLine::Field("catalog", catalog.to_string()));
281 for (label, value) in [("publisher", &entry.publisher), ("license", &entry.license)] {
282 if !value.is_empty() {
283 out.push(DocLine::Field(label, value.clone()));
284 }
285 }
286 out.push(DocLine::Field("format", format_of(entry)));
287 match (&entry.path, &entry.url) {
288 (Some(_), _) => out.push(DocLine::Field(
289 "path",
290 crate::home::display_path(&entry.location()),
291 )),
292 (None, Some(url)) => {
293 out.push(DocLine::Link("url", url.clone()));
294 out.push(DocLine::Field("login", crate::home::login_of(entry)));
295 }
296 (None, None) => {}
297 }
298 match (measured, entry.size) {
299 (Some(size), _) => out.push(DocLine::Field("size", crate::discover::format_size(size))),
300 (None, Some(hint)) => out.push(DocLine::Field(
301 "size",
302 format!("~{}", crate::discover::format_size(hint)),
303 )),
304 (None, None) => {}
305 }
306}
307
308fn format_of(entry: &Dataset) -> String {
310 let location = entry.location();
311 let text = location.to_string_lossy();
312 if text.ends_with('/') || (entry.path.is_some() && location.is_dir()) {
313 return "directory".to_string();
314 }
315 crate::FileFormat::from_path(&location)
316 .map(|f| f.name().to_string())
317 .unwrap_or_else(|| {
318 if crate::catalog::is_object_store_dataset(&text) {
319 "directory".to_string()
320 } else {
321 "file".to_string()
322 }
323 })
324}
325
326#[derive(Debug, Clone, Default)]
328pub struct DocState {
329 pub doc: Option<Documented>,
330 pub measured: Option<u64>,
332 pub cursor: usize,
334 pub scroll: usize,
336 pub expanded: HashSet<String>,
338 pub view_height: usize,
340 pub links_open: bool,
342}
343
344impl DocState {
345 pub fn open(&mut self, doc: Documented, measured: Option<u64>) {
347 *self = Self {
348 doc: Some(doc),
349 measured,
350 ..Self::default()
351 };
352 self.cursor = self.first_focusable();
353 }
354
355 pub fn close(&mut self) {
356 *self = Self::default();
357 }
358
359 pub fn is_open(&self) -> bool {
360 self.doc.is_some()
361 }
362
363 pub fn lines(&self) -> Vec<DocLine> {
364 match &self.doc {
365 Some(doc) => lines(doc, &self.expanded, self.measured),
366 None => Vec::new(),
367 }
368 }
369
370 fn first_focusable(&self) -> usize {
371 self.lines()
372 .iter()
373 .position(DocLine::focusable)
374 .unwrap_or(0)
375 }
376
377 pub fn move_cursor(&mut self, delta: isize) {
379 let lines = self.lines();
380 let focusable: Vec<usize> = (0..lines.len()).filter(|&i| lines[i].focusable()).collect();
381 if focusable.is_empty() {
382 return;
383 }
384 let at = focusable
385 .iter()
386 .position(|&i| i >= self.cursor)
387 .unwrap_or(focusable.len() - 1) as isize;
388 let to = (at + delta).clamp(0, focusable.len() as isize - 1) as usize;
389 self.cursor = focusable[to];
390 }
391
392 pub fn toggle_legend(&mut self) -> bool {
395 let lines = self.lines();
396 let column = lines[..=self.cursor.min(lines.len().saturating_sub(1))]
398 .iter()
399 .rev()
400 .find_map(|line| match line {
401 DocLine::Column { name, values, .. } if *values > 0 => Some(name.clone()),
402 DocLine::Legend(..) => None,
403 _ => Some(String::new()),
404 })
405 .filter(|name| !name.is_empty());
406 let on_legend_or_column = matches!(
407 lines.get(self.cursor),
408 Some(DocLine::Legend(..)) | Some(DocLine::Column { values: 1.., .. })
409 );
410 let Some(column) = column.filter(|_| on_legend_or_column) else {
411 return false;
412 };
413 if !self.expanded.remove(&column) {
414 self.expanded.insert(column.clone());
415 }
416 let lines = self.lines();
418 if let Some(at) = lines
419 .iter()
420 .position(|l| matches!(l, DocLine::Column { name, values: 1.., .. } if *name == column))
421 && !self.expanded.contains(&column)
422 {
423 self.cursor = at;
424 }
425 true
426 }
427
428 pub fn link(&self) -> Option<String> {
431 match self.lines().get(self.cursor) {
432 Some(DocLine::Link(_, url)) => Some(url.clone()),
433 _ => None,
434 }
435 }
436
437 pub fn offers_open(&self) -> bool {
439 self.links_open && self.link().is_some()
440 }
441
442 pub fn copy_text(&self) -> Option<String> {
444 self.lines()
445 .get(self.cursor)
446 .and_then(DocLine::copy_text)
447 .map(str::to_string)
448 }
449}
450
451fn cut(text: &str, width: usize) -> String {
453 if text.width() <= width {
454 return text.to_string();
455 }
456 let ellipsis = glyphs::get().ellipsis;
457 let room = width.saturating_sub(ellipsis.width());
458 let mut out = String::new();
459 for c in text.chars() {
460 if out.width() + c.to_string().width() > room {
461 break;
462 }
463 out.push(c);
464 }
465 out.push_str(ellipsis);
466 out
467}
468
469fn wrap(text: &str, width: usize) -> Vec<String> {
471 let width = width.max(8);
472 let mut rows = Vec::new();
473 let mut row = String::new();
474 for word in text.split_whitespace() {
475 let candidate = if row.is_empty() {
476 word.to_string()
477 } else {
478 format!("{row} {word}")
479 };
480 if candidate.width() <= width || row.is_empty() {
481 row = candidate;
482 while row.width() > width {
483 let head: String = row.chars().take(width).collect();
485 let rest: String = row.chars().skip(width).collect();
486 rows.push(head);
487 row = rest;
488 }
489 } else {
490 rows.push(std::mem::take(&mut row));
491 row = word.to_string();
492 }
493 }
494 if !row.is_empty() || rows.is_empty() {
495 rows.push(row);
496 }
497 rows
498}
499
500fn layout(
502 lines: &[DocLine],
503 width: usize,
504 cursor: usize,
505 ctx: &RenderContext,
506) -> (Vec<Line<'static>>, Vec<usize>) {
507 let g = glyphs::get();
508 let key_w = lines
509 .iter()
510 .filter_map(|l| match l {
511 DocLine::Field(label, _) | DocLine::Link(label, _) => Some(label.len()),
512 _ => None,
513 })
514 .max()
515 .unwrap_or(8)
516 + 2;
517 let col_w = lines
518 .iter()
519 .filter_map(|l| match l {
520 DocLine::Column { name, .. }
521 | DocLine::Bookmark(name, _)
522 | DocLine::RecordType(name, _) => Some(name.width()),
523 _ => None,
524 })
525 .max()
526 .unwrap_or(0)
527 .min(width / 3)
528 + 2;
529 let label = Style::default().fg(ctx.label);
530 let plain = Style::default().fg(ctx.text_primary);
531 let dim = Style::default().fg(ctx.dimmed);
532 let inner = width.saturating_sub(1);
534 let mut rows: Vec<Line<'static>> = Vec::new();
535 let mut starts = Vec::with_capacity(lines.len());
536 for (i, line) in lines.iter().enumerate() {
537 starts.push(rows.len());
538 let focused = i == cursor;
539 let rail = if focused { g.rail } else { " " };
540 let rail = Span::styled(rail, Style::default().fg(ctx.accent));
541 let key_style = if focused {
542 Style::default().fg(ctx.accent)
543 } else {
544 label
545 };
546 let mut push = |spans: Vec<Span<'static>>, first: bool| {
547 let mut all = vec![if first { rail.clone() } else { Span::raw(" ") }];
548 all.extend(spans);
549 let mut line = Line::from(all);
550 if focused {
551 line = line.patch_style(ctx.highlight_style());
552 }
553 rows.push(line);
554 };
555 match line {
556 DocLine::Blank => rows.push(Line::from("")),
557 DocLine::Section(..) => rows.push(Line::from("")),
558 DocLine::About(text) => {
559 for (n, row) in wrap(text, inner).into_iter().enumerate() {
560 push(vec![Span::styled(row, plain)], n == 0);
561 }
562 }
563 DocLine::Field(key, value) => {
564 let room = inner.saturating_sub(key_w);
565 for (n, row) in wrap(value, room).into_iter().enumerate() {
566 let head = if n == 0 {
567 format!("{key:<key_w$}")
568 } else {
569 " ".repeat(key_w)
570 };
571 push(
572 vec![Span::styled(head, key_style), Span::styled(row, plain)],
573 n == 0,
574 );
575 }
576 }
577 DocLine::Link(key, url) => {
578 let room = inner.saturating_sub(key_w);
579 push(
580 vec![
581 Span::styled(format!("{key:<key_w$}"), key_style),
582 Span::styled(cut(url, room), plain.add_modifier(Modifier::UNDERLINED)),
583 ],
584 true,
585 );
586 }
587 DocLine::Column {
588 name,
589 about,
590 values,
591 } => {
592 let marker = if *values == 0 {
593 String::new()
594 } else {
595 let open = if i + 1 < lines.len() && matches!(lines[i + 1], DocLine::Legend(..))
596 {
597 g.expanded
598 } else {
599 g.collapsed
600 };
601 format!(" {}{values} values", open)
602 };
603 let room = inner.saturating_sub(col_w);
604 let text = format!("{about}{marker}");
605 for (n, row) in wrap(&text, room).into_iter().enumerate() {
606 let head = if n == 0 {
607 format!("{:<col_w$}", cut(name, col_w - 2))
608 } else {
609 " ".repeat(col_w)
610 };
611 push(
612 vec![Span::styled(head, key_style), Span::styled(row, plain)],
613 n == 0,
614 );
615 }
616 }
617 DocLine::RecordType(name, about) => {
618 let room = inner.saturating_sub(col_w);
619 for (n, row) in wrap(about, room).into_iter().enumerate() {
620 let head = if n == 0 {
621 format!("{:<col_w$}", cut(name, col_w - 2))
622 } else {
623 " ".repeat(col_w)
624 };
625 push(
626 vec![Span::styled(head, key_style), Span::styled(row, plain)],
627 n == 0,
628 );
629 }
630 }
631 DocLine::Legend(code, meaning) => {
632 let pad = col_w + 2;
633 let code_w = 6usize.max(code.width() + 2);
634 let room = inner.saturating_sub(pad + code_w);
635 for (n, row) in wrap(meaning, room).into_iter().enumerate() {
636 let head = if n == 0 {
637 format!("{}{code:<code_w$}", " ".repeat(pad))
638 } else {
639 " ".repeat(pad + code_w)
640 };
641 push(
642 vec![Span::styled(head, dim), Span::styled(row, plain)],
643 n == 0,
644 );
645 }
646 }
647 DocLine::Bookmark(name, path) => {
648 let room = inner.saturating_sub(col_w);
649 push(
650 vec![
651 Span::styled(format!("{:<col_w$}", cut(name, col_w - 2)), key_style),
652 Span::styled(cut(path, room), plain),
653 ],
654 true,
655 );
656 }
657 }
658 }
659 (rows, starts)
660}
661
662pub fn render_page(state: &mut DocState, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
665 if area.width < 10 || area.height == 0 {
666 return;
667 }
668 let area = Rect {
669 width: area.width.min(MEASURE),
670 ..area
671 };
672 let lines = state.lines();
673 state.cursor = state.cursor.min(lines.len().saturating_sub(1));
674 let (rows, starts) = layout(&lines, area.width as usize, state.cursor, ctx);
675 let height = area.height as usize;
676 state.view_height = height;
677 let first = starts.get(state.cursor).copied().unwrap_or(0);
679 let last = starts
680 .get(state.cursor + 1)
681 .copied()
682 .unwrap_or(rows.len())
683 .saturating_sub(1);
684 if first < state.scroll {
685 state.scroll = first;
686 }
687 if last >= state.scroll + height {
688 state.scroll = last + 1 - height.min(last + 1);
689 }
690 if state.scroll > 0 && state.cursor == state.first_focusable() {
691 state.scroll = 0;
692 }
693 let shown: Vec<Line> = rows
694 .iter()
695 .skip(state.scroll)
696 .take(height)
697 .cloned()
698 .collect();
699 Paragraph::new(shown).render(area, buf);
700 for (i, line) in lines.iter().enumerate() {
702 let DocLine::Section(title, count) = line else {
703 continue;
704 };
705 let Some(row) = starts[i].checked_sub(state.scroll).filter(|r| *r < height) else {
706 continue;
707 };
708 let chip = count.to_string();
709 SectionRule {
710 title,
711 chip: Some(&chip),
712 }
713 .render(
714 Rect {
715 x: area.x + 1,
716 y: area.y + row as u16,
717 width: area.width.saturating_sub(1),
718 height: 1,
719 },
720 buf,
721 ctx,
722 );
723 }
724}
725
726pub fn render_view(state: &mut DocState, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
729 let Some(title) = state.doc.as_ref().map(|d| d.title().to_string()) else {
730 return;
731 };
732 let mut footer = HintBar::from_ctx(ctx);
733 let lines = state.lines();
734 match lines.get(state.cursor) {
735 Some(DocLine::Column { values: 1.., .. }) | Some(DocLine::Legend(..)) => {
736 footer = footer.hint("Enter", "Values");
737 }
738 _ => {}
739 }
740 if state.offers_open() {
741 footer = footer.hint("o", "Open");
742 }
743 footer = footer.hint("y", "Copy").hint("Esc", "Back");
744 let title = format!("Documentation {} {title}", glyphs::get().trail);
745 let inner = Surface::new(&title).footer(&footer).render(area, buf, ctx);
746 render_page(state, inner, buf, ctx);
747}
748
749#[cfg(test)]
750mod tests {
751 use super::*;
752
753 fn noaa() -> Arc<Dataset> {
754 Arc::new(
755 crate::catalog::bundled()
756 .datasets
757 .into_iter()
758 .find(|d| d.id == "noaa")
759 .unwrap(),
760 )
761 }
762
763 fn page(entry: Arc<Dataset>) -> Documented {
764 Documented {
765 catalog: Some(("Example datasets".into(), entry)),
766 ..Documented::default()
767 }
768 }
769
770 fn screen(state: &mut DocState, width: u16, height: u16) -> Vec<String> {
771 let ctx = RenderContext::for_test();
772 let area = Rect::new(0, 0, width, height);
773 let mut buf = Buffer::empty(area);
774 render_view(state, area, &mut buf, &ctx);
775 (0..height)
776 .map(|y| {
777 (0..width)
778 .map(|x| buf[(x, y)].symbol().to_string())
779 .collect::<String>()
780 .trim_end()
781 .to_string()
782 })
783 .collect()
784 }
785
786 #[test]
787 fn a_link_is_one_line_cut_never_wrapped() {
788 let mut state = DocState::default();
789 state.open(page(noaa()), None);
790 let rows = screen(&mut state, 50, 40);
791 let text = rows.join("\n");
792 assert!(text.contains("publisher"), "{text}");
793 let docs: Vec<&String> = rows
794 .iter()
795 .filter(|r| r.contains("documentation"))
796 .collect();
797 assert_eq!(docs.len(), 1, "{text}");
798 assert!(docs[0].contains(glyphs::get().ellipsis), "{text}");
799 assert!(!text.contains("readme.txt"), "{text}");
800 assert!(text.contains("LINKS"), "{text}");
801 }
802
803 #[test]
804 fn y_copies_the_whole_link_and_enter_opens_a_legend() {
805 let mut state = DocState::default();
806 state.open(page(noaa()), None);
807 while !matches!(
808 state.lines()[state.cursor],
809 DocLine::Link("documentation", _)
810 ) {
811 state.move_cursor(1);
812 }
813 assert_eq!(
814 state.copy_text().as_deref(),
815 Some("https://www.ncei.noaa.gov/pub/data/ghcn/daily/readme.txt")
816 );
817 while !matches!(&state.lines()[state.cursor], DocLine::Column { name, .. } if name == "ELEMENT")
818 {
819 state.move_cursor(1);
820 }
821 assert!(state.toggle_legend());
822 let text = screen(&mut state, 100, 60).join("\n");
823 assert!(text.contains("PRCP"), "{text}");
824 state.move_cursor(3);
825 assert!(state.toggle_legend(), "closes from inside the legend");
826 assert!(!screen(&mut state, 100, 60).join("\n").contains("PRCP"));
827 assert!(
828 matches!(&state.lines()[state.cursor], DocLine::Column { name, .. } if name == "ELEMENT")
829 );
830 }
831
832 const ORDERS: &str = r#"
833name = "acme.orders"
834description = "Order entry capture"
835documentation = "https://example.com/orders.pdf"
836match = { glob = "*.ord" }
837
838[records]
839framing = "length_prefixed"
840size = "len"
841type = "kind"
842fields = [{ name = "len", type = "u2" }, { name = "kind", type = "str", size = 1 }]
843
844[[variants]]
845name = "add"
846when = "A"
847description = "An order added to the book"
848fields = [
849 { name = "price", type = "u4", scale = 4, description = "Limit price", unit = "USD" },
850 { name = "side", type = "u1", enum = { 1 = "BUY", 2 = "SELL" } },
851]
852
853[[variants]]
854name = "exec"
855when = ["E", "C"]
856fields = [{ name = "shares", type = "u4", description = "Shares executed" }]
857"#;
858
859 fn spec_page(text: &str) -> Documented {
860 let spec = crate::formats::Spec::parse(text, None).unwrap();
861 Documented::new(None, spec.docs().map(Arc::new), "day.ord".into()).unwrap()
862 }
863
864 #[test]
865 fn a_spec_read_from_a_file_names_its_file() {
866 let file = dirs::home_dir().unwrap().join("specs").join("orders.toml");
867 let spec = crate::formats::Spec::parse(ORDERS, Some(&file)).unwrap();
868 let doc = Documented::new(None, spec.docs().map(Arc::new), "day.ord".into()).unwrap();
869 let page = lines(&doc, &HashSet::new(), None);
870 let at = |line: &DocLine| page.iter().position(|l| l == line);
871 let name = at(&DocLine::Field("format spec", "acme.orders".into())).unwrap();
872 let shown = crate::home::display_path(&file);
873 assert!(shown.starts_with('~'), "{shown}");
874 assert_eq!(at(&DocLine::Field("spec file", shown)), Some(name + 1));
875 let parsed = lines(&spec_page(ORDERS), &HashSet::new(), None);
877 assert!(
878 !parsed
879 .iter()
880 .any(|l| matches!(l, DocLine::Field("spec file", _)))
881 );
882 }
883
884 #[test]
885 fn a_variant_specs_page_lists_record_types_columns_and_legends() {
886 let mut state = DocState::default();
887 state.open(spec_page(ORDERS), None);
888 let m = glyphs::get().middot;
889 let lines = state.lines();
890 assert_eq!(lines[0], DocLine::About("Order entry capture".into()));
891 assert!(lines.contains(&DocLine::Field("format spec", "acme.orders".into())));
892 assert!(lines.contains(&DocLine::Link(
893 "documentation",
894 "https://example.com/orders.pdf".into()
895 )));
896 assert!(lines.contains(&DocLine::Section("RECORD TYPES", 2)));
897 assert!(lines.contains(&DocLine::RecordType(
898 "add".into(),
899 format!("kind = \"A\" {m} 4 columns {m} An order added to the book")
900 )));
901 assert!(lines.contains(&DocLine::RecordType(
902 "exec".into(),
903 format!("kind in (\"E\", \"C\") {m} 3 columns")
904 )));
905 assert!(lines.contains(&DocLine::Section("COLUMNS", 3)));
906 assert!(lines.contains(&DocLine::Column {
907 name: "price".into(),
908 about: "Limit price (USD)".into(),
909 values: 0,
910 }));
911 while !matches!(&state.lines()[state.cursor], DocLine::Column { name, .. } if name == "side")
913 {
914 state.move_cursor(1);
915 }
916 assert!(state.toggle_legend());
917 assert!(
918 state
919 .lines()
920 .contains(&DocLine::Legend("2".into(), "SELL".into()))
921 );
922 let text = screen(&mut state, 100, 40).join("\n");
923 assert!(text.contains("Documentation"), "{text}");
924 assert!(text.contains("day.ord"), "{text}");
925 assert!(text.contains("RECORD TYPES"), "{text}");
926 assert!(!text.contains("catalog"), "{text}");
927 }
928
929 #[test]
930 fn a_delimited_specs_page_lists_its_column_notes() {
931 let doc = spec_page(
932 r#"
933name = "acme.log"
934kind = "delimited"
935description = "Instrument log"
936
937[columns]
938temp = { description = "Air temperature", unit = "deg F" }
939"#,
940 );
941 let lines = lines(&doc, &HashSet::new(), None);
942 assert!(
943 !lines
944 .iter()
945 .any(|l| matches!(l, DocLine::Section("RECORD TYPES", _)))
946 );
947 assert!(lines.contains(&DocLine::Column {
948 name: "temp".into(),
949 about: "Air temperature (deg F)".into(),
950 values: 0,
951 }));
952 }
953
954 #[test]
956 fn a_typed_column_shows_its_type_beside_its_unit() {
957 let doc = spec_page(
958 r#"
959name = "acme.log"
960kind = "delimited"
961
962[columns]
963Latitude = { type = "f64", unit = "deg", description = "GPS latitude" }
964LogIdx = { type = "i64" }
965"#,
966 );
967 let lines = lines(&doc, &HashSet::new(), None);
968 assert!(lines.contains(&DocLine::Column {
969 name: "Latitude".into(),
970 about: "f64 · deg GPS latitude".into(),
971 values: 0,
972 }));
973 assert!(lines.contains(&DocLine::Column {
974 name: "LogIdx".into(),
975 about: "i64".into(),
976 values: 0,
977 }));
978 }
979
980 #[test]
981 fn a_catalogs_word_stands_over_the_specs() {
982 let catalog = crate::catalog::parse(
983 r#"
984label = "Mine"
985
986[orders]
987name = "Orders"
988path = "/data/day.ord"
989description = "Orders from the lab"
990documentation = "https://example.com/lab.txt"
991columns.price = { description = "Price the lab quotes" }
992columns.venue = { description = "Where it traded" }
993"#,
994 "mine",
995 crate::catalog::Origin::Mine,
996 None,
997 )
998 .unwrap();
999 let entry = Arc::new(catalog.datasets.into_iter().next().unwrap());
1000 let spec = crate::formats::Spec::parse(ORDERS, None).unwrap();
1001 let doc = Documented::new(
1002 Some(("Mine".into(), entry)),
1003 spec.docs().map(Arc::new),
1004 "day.ord".into(),
1005 )
1006 .unwrap();
1007 assert_eq!(doc.title(), "Orders");
1008 let lines = lines(&doc, &HashSet::new(), None);
1009 assert_eq!(lines[0], DocLine::About("Orders from the lab".into()));
1010 assert!(lines.contains(&DocLine::Field("catalog", "Mine".into())));
1011 assert!(lines.contains(&DocLine::Field("format spec", "acme.orders".into())));
1012 let links: Vec<&DocLine> = lines
1013 .iter()
1014 .filter(|l| matches!(l, DocLine::Link("documentation", _)))
1015 .collect();
1016 assert_eq!(
1017 links,
1018 [&DocLine::Link(
1019 "documentation",
1020 "https://example.com/lab.txt".into()
1021 )]
1022 );
1023 assert!(lines.contains(&DocLine::Section("RECORD TYPES", 2)));
1024 let columns: Vec<(String, String)> = lines
1025 .iter()
1026 .filter_map(|l| match l {
1027 DocLine::Column { name, about, .. } => Some((name.clone(), about.clone())),
1028 _ => None,
1029 })
1030 .collect();
1031 assert_eq!(
1032 columns,
1033 [
1034 (
1035 "price".to_string(),
1036 "Price the lab quotes (USD)".to_string()
1037 ),
1038 ("side".into(), String::new()),
1039 ("shares".into(), "Shares executed".into()),
1040 ("venue".into(), "Where it traded".into()),
1041 ]
1042 );
1043 }
1044
1045 #[test]
1046 fn a_catalog_note_keeps_the_specs_legend_and_unit() {
1047 let over = ColumnNote {
1048 description: "Side of the book".into(),
1049 ..ColumnNote::default()
1050 };
1051 let under = ColumnNote {
1052 description: "Side".into(),
1053 unit: "flag".into(),
1054 values: vec![("1".into(), "BUY".into())],
1055 ty: String::new(),
1056 };
1057 assert_eq!(
1058 layered(&over, &under),
1059 ColumnNote {
1060 description: "Side of the book".into(),
1061 ..under.clone()
1062 }
1063 );
1064 let legend = ColumnNote {
1065 values: vec![("B".into(), "Buy".into())],
1066 ..ColumnNote::default()
1067 };
1068 assert_eq!(layered(&legend, &under).values, legend.values);
1069 assert_eq!(layered(&legend, &under).description, "Side");
1070 }
1071
1072 #[test]
1073 fn header_and_footer_fields_are_documented_in_their_own_sections() {
1074 let doc = spec_page(
1075 r#"
1076name = "acme.tape"
1077match = { glob = "*.tape" }
1078
1079[header]
1080fields = [
1081 { name = "magic", type = "str", size = 4 },
1082 { type = "pad", size = 4 },
1083 { name = "trade_date", type = "u4", description = "Session date" },
1084 { name = "tick", type = "u4", unit = "ns" },
1085]
1086
1087[records]
1088fields = [{ name = "px", type = "u4", description = "Price" }]
1089
1090[footer]
1091fields = [{ name = "rows", type = "u4", description = "Records written" }]
1092"#,
1093 );
1094 let lines = lines(&doc, &HashSet::new(), None);
1095 let at = |line: &DocLine| lines.iter().position(|l| l == line).unwrap();
1096 let column = |name: &str, about: &str| DocLine::Column {
1097 name: name.into(),
1098 about: about.into(),
1099 values: 0,
1100 };
1101 let header = at(&DocLine::Section("HEADER", 2));
1102 assert_eq!(
1103 lines[header + 1..header + 3],
1104 [column("trade_date", "Session date"), column("tick", "ns")]
1105 );
1106 let columns = at(&DocLine::Section("COLUMNS", 1));
1107 let footer = at(&DocLine::Section("FOOTER", 1));
1108 assert!(header < columns && columns < footer);
1109 assert_eq!(lines[footer + 1], column("rows", "Records written"));
1110 let bare = spec_page(
1112 "name = \"a.b\"\n[header]\nfields = [{ name = \"v\", type = \"u1\", description = \"Version\" }]\n[records]\nfields = [{ name = \"x\", type = \"u1\" }]\n[footer]\nfields = [{ name = \"n\", type = \"u4\" }]\n",
1113 );
1114 let lines = super::lines(&bare, &HashSet::new(), None);
1115 assert!(lines.contains(&DocLine::Section("HEADER", 1)));
1116 assert!(
1117 !lines
1118 .iter()
1119 .any(|l| matches!(l, DocLine::Section("FOOTER", _)))
1120 );
1121 }
1122
1123 #[test]
1124 fn the_cursor_stays_in_view_and_bookmarks_are_listed() {
1125 let mut state = DocState::default();
1126 state.open(page(noaa()), None);
1127 state.move_cursor(isize::MAX / 2);
1128 let text = screen(&mut state, 80, 16).join("\n");
1129 assert!(text.contains("Central Park, NY"), "{text}");
1130 }
1131}