//! LastPass CSV importer. //! //! Pure: takes CSV bytes, returns a vector of `Item` (with freshly-minted //! IDs and timestamps) plus a vector of `ImportWarning` for skipped or //! partially-imported rows. Failed rows never abort the whole import; //! the only fatal error is a missing or malformed header. //! //! Spec: docs/superpowers/specs/2026-04-27-relicario-import-export-design.md //! (D10–D13 + the LastPass field-mapping table). use serde::{Deserialize, Serialize}; use url::Url; use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; use crate::item::Item; use crate::item_types::{ItemCore, LoginCore}; /// LastPass column order. The header row must contain these exact column /// names in this exact order. pub const EXPECTED_HEADER: &[&str] = &["url", "username", "password", "totp", "extra", "name", "grouping", "fav"]; /// A row that was skipped, or partially imported with a downgrade /// (e.g., login imported without TOTP). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImportWarning { /// 1-indexed row number in the CSV body (the header is row 0). pub row: usize, /// Title from the row's `name` column, if present and non-empty. pub title: Option, /// Human-readable explanation, suitable for stderr / inline UI. pub message: String, } /// Parse a LastPass CSV export. /// /// Returns the parsed items (with fresh IDs and timestamps) and any /// per-row warnings. The function only fails if the header is missing /// or doesn't match `EXPECTED_HEADER`. pub fn parse_lastpass_csv(csv_bytes: &[u8]) -> Result<(Vec, Vec)> { let mut reader = csv::ReaderBuilder::new() .has_headers(true) .flexible(false) .from_reader(csv_bytes); // Validate header. let headers = reader .headers() .map_err(|e| RelicarioError::ImportCsvFormat(format!("read header: {e}")))? .clone(); if headers.len() != EXPECTED_HEADER.len() || headers.iter().zip(EXPECTED_HEADER).any(|(got, want)| got != *want) { return Err(RelicarioError::ImportCsvHeader(format!( "expected `{}`, got `{}`", EXPECTED_HEADER.join(","), headers.iter().collect::>().join(",") ))); } let mut items = Vec::new(); let mut warnings = Vec::new(); for (idx, record) in reader.records().enumerate() { let row_num = idx + 1; let record = match record { Ok(r) => r, Err(e) => { warnings.push(ImportWarning { row: row_num, title: None, message: format!("CSV parse error — skipped: {e}"), }); continue; } }; match map_row(&record, row_num) { Ok(item) => items.push(item), Err(w) => warnings.push(w), } } Ok((items, warnings)) } /// Map a single CSV record to either an `Item` or an `ImportWarning`. /// Column order is fixed by `EXPECTED_HEADER`. fn map_row(record: &csv::StringRecord, row: usize) -> std::result::Result { let url = record.get(0).unwrap_or("").trim(); let username = record.get(1).unwrap_or("").trim(); let password = record.get(2).unwrap_or(""); let _totp = record.get(3).unwrap_or(""); // populated in Task 4 let extra = record.get(4).unwrap_or(""); let name = record.get(5).unwrap_or("").trim(); let group = record.get(6).unwrap_or("").trim(); let fav = record.get(7).unwrap_or("").trim(); if name.is_empty() { return Err(ImportWarning { row, title: None, message: "missing `name` — skipped".into(), }); } if password.is_empty() { return Err(ImportWarning { row, title: Some(name.to_string()), message: "missing `password` — skipped".into(), }); } let parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() }; let mut item = Item::new( name.to_string(), ItemCore::Login(LoginCore { username: if username.is_empty() { None } else { Some(username.to_string()) }, password: Some(Zeroizing::new(password.to_string())), url: parsed_url, totp: None, }), ); item.group = if group.is_empty() { None } else { Some(group.to_string()) }; item.favorite = fav == "1"; item.notes = if extra.is_empty() { None } else { Some(extra.to_string()) }; Ok(item) }