use duckfn::declared_function_descriptions;
#[duckfn::duck_scalar_function(
description = "Doubles an INTEGER",
comment = "NULL in, NULL out",
example = "SELECT docs_double_it(21)"
)]
fn docs_double_it(v: Option<i64>) -> Option<i64> {
v.map(|x| x * 2)
}
#[duckfn::duck_scalar_function(
description = "Adds two INTEGERs",
examples = [
"SELECT docs_add_two(i, 1) FROM (VALUES (1), (2), (3)) v(i) ORDER BY i",
"SELECT docs_add_two(i, j) FROM (VALUES (1, 2), (3, 4)) v(i, j);"
]
)]
fn docs_add_two(a: i64, b: i64) -> i64 {
a + b
}
#[duckfn::duck_scalar_function]
fn docs_undocumented(v: i64) -> i64 {
v
}
#[duckfn::duck_scalar_function(
description = "Comma, \"quote\", and\na newline",
comment = " padded ",
example = "SELECT docs_special('a,b') -- say \"hi\" {{ }} {% raw %}"
)]
fn docs_special() -> i64 {
0
}
#[duckfn::duck_scalar_function(
description = "把 INTEGER 翻倍",
example = "SELECT docs_unicode()"
)]
fn docs_unicode() -> i64 {
0
}
#[duckfn::duck_scalar_function(
overloads_name = "docs_overloaded",
description = "Overloaded: INTEGER input",
example = "SELECT docs_overloaded(i) FROM (VALUES (1), (2)) v(i)"
)]
fn docs_overload_int(v: i64) -> i64 {
v
}
#[duckfn::duck_scalar_function(
overloads_name = "docs_overloaded",
comment = "Also accepts VARCHAR",
examples = [
"SELECT docs_overloaded(i) FROM (VALUES (1), (2)) v(i)",
"SELECT docs_overloaded(s) FROM (VALUES ('a'), ('b')) v(s);"
]
)]
fn docs_overload_str(v: String) -> String {
v
}
#[cfg(feature = "cli")]
const EXPECTED_DEFAULT_CSV: &str = concat!(
"function,description,comment,example\n",
"docs_add_two,Adds two INTEGERs,,\"SELECT docs_add_two(i, 1) FROM (VALUES (1), (2), (3)) v(i) ORDER BY i; SELECT docs_add_two(i, j) FROM (VALUES (1, 2), (3, 4)) v(i, j)\"\n",
"docs_double_it,Doubles an INTEGER,\"NULL in, NULL out\",SELECT docs_double_it(21)\n",
"docs_overloaded,Overloaded: INTEGER input,Also accepts VARCHAR,\"SELECT docs_overloaded(i) FROM (VALUES (1), (2)) v(i); SELECT docs_overloaded(s) FROM (VALUES ('a'), ('b')) v(s)\"\n",
"docs_special,\"Comma, \"\"quote\"\", and a newline\", padded ,\"SELECT docs_special('a,b') -- say \"\"hi\"\" {{ }} {% raw %}\"\n",
"docs_unicode,把 INTEGER 翻倍,,SELECT docs_unicode()\n",
);
#[test]
fn declared_documentation_is_collected() {
let rows = declared_function_descriptions();
let find = |name: &str| {
rows.iter()
.find(|row| row.function == name)
.unwrap_or_else(|| panic!("`{name}` is missing from the collected documentation"))
};
let double_it = find("docs_double_it");
assert_eq!(double_it.description.as_deref(), Some("Doubles an INTEGER"));
assert_eq!(double_it.comment.as_deref(), Some("NULL in, NULL out"));
assert_eq!(
double_it.examples,
vec!["SELECT docs_double_it(21)".to_string()]
);
assert!(double_it.is_documented());
let add_two = find("docs_add_two");
assert_eq!(add_two.description.as_deref(), Some("Adds two INTEGERs"));
assert_eq!(add_two.comment, None);
assert_eq!(
add_two.examples,
vec![
"SELECT docs_add_two(i, 1) FROM (VALUES (1), (2), (3)) v(i) ORDER BY i".to_string(),
"SELECT docs_add_two(i, j) FROM (VALUES (1, 2), (3, 4)) v(i, j);".to_string()
]
);
let undocumented = find("docs_undocumented");
assert!(!undocumented.is_documented());
assert_eq!(undocumented.description, None);
assert_eq!(undocumented.comment, None);
assert!(undocumented.examples.is_empty());
let special = find("docs_special");
assert_eq!(
special.description.as_deref(),
Some("Comma, \"quote\", and\na newline")
);
assert_eq!(special.comment.as_deref(), Some(" padded "));
assert_eq!(find("docs_unicode").description.as_deref(), Some("把 INTEGER 翻倍"));
assert!(
rows.iter().all(|row| row.function != "docs_overload_int"
&& row.function != "docs_overload_str"),
"overloads must be reported under the function-set name, not the Rust function names"
);
let overloaded: Vec<_> = rows
.iter()
.filter(|row| row.function == "docs_overloaded")
.collect();
assert_eq!(overloaded.len(), 1, "the overload set must collapse into one row");
let overloaded = overloaded[0];
assert_eq!(
overloaded.description.as_deref(),
Some("Overloaded: INTEGER input")
);
assert_eq!(overloaded.comment.as_deref(), Some("Also accepts VARCHAR"));
assert_eq!(
overloaded.examples,
vec![
"SELECT docs_overloaded(i) FROM (VALUES (1), (2)) v(i)".to_string(),
"SELECT docs_overloaded(s) FROM (VALUES ('a'), ('b')) v(s);".to_string()
]
);
}
#[cfg(feature = "cli")]
const OWN_PREFIX: &str = "docs_";
#[cfg(feature = "cli")]
fn own_csv(text: &str) -> String {
text.lines()
.filter(|line| line.starts_with("function,") || line.starts_with(OWN_PREFIX))
.map(|line| format!("{line}\n"))
.collect()
}
#[cfg(feature = "cli")]
#[test]
fn export_writes_documented_rows_only() {
let dir = temp_dir("default");
let summary = duckfn::cli::function_descriptions::export(&dir, false).expect("export must work");
let path = duckfn::cli::function_descriptions::output_path(&dir, false);
let text = std::fs::read_to_string(&path).expect("the CSV must be readable");
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some("function_descriptions.csv")
);
let declared = declared_function_descriptions();
assert_eq!(
summary.written,
declared.iter().filter(|row| row.is_documented()).count()
);
assert_eq!(summary.without_description, 0);
assert_eq!(
summary.skipped,
declared.len() - summary.written,
"every undocumented function must be skipped"
);
let own = own_csv(&text);
assert_eq!(own, EXPECTED_DEFAULT_CSV);
assert!(
!text.lines().any(|line| line.starts_with("docs_undocumented")),
"the undocumented function must not be exported by default"
);
assert_eq!(own.lines().count(), 6, "header + 5 rows, no multi-line field");
assert!(!text.contains('\r'));
assert!(text.ends_with('\n'));
}
#[cfg(feature = "cli")]
#[test]
fn export_all_includes_undocumented_rows() {
let dir = temp_dir("all");
let summary = duckfn::cli::function_descriptions::export(&dir, true).expect("export must work");
let path = duckfn::cli::function_descriptions::output_path(&dir, true);
let text = std::fs::read_to_string(&path).expect("the CSV must be readable");
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some("function_descriptions_all.csv")
);
let declared = declared_function_descriptions();
assert_eq!(summary.written, declared.len());
assert_eq!(
summary.without_description,
declared
.iter()
.filter(|row| row.description.is_none())
.count()
);
assert_eq!(summary.skipped, 0);
let expected = EXPECTED_DEFAULT_CSV.replace(
"docs_unicode,",
"docs_undocumented,,,\ndocs_unicode,",
);
let own = own_csv(&text);
assert_eq!(own, expected);
assert_eq!(own.lines().count(), 7, "header + 6 rows, no multi-line field");
}
#[cfg(feature = "cli")]
#[test]
fn csv_round_trips_through_duckdb() {
let Some(duckdb) = duckdb_binary() else {
eprintln!("skipping csv_round_trips_through_duckdb: no `duckdb` CLI on PATH");
return;
};
let dir = temp_dir("roundtrip");
duckfn::cli::function_descriptions::export(&dir, true).expect("export must work");
let path = duckfn::cli::function_descriptions::output_path(&dir, true);
let source = format!("read_csv('{}')", sql_path(&path));
let select = |expr: &str, function: &str| {
duckdb_scalar(
&duckdb,
&format!("SELECT {expr} FROM {source} WHERE function = '{function}'"),
)
};
assert_eq!(
select("description", "docs_special"),
"Comma, \"quote\", and a newline"
);
assert_eq!(select("comment", "docs_special"), " padded ");
assert_eq!(
select("example", "docs_special"),
"SELECT docs_special('a,b') -- say \"hi\" {{ }} {% raw %}"
);
assert_eq!(select("description", "docs_unicode"), "把 INTEGER 翻倍");
assert_eq!(select("contains(description, chr(10))", "docs_special"), "false");
assert_eq!(select("contains(description, chr(13))", "docs_special"), "false");
assert_eq!(
select("example", "docs_add_two"),
"SELECT docs_add_two(i, 1) FROM (VALUES (1), (2), (3)) v(i) ORDER BY i; \
SELECT docs_add_two(i, j) FROM (VALUES (1, 2), (3, 4)) v(i, j)"
);
assert_eq!(
select("'[' || example || ']'", "docs_add_two"),
"[SELECT docs_add_two(i, 1) FROM (VALUES (1), (2), (3)) v(i) ORDER BY i; \
SELECT docs_add_two(i, j) FROM (VALUES (1, 2), (3, 4)) v(i, j)]"
);
assert_eq!(
duckdb_scalar(
&duckdb,
&format!("SELECT count(*) FROM {source} WHERE function = 'docs_overloaded'"),
),
"1"
);
assert_eq!(
select("description", "docs_overloaded"),
"Overloaded: INTEGER input"
);
assert_eq!(
select("example", "docs_overloaded"),
"SELECT docs_overloaded(i) FROM (VALUES (1), (2)) v(i); \
SELECT docs_overloaded(s) FROM (VALUES ('a'), ('b')) v(s)"
);
assert_eq!(select("comment IS NULL", "docs_undocumented"), "true");
assert_eq!(select("comment IS NULL", "docs_double_it"), "false");
assert_eq!(
duckdb_scalar(
&duckdb,
&format!(
"SELECT string_agg(function, ',') FROM \
(SELECT function FROM {source} WHERE starts_with(function, 'docs_') ORDER BY 1)"
),
),
"docs_add_two,docs_double_it,docs_overloaded,docs_special,docs_undocumented,docs_unicode"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(feature = "cli")]
fn temp_dir(label: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("duckfn_doc_test_{label}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[cfg(feature = "cli")]
fn duckdb_binary() -> Option<String> {
let candidate = std::env::var("DUCKDB").unwrap_or_else(|_| "duckdb".to_string());
let ok = std::process::Command::new(&candidate)
.arg("--version")
.output()
.is_ok_and(|output| output.status.success());
ok.then_some(candidate)
}
#[cfg(feature = "cli")]
fn duckdb_scalar(duckdb: &str, sql: &str) -> String {
let output = std::process::Command::new(duckdb)
.args(["-noheader", "-list", "-c", sql])
.output()
.expect("running duckdb");
assert!(
output.status.success(),
"duckdb failed: {}\nSQL: {sql}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout)
.expect("duckdb writes UTF-8")
.trim_end_matches(['\r', '\n'])
.to_string()
}
#[cfg(feature = "cli")]
fn sql_path(path: &std::path::Path) -> String {
path.display().to_string().replace('\\', "/").replace('\'', "''")
}