use std::io::{self, Write};
use crate::diagnostic::Diagnostic;
use crate::util::text::replace_control_chars;
use super::MetaContext;
pub(crate) fn csv_field(s: &str) -> String {
let needs_quoting = s.contains(',')
|| s.contains('"')
|| s.contains('\n')
|| matches!(s.chars().next(), Some('=' | '+' | '-' | '@'));
if needs_quoting {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
pub(crate) fn render_table_disclosure(err: &mut dyn Write, meta: &MetaContext) -> io::Result<()> {
match &meta.filter_warning {
None => writeln!(err, "[{} on {}]", meta.auth_state, meta.server_label),
Some(fw) => writeln!(err, "[{} on {}] — {fw}", meta.auth_state, meta.server_label),
}
}
pub(crate) fn render_prose_footer(out: &mut dyn Write, meta: &MetaContext) -> io::Result<()> {
writeln!(out)?;
match &meta.filter_warning {
None => writeln!(out, "[{} on {}]", meta.auth_state, meta.server_label),
Some(fw) => writeln!(out, "[{} on {}] — {fw}", meta.auth_state, meta.server_label),
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum HeaderMode {
#[default]
On,
Off,
Only,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum QuoteMode {
Csv,
Tsv,
Lines,
}
impl QuoteMode {
fn sep(self) -> &'static str {
match self {
QuoteMode::Csv => ",",
QuoteMode::Tsv | QuoteMode::Lines => "\t",
}
}
fn apply(self, s: &str) -> String {
match self {
QuoteMode::Csv => csv_field(&replace_control_chars(s)),
QuoteMode::Tsv | QuoteMode::Lines => replace_control_chars(s),
}
}
}
#[derive(Debug, Default)]
pub struct TableOptions {
pub columns: Option<Vec<String>>,
pub header: HeaderMode,
}
impl TableOptions {
pub fn projected(&self) -> Option<Vec<&str>> {
self.columns
.as_ref()
.map(|c| c.iter().map(String::as_str).collect())
}
}
pub(crate) const PROJECTS_COLUMNS: &[&str] = &[
"shortcode",
"shortname",
"longname",
"status",
"data_models",
"iri",
];
pub(crate) const DATA_MODELS_COLUMNS: &[&str] =
&["name", "iri", "label", "last_modified", "is_builtin"];
pub(crate) const DATA_MODEL_DESCRIBE_COLUMNS: &[&str] =
&["name", "iri", "label", "last_modified", "resource_types"];
pub(crate) const RESOURCE_TYPES_COLUMNS: &[&str] = &["name", "iri", "label", "is_builtin"];
pub(crate) const RESOURCE_TYPE_DESCRIBE_COLUMNS: &[&str] = &[
"name",
"iri",
"value_type",
"link_target",
"cardinality",
"label",
"is_builtin",
"data_model",
];
pub(crate) const RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS: &[&str] = &[
"name",
"value_type",
"link_target",
"cardinality",
"label",
"is_builtin",
"data_model",
];
pub(crate) const RESOURCE_LIST_COLUMNS: &[&str] = &[
"label",
"iri",
"ark_url",
"creation_date",
"last_modified",
"resource_type",
];
pub(crate) const RESOURCE_DESCRIBE_COLUMNS: &[&str] = &[
"label",
"iri",
"resource_type",
"ark_url",
"creation_date",
"last_modified",
"attached_project",
"owner",
"visibility",
"your_access",
];
pub(crate) const RESOURCE_DESCRIBE_VALUES_COLUMNS: &[&str] = &[
"label",
"iri",
"field",
"field_label",
"value_type",
"value",
"comment",
];
pub(crate) const RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS: &[&str] =
&["field", "field_label", "value_type", "value"];
pub(crate) const DATA_MODEL_STRUCTURE_COLUMNS: &[&str] =
&["source", "target", "kind", "field", "target_data_model"];
pub(crate) const AUTH_LOGIN_COLUMNS: &[&str] = &["server", "user", "expires_at", "state"];
pub(crate) const AUTH_LOGOUT_COLUMNS: &[&str] = &["server", "was_cached"];
pub(crate) const PROJECT_DUMP_COLUMNS: &[&str] = &["path"];
pub(crate) const PROJECT_DUMP_DELETED_COLUMNS: &[&str] = &["deleted"];
pub(crate) struct TableSpec<'a> {
pub all_columns: &'a [&'a str],
pub rows: &'a [Vec<String>],
pub default_columns: Option<&'a [&'a str]>,
pub projected: Option<Vec<&'a str>>,
pub quote: QuoteMode,
pub header: HeaderMode,
}
pub(crate) fn render_table(out: &mut dyn Write, spec: &TableSpec<'_>) -> Result<(), Diagnostic> {
debug_assert!(
spec.default_columns
.map(|defs| defs.iter().all(|n| spec.all_columns.contains(n)))
.unwrap_or(true),
"default_columns must be a subset of all_columns (internal invariant)"
);
let effective_columns: &[&str] = if let Some(ref proj) = spec.projected {
for name in proj.iter() {
if !spec.all_columns.contains(name) {
let valid = spec.all_columns.join(", ");
return Err(Diagnostic::Usage(format!(
"unknown column \"{name}\"; valid columns: {valid}"
)));
}
}
proj.as_slice()
} else if let Some(defaults) = spec.default_columns {
defaults
} else {
spec.all_columns
};
let sep = spec.quote.sep();
let col_indices: Vec<usize> = effective_columns
.iter()
.map(|name| {
spec.all_columns
.iter()
.position(|c| c == name)
.ok_or_else(|| {
Diagnostic::Internal(format!(
"column index missing for \"{name}\" after validation \
(all_columns=[{}]); this is a dsp-cli bug",
spec.all_columns.join(", ")
))
})
})
.collect::<Result<Vec<_>, _>>()?;
if matches!(spec.header, HeaderMode::On | HeaderMode::Only) {
let header_line = effective_columns.join(sep);
writeln!(out, "{header_line}")?;
}
if !matches!(spec.header, HeaderMode::Only) {
for row in spec.rows {
let mut cells: Vec<String> = Vec::with_capacity(col_indices.len());
for &idx in &col_indices {
let value = row.get(idx).ok_or_else(|| {
Diagnostic::Internal(format!(
"row has {} cells, expected {} (column index {idx} out of \
range); this is a dsp-cli bug",
row.len(),
spec.all_columns.len()
))
})?;
cells.push(spec.quote.apply(value));
}
writeln!(out, "{}", cells.join(sep))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn render_table_row(fields: &[&str], sep: &str, quote: impl Fn(&str) -> String) -> String {
fields
.iter()
.map(|f| quote(f))
.collect::<Vec<_>>()
.join(sep)
}
#[test]
fn table_disclosure_anonymous_prod() {
let meta = MetaContext {
auth_state: "anonymous".to_string(),
server_label: "prod".to_string(),
filter_warning: None,
};
let mut buf: Vec<u8> = Vec::new();
render_table_disclosure(&mut buf, &meta).unwrap();
assert_eq!(buf, b"[anonymous on prod]\n");
}
#[test]
fn prose_footer_authenticated_test() {
let meta = MetaContext {
auth_state: "authenticated as you@dasch.swiss".to_string(),
server_label: "test".to_string(),
filter_warning: None,
};
let mut buf: Vec<u8> = Vec::new();
render_prose_footer(&mut buf, &meta).unwrap();
assert_eq!(buf, b"\n[authenticated as you@dasch.swiss on test]\n");
}
#[test]
fn table_row_csv_no_quoting_needed() {
let result = render_table_row(&["abc", "def", "ghi"], ",", csv_field);
assert_eq!(result, "abc,def,ghi");
}
#[test]
fn table_row_csv_comma_in_field() {
let result = render_table_row(&["foo", "bar,baz", "qux"], ",", csv_field);
assert_eq!(result, r#"foo,"bar,baz",qux"#);
}
#[test]
fn table_row_csv_quote_in_field() {
let result = render_table_row(&[r#"say "hello""#], ",", csv_field);
assert_eq!(result, r#""say ""hello""" "#.trim());
}
#[test]
fn table_row_csv_newline_in_field() {
let result = render_table_row(&["line1\nline2"], ",", csv_field);
assert_eq!(result, "\"line1\nline2\"");
}
#[test]
fn table_row_identity_tab_separator() {
let result = render_table_row(&["alpha", "beta", "gamma"], "\t", |s: &str| s.to_string());
assert_eq!(result, "alpha\tbeta\tgamma");
}
#[test]
fn table_row_identity_comma_separator() {
let result = render_table_row(&["a,b", "c"], ",", |s: &str| s.to_string());
assert_eq!(result, "a,b,c");
}
#[test]
fn table_row_empty_fields() {
let result = render_table_row(&["", "x", ""], "\t", |s: &str| s.to_string());
assert_eq!(result, "\tx\t");
}
#[test]
fn csv_field_plain_string() {
assert_eq!(csv_field("hello"), "hello");
}
#[test]
fn csv_field_contains_comma() {
assert_eq!(csv_field("a,b"), "\"a,b\"");
}
#[test]
fn csv_field_contains_quote() {
assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
}
#[test]
fn csv_field_contains_newline() {
assert_eq!(csv_field("a\nb"), "\"a\nb\"");
}
#[test]
fn csv_field_leading_equals_formula_injection() {
assert_eq!(csv_field("=SUM(A1:A10)"), "\"=SUM(A1:A10)\"");
}
#[test]
fn csv_field_leading_plus_formula_injection() {
assert_eq!(csv_field("+1"), "\"+1\"");
}
#[test]
fn csv_field_leading_minus_formula_injection() {
assert_eq!(csv_field("-1"), "\"-1\"");
}
#[test]
fn csv_field_leading_at_formula_injection() {
assert_eq!(csv_field("@SUM"), "\"@SUM\"");
}
#[test]
fn csv_field_not_leading_equals() {
assert_eq!(csv_field("a=b"), "a=b");
}
#[test]
fn csv_field_empty_string() {
assert_eq!(csv_field(""), "");
}
fn make_spec<'a>(
all: &'a [&'a str],
rows: &'a [Vec<String>],
projected: Option<Vec<&'a str>>,
defaults: Option<&'a [&'a str]>,
quote: QuoteMode,
header: HeaderMode,
) -> TableSpec<'a> {
TableSpec {
all_columns: all,
rows,
default_columns: defaults,
projected,
quote,
header,
}
}
#[test]
fn header_mode_on_non_empty_rows() {
let all = &["a", "b"];
let rows = vec![
vec!["1".to_string(), "2".to_string()],
vec!["3".to_string(), "4".to_string()],
];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::On),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "a\tb\n1\t2\n3\t4\n");
}
#[test]
fn header_mode_on_empty_rows() {
let all = &["x", "y"];
let rows: Vec<Vec<String>> = vec![];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::On),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "x\ty\n");
}
#[test]
fn header_mode_off_non_empty_rows() {
let all = &["a", "b"];
let rows = vec![vec!["1".to_string(), "2".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Off),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "1\t2\n");
}
#[test]
fn header_mode_off_empty_rows_yields_zero_bytes() {
let all = &["a", "b"];
let rows: Vec<Vec<String>> = vec![];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Off),
)
.unwrap();
assert_eq!(buf, b"");
}
#[test]
fn header_mode_only_non_empty_rows() {
let all = &["a", "b"];
let rows = vec![vec!["1".to_string(), "2".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Only),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "a\tb\n");
}
#[test]
fn header_mode_only_empty_rows() {
let all = &["a", "b"];
let rows: Vec<Vec<String>> = vec![];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Only),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "a\tb\n");
}
#[test]
fn lines_mode_off_emits_no_header() {
let all = &["name", "label"];
let rows = vec![vec!["foo".to_string(), "bar".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Lines, HeaderMode::Off),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "foo\tbar\n");
}
#[test]
fn lines_mode_applies_replace_control_chars() {
let all = &["name"];
let rows = vec![vec!["a\tb".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Lines, HeaderMode::Off),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "a b\n");
}
#[test]
fn csv_mode_neutralises_control_chars() {
let all = &["name"];
let rows = vec![vec!["a\u{1b}\t\n\u{7f}b".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Csv, HeaderMode::Off),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "a b\n");
}
#[test]
fn tsv_mode_neutralises_control_chars() {
let all = &["name"];
let rows = vec![vec!["a\u{1b}\u{7f}b".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Off),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "a b\n");
}
#[test]
fn tsv_embedded_tab_does_not_split_column() {
let all = &["a", "b"];
let rows = vec![vec!["x\ty".to_string(), "z".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::Off),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "x y\tz\n");
}
#[test]
fn csv_leading_control_then_formula_char_not_quoted() {
let all = &["name"];
let rows = vec![vec!["\u{1b}=SUM(A1)".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Csv, HeaderMode::Off),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), " =SUM(A1)\n");
}
#[test]
fn projection_selects_subset() {
let all = &["a", "b", "c"];
let rows = vec![vec!["1".to_string(), "2".to_string(), "3".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(
all,
&rows,
Some(vec!["a", "c"]),
None,
QuoteMode::Tsv,
HeaderMode::On,
),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "a\tc\n1\t3\n");
}
#[test]
fn projection_reorders_columns() {
let all = &["a", "b", "c"];
let rows = vec![vec!["1".to_string(), "2".to_string(), "3".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(
all,
&rows,
Some(vec!["c", "a"]),
None,
QuoteMode::Tsv,
HeaderMode::On,
),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "c\ta\n3\t1\n");
}
#[test]
fn projection_csv_quoting_applied_to_data_cells() {
let all = &["name", "note"];
let rows = vec![vec!["foo".to_string(), "a,b".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(
all,
&rows,
Some(vec!["name", "note"]),
None,
QuoteMode::Csv,
HeaderMode::On,
),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "name,note\nfoo,\"a,b\"\n");
}
#[test]
fn projection_header_cells_are_plain_literals() {
let all = &["shortcode", "longname"];
let rows = vec![vec!["0001".to_string(), "Project One".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Csv, HeaderMode::On),
)
.unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
"shortcode,longname\n0001,Project One\n"
);
}
#[test]
fn unknown_column_returns_usage_error_with_valid_names() {
let all = &["shortcode", "shortname", "iri"];
let rows: Vec<Vec<String>> = vec![];
let mut buf: Vec<u8> = Vec::new();
let result = render_table(
&mut buf,
&make_spec(
all,
&rows,
Some(vec!["shortcode", "xyz"]),
None,
QuoteMode::Csv,
HeaderMode::On,
),
);
let err = result.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("\"xyz\""), "message: {msg}");
assert!(msg.contains("shortcode"), "message: {msg}");
assert!(msg.contains("shortname"), "message: {msg}");
assert!(msg.contains("iri"), "message: {msg}");
}
#[test]
fn unknown_column_error_before_any_output() {
let all = &["a", "b"];
let rows = vec![vec!["1".to_string(), "2".to_string()]];
let mut buf: Vec<u8> = Vec::new();
let result = render_table(
&mut buf,
&make_spec(
all,
&rows,
Some(vec!["a", "z"]),
None,
QuoteMode::Csv,
HeaderMode::On,
),
);
assert!(result.is_err());
assert_eq!(buf, b"", "output must be empty when validation fails");
}
#[test]
fn unknown_column_error_before_output_with_header_only_mode() {
let all = &["a", "b"];
let rows: Vec<Vec<String>> = vec![];
let mut buf: Vec<u8> = Vec::new();
let result = render_table(
&mut buf,
&make_spec(
all,
&rows,
Some(vec!["a", "unknown"]),
None,
QuoteMode::Csv,
HeaderMode::Only,
),
);
assert!(result.is_err());
assert_eq!(buf, b"", "no header must be emitted before error");
}
#[test]
fn projected_overrides_default_columns() {
let all = &["a", "b"];
let rows = vec![vec!["val_a".to_string(), "val_b".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(
all,
&rows,
Some(vec!["b"]),
Some(&["a"]),
QuoteMode::Tsv,
HeaderMode::On,
),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "b\nval_b\n");
}
#[test]
fn default_columns_used_when_no_projection() {
let all = &["a", "b"];
let rows = vec![vec!["val_a".to_string(), "val_b".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(
all,
&rows,
None,
Some(&["a"]),
QuoteMode::Tsv,
HeaderMode::On,
),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "a\nval_a\n");
}
#[test]
fn default_columns_none_means_all_columns() {
let all = &["a", "b"];
let rows = vec![vec!["1".to_string(), "2".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, None, QuoteMode::Tsv, HeaderMode::On),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "a\tb\n1\t2\n");
}
#[test]
fn default_columns_some_empty_means_zero_columns() {
let all = &["a", "b"];
let rows = vec![vec!["1".to_string(), "2".to_string()]];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, Some(&[]), QuoteMode::Tsv, HeaderMode::Off),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "\n");
}
#[test]
fn default_columns_some_empty_with_header_on() {
let all = &["a", "b"];
let rows = vec![
vec!["1".to_string(), "2".to_string()],
vec!["3".to_string(), "4".to_string()],
];
let mut buf: Vec<u8> = Vec::new();
render_table(
&mut buf,
&make_spec(all, &rows, None, Some(&[]), QuoteMode::Csv, HeaderMode::On),
)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "\n\n\n");
}
}