//! Parsing of Google Takeout's `subscriptions.csv`. //! //! The export's real header is `Channel Id,Channel Url,Channel Title`, but the //! column order has varied across Takeout versions and locales, so we resolve //! columns by header name rather than by position. use crate::models::Channel; fn find_col(headers: &csv::StringRecord, name: &str) -> Option { headers .iter() .position(|h| h.trim().eq_ignore_ascii_case(name)) } pub fn parse_csv(input: &str) -> Result, String> { if input.trim().is_empty() { return Err("The file is empty.".into()); } let mut reader = csv::ReaderBuilder::new() .flexible(true) .from_reader(input.as_bytes()); let headers = reader .headers() .map_err(|e| format!("Could not read the CSV header: {e}"))? .clone(); let id_col = find_col(&headers, "Channel Id") .ok_or("This does not look like a Takeout subscriptions.csv: no 'Channel Id' column.")?; let title_col = find_col(&headers, "Channel Title") .ok_or("This does not look like a Takeout subscriptions.csv: no 'Channel Title' column.")?; let url_col = find_col(&headers, "Channel Url"); let mut out = Vec::new(); for record in reader.records() { let record = record.map_err(|e| format!("Malformed CSV row: {e}"))?; let id = record.get(id_col).unwrap_or("").trim(); if id.is_empty() { continue; } let title = record.get(title_col).unwrap_or("").trim(); let url = url_col .and_then(|c| record.get(c)) .map(str::trim) .filter(|u| !u.is_empty()) .map(str::to_string) .unwrap_or_else(|| format!("https://www.youtube.com/channel/{id}")); out.push(Channel { id: id.to_string(), title: if title.is_empty() { id.to_string() } else { title.to_string() }, url, }); } Ok(out) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_standard_export() { let csv = "Channel Id,Channel Url,Channel Title\n\ UCabc,http://www.youtube.com/channel/UCabc,Linus Tech Tips\n"; let out = parse_csv(csv).unwrap(); assert_eq!(out.len(), 1); assert_eq!(out[0].id, "UCabc"); assert_eq!(out[0].title, "Linus Tech Tips"); assert_eq!(out[0].url, "http://www.youtube.com/channel/UCabc"); } #[test] fn handles_commas_and_unicode_in_titles() { let csv = "Channel Id,Channel Url,Channel Title\n\ UCx,http://y.com/UCx,\"Kurzgesagt – In a Nutshell, Ltd\"\n"; let out = parse_csv(csv).unwrap(); assert_eq!(out[0].title, "Kurzgesagt – In a Nutshell, Ltd"); } #[test] fn tolerates_reordered_columns() { let csv = "Channel Title,Channel Id,Channel Url\nVeritasium,UCz,http://y.com/UCz\n"; let out = parse_csv(csv).unwrap(); assert_eq!(out[0].id, "UCz"); assert_eq!(out[0].title, "Veritasium"); } #[test] fn derives_url_when_column_absent() { let csv = "Channel Id,Channel Title\nUCq,Some Channel\n"; let out = parse_csv(csv).unwrap(); assert_eq!(out[0].url, "https://www.youtube.com/channel/UCq"); } #[test] fn skips_blank_lines_and_empty_file() { assert!(parse_csv("Channel Id,Channel Url,Channel Title\n\n") .unwrap() .is_empty()); assert!(parse_csv("").is_err()); } #[test] fn errors_on_missing_required_column() { assert!(parse_csv("Foo,Bar\n1,2\n").is_err()); } }