use std::fs;
use std::time::Duration;
use reqwest::blocking::{Client, multipart};
fn make_client() -> Result<Client, String> {
Client::builder()
.timeout(Duration::from_secs(60))
.no_proxy()
.cookie_store(true)
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))
}
pub fn run_export(
model: &str,
base_url: &str,
out: Option<&str>,
filter: Option<&str>,
cookie: Option<&str>,
) {
if let Err(e) = run_export_inner(model, base_url, out, filter, cookie) {
eprintln!("autumn data export: {e}");
std::process::exit(1);
}
}
fn run_export_inner(
model: &str,
base_url: &str,
out: Option<&str>,
filter: Option<&str>,
cookie: Option<&str>,
) -> Result<(), String> {
let client = make_client()?;
let base = base_url.trim_end_matches('/');
let mut url = format!("{base}/{model}/export.csv");
if let Some(q) = filter {
let encoded = percent_encode(q);
url.push_str("?q=");
url.push_str(&encoded);
}
println!("Exporting {model} from {url}");
let mut req = client.get(&url);
if let Some(c) = cookie {
req = req.header("Cookie", c);
}
let response = req.send().map_err(|e| format!("Request failed: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"Server returned HTTP {} for {url}",
response.status()
));
}
let bytes = response
.bytes()
.map_err(|e| format!("Failed to read response body: {e}"))?;
let output_path = out.map_or_else(|| format!("{model}.csv"), str::to_owned);
fs::write(&output_path, &bytes).map_err(|e| format!("Failed to write '{output_path}': {e}"))?;
#[allow(clippy::naive_bytecount)]
let row_count = bytes
.iter()
.filter(|&&b| b == b'\n')
.count()
.saturating_sub(1);
println!("Exported {row_count} rows to {output_path}");
Ok(())
}
pub fn run_import(
model: &str,
base_url: &str,
input: &str,
dry_run: bool,
upsert_by: Option<&str>,
cookie: Option<&str>,
) {
if upsert_by.is_some() {
eprintln!(
"autumn data import: --upsert-by is not yet supported via the admin HTTP API; \
omit the flag to use insert mode, or implement upsert in a custom migration script"
);
std::process::exit(1);
}
if let Err(e) = run_import_inner(model, base_url, input, dry_run, cookie) {
eprintln!("autumn data import: {e}");
std::process::exit(1);
}
}
fn run_import_inner(
model: &str,
base_url: &str,
input: &str,
dry_run: bool,
cookie: Option<&str>,
) -> Result<(), String> {
let client = make_client()?;
let base = base_url.trim_end_matches('/');
let url = format!("{base}/{model}/import");
let csv_bytes = fs::read(input).map_err(|e| format!("Failed to read '{input}': {e}"))?;
let mode_value = if dry_run { "dry_run" } else { "insert" };
let label = if dry_run { "Dry run" } else { "Import" };
let mut get_req = client.get(&url);
if let Some(c) = cookie {
get_req = get_req.header("Cookie", c);
}
let get_resp = get_req
.send()
.map_err(|e| format!("Failed to fetch import form: {e}"))?;
let server_cookies: Vec<String> = get_resp
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.filter_map(|v| v.split(';').next())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
let form_html = get_resp
.text()
.map_err(|e| format!("Failed to read import form: {e}"))?;
let csrf_token = extract_csrf_token(&form_html).unwrap_or_default();
let csrf_header =
extract_csrf_header_name(&form_html).unwrap_or_else(|| "X-CSRF-Token".to_owned());
println!("{label}ing {model} from {input} → {url}");
let form = multipart::Form::new()
.part(
"file",
multipart::Part::bytes(csv_bytes)
.file_name(
std::path::Path::new(input)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("import.csv")
.to_owned(),
)
.mime_str("text/csv")
.map_err(|e| format!("MIME type error: {e}"))?,
)
.text("mode", mode_value.to_owned());
let mut post_req = client.post(&url).multipart(form);
if let Some(c) = cookie {
let mut parts = vec![c];
let sc_refs: Vec<&str> = server_cookies.iter().map(String::as_str).collect();
parts.extend_from_slice(&sc_refs);
post_req = post_req.header("Cookie", parts.join("; "));
}
if !csrf_token.is_empty() {
post_req = post_req.header(csrf_header.as_str(), csrf_token.as_str());
}
let response = post_req
.send()
.map_err(|e| format!("Request failed: {e}"))?;
let status = response.status();
let body = response
.text()
.map_err(|e| format!("Failed to read response: {e}"))?;
if !status.is_success() {
return Err(format!("Server returned HTTP {status}:\n{body}"));
}
print_import_summary(&body, dry_run);
Ok(())
}
fn print_import_summary(html: &str, dry_run: bool) {
let counts: Vec<u64> = html
.split("font-size: 1.5rem")
.skip(1)
.take(4)
.filter_map(|chunk| {
chunk
.find('>')
.and_then(|start| {
chunk[start + 1..]
.find('<')
.map(|end| (start + 1, start + 1 + end))
})
.and_then(|(s, e)| chunk[s..e].trim().parse::<u64>().ok())
})
.collect();
if counts.len() == 4 {
let prefix = if dry_run { "(dry run) " } else { "" };
println!(
"{prefix}inserted={} updated={} skipped={} errors={}",
counts[0], counts[1], counts[2], counts[3]
);
if counts[3] > 0 {
eprintln!(
"Import completed with {} errors. Check the admin UI for details.",
counts[3]
);
std::process::exit(1);
}
println!("Import completed successfully.");
} else {
println!("Import request accepted. Check the admin UI for details.");
}
}
fn extract_csrf_token(html: &str) -> Option<String> {
let meta_pos = html.find("name=\"csrf-token\"")?;
let after_meta = &html[meta_pos..];
let val_start = after_meta.find("content=\"")? + 9;
let val_end = after_meta[val_start..].find('"')?;
Some(after_meta[val_start..val_start + val_end].to_owned())
}
fn extract_csrf_header_name(html: &str) -> Option<String> {
let meta_pos = html.find("name=\"csrf-token\"")?;
let after_meta = &html[meta_pos..];
let val_start = after_meta.find("data-header=\"")? + 13;
let val_end = after_meta[val_start..].find('"')?;
let header = after_meta[val_start..val_start + val_end].to_owned();
if header.is_empty() {
None
} else {
Some(header)
}
}
fn percent_encode(s: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
b' ' => out.push('+'),
b => {
out.push('%');
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0xf) as usize] as char);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percent_encode_encodes_special_chars() {
assert_eq!(percent_encode("hello world"), "hello+world");
assert_eq!(percent_encode("a&b=c"), "a%26b%3Dc");
assert_eq!(percent_encode("safe-chars_123"), "safe-chars_123");
}
#[test]
fn run_export_prints_error_and_exits_on_bad_url() {
let result = run_export_inner("posts", "http://127.0.0.1:9", None, None, None);
assert!(result.is_err(), "should fail with unreachable URL");
}
#[test]
fn run_import_prints_error_on_missing_file() {
let result = run_import_inner(
"posts",
"http://127.0.0.1:3000",
"/tmp/nonexistent_autumn_csv_import_test.csv",
false,
None,
);
assert!(result.is_err(), "should fail when file doesn't exist");
assert!(
result.unwrap_err().contains("Failed to read"),
"error should mention file read failure"
);
}
#[test]
fn print_import_summary_handles_incomplete_html_gracefully() {
print_import_summary("<html>Something went wrong</html>", false);
}
#[test]
fn extract_csrf_token_reads_from_meta_tag() {
let html = r#"<meta name="csrf-token" content="tok-abc" data-header="X-CSRF-Token">"#;
assert_eq!(extract_csrf_token(html), Some("tok-abc".to_owned()));
}
#[test]
fn extract_csrf_token_returns_none_when_meta_absent() {
assert_eq!(extract_csrf_token("<html></html>"), None);
}
#[test]
fn extract_csrf_header_name_reads_data_header() {
let html = r#"<meta name="csrf-token" content="tok" data-header="X-Custom-CSRF">"#;
assert_eq!(
extract_csrf_header_name(html),
Some("X-Custom-CSRF".to_owned())
);
}
#[test]
fn extract_csrf_header_name_returns_none_when_absent() {
let html = r#"<meta name="csrf-token" content="tok">"#;
assert_eq!(extract_csrf_header_name(html), None);
}
}