use faucet_common_sftp::SftpConnectionConfig;
use faucet_core::DEFAULT_BATCH_SIZE;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SftpFormat {
#[default]
Jsonl,
JsonArray,
RawText,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SftpSourceConfig {
#[serde(flatten)]
pub connection: SftpConnectionConfig,
pub path: String,
#[serde(default)]
pub glob: Option<String>,
#[serde(default)]
pub format: SftpFormat,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
}
fn default_batch_size() -> usize {
DEFAULT_BATCH_SIZE
}
impl SftpSourceConfig {
pub fn new(connection: SftpConnectionConfig, path: impl Into<String>) -> Self {
Self {
connection,
path: path.into(),
glob: None,
format: SftpFormat::default(),
batch_size: DEFAULT_BATCH_SIZE,
}
}
pub fn glob(mut self, glob: impl Into<String>) -> Self {
self.glob = Some(glob.into());
self
}
pub fn format(mut self, format: SftpFormat) -> Self {
self.format = format;
self
}
pub fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
}
pub fn glob_match(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
let (mut pi, mut ni) = (0usize, 0usize);
let (mut star, mut star_n) = (None, 0usize);
while ni < n.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
pi += 1;
ni += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
star_n = ni;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
star_n += 1;
ni = star_n;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
#[cfg(test)]
mod tests {
use super::*;
use faucet_common_sftp::SftpConnectionConfig;
fn conn() -> SftpConnectionConfig {
SftpConnectionConfig::with_password("h", "u", "p")
}
#[test]
fn defaults() {
let cfg = SftpSourceConfig::new(conn(), "/data");
assert_eq!(cfg.path, "/data");
assert!(cfg.glob.is_none());
assert_eq!(cfg.format, SftpFormat::Jsonl);
assert_eq!(cfg.batch_size, DEFAULT_BATCH_SIZE);
}
#[test]
fn format_default_is_jsonl() {
assert_eq!(SftpFormat::default(), SftpFormat::Jsonl);
}
#[test]
fn deserializes_flat_shape() {
let json = r#"{
"host": "sftp.example.com",
"port": 2222,
"username": "user",
"type": "password",
"config": { "password": "secret" },
"path": "/incoming",
"glob": "*.jsonl",
"format": "jsonl",
"batch_size": 250
}"#;
let cfg: SftpSourceConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.connection.host, "sftp.example.com");
assert_eq!(cfg.connection.port, 2222);
assert_eq!(cfg.path, "/incoming");
assert_eq!(cfg.glob.as_deref(), Some("*.jsonl"));
assert_eq!(cfg.batch_size, 250);
}
#[test]
fn batch_size_zero_is_valid_sentinel() {
let cfg = SftpSourceConfig::new(conn(), "/d").with_batch_size(0);
assert_eq!(cfg.batch_size, 0);
assert!(faucet_core::validate_batch_size(cfg.batch_size).is_ok());
}
#[test]
fn format_parses_all_variants() {
for (s, want) in [
("jsonl", SftpFormat::Jsonl),
("json_array", SftpFormat::JsonArray),
("raw_text", SftpFormat::RawText),
] {
let got: SftpFormat = serde_json::from_str(&format!("\"{s}\"")).unwrap();
assert_eq!(got, want);
}
}
#[test]
fn glob_literal_and_wildcards() {
assert!(glob_match("*.jsonl", "orders.jsonl"));
assert!(glob_match("*.jsonl", ".jsonl"));
assert!(!glob_match("*.jsonl", "orders.json"));
assert!(glob_match("data-?.csv", "data-1.csv"));
assert!(!glob_match("data-?.csv", "data-12.csv"));
assert!(glob_match("*", "anything"));
assert!(glob_match("exact.txt", "exact.txt"));
assert!(!glob_match("exact.txt", "other.txt"));
assert!(glob_match("a*b*c", "axxbyyc"));
assert!(!glob_match("a*b*c", "axxbyy"));
}
}