passlane 3.0.1

A password manager and authenticator for the command line
use crate::vault::entities::{Credential, Error, Note, PaymentCard};
use csv::{ReaderBuilder, Writer};
use serde::Serialize;
use std::fs::create_dir;
use std::fs::OpenOptions;
use std::io::prelude::*;
use std::path::Path;
use std::path::PathBuf;

impl From<csv::Error> for Error {
    fn from(e: csv::Error) -> Self {
        Error {
            message: e.to_string(),
        }
    }
}

impl From<serde_json::Error> for Error {
    fn from(e: serde_json::Error) -> Self {
        Error {
            message: e.to_string(),
        }
    }
}

#[derive(Debug, Serialize, Clone)]
pub struct CSVPaymentCard {
    pub name: String,
    pub name_on_card: String,
    pub number: String,
    pub cvv: String,
    pub expiry: String,
    pub color: String,
    pub billing_address: String,
}

#[derive(Debug, Serialize, Clone)]
pub struct CSVSecureNote {
    pub title: String,
    pub note: String,
}

fn home_dir() -> PathBuf {
    dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"))
}

fn dir_path() -> PathBuf {
    let dir_path = home_dir().join(".passlane");
    let exists = Path::new(&dir_path).exists();
    if !exists {
        create_dir(&dir_path).expect("Unable to create .passlane dir");
    }
    dir_path
}

pub fn read_from_csv(file_path: &str) -> anyhow::Result<Vec<Credential>> {
    let path = PathBuf::from(file_path);
    let in_file = OpenOptions::new().read(true).open(path)?;
    let mut reader = ReaderBuilder::new().has_headers(true).from_reader(in_file);
    let credentials = &mut Vec::new();
    for result in reader.deserialize() {
        credentials.push(result?);
    }
    Ok(credentials.clone())
}

fn read_from_file(path: &PathBuf) -> Option<String> {
    let mut file = OpenOptions::new()
        .read(true)
        .write(false)
        .create_new(false)
        .open(&path)
        .unwrap();

    let mut file_content = String::new();
    file.read_to_string(&mut file_content)
        .expect("Unable to read master password file");
    Some(file_content.trim().parse().unwrap())
}

fn resolve_keyfile_path(path_config_file: &str) -> Option<String> {
    let path = dir_path().join(path_config_file);
    if !path.exists() {
        None
    } else {
        read_from_file(&path)
    }
}

pub fn get_keyfile_path() -> Option<String> {
    resolve_keyfile_path(".keyfile_path")
}

pub(crate) fn get_totp_keyfile_path() -> Option<String> {
    resolve_keyfile_path(".totp_keyfile_path")
}

fn resolve_vault_path(default_filename: &str, path_config_filename: &str) -> String {
    let default_path = dir_path()
        .join(default_filename)
        .to_str()
        .unwrap()
        .to_string();
    let path = dir_path().join(path_config_filename);
    if path.exists() {
        return read_from_file(&path)
            .unwrap_or(default_path)
            .trim()
            .to_string();
    }
    default_path
}

fn config_file_exists(path_config_filename: &str) -> bool {
    dir_path().join(path_config_filename).exists()
}

pub(crate) fn get_vault_path() -> String {
    resolve_vault_path("store.kdbx", ".vault_path")
}

pub(crate) fn get_totp_vault_path() -> String {
    resolve_vault_path("totp.kdbx", ".totp_vault_path")
}

pub(crate) fn write_credentials_to_csv(
    file_path: &str,
    creds: &Vec<Credential>,
) -> Result<i64, Error> {
    let mut wtr = Writer::from_path(file_path)?;
    for cred in creds {
        wtr.serialize(cred)?;
    }
    wtr.flush()?;
    Ok(creds.len() as i64)
}

pub(crate) fn write_payment_cards_to_csv(
    file_path: &str,
    cards: &Vec<PaymentCard>,
) -> Result<i64, Error> {
    let mut wtr = Writer::from_path(file_path)?;
    for card in cards {
        wtr.serialize(CSVPaymentCard {
            name: String::from(card.name()),
            name_on_card: String::from(card.name_on_card()),
            number: String::from(card.number()),
            cvv: String::from(card.cvv()),
            expiry: format!("{}", card.expiry()),
            color: match card.color() {
                Some(color) => String::from(color),
                None => String::from(""),
            },
            billing_address: match card.billing_address() {
                Some(address) => format!("{}", address),
                None => String::from(""),
            },
        })?;
    }
    wtr.flush()?;
    Ok(cards.len() as i64)
}

pub(crate) fn write_secure_notes_to_csv(file_path: &str, notes: &Vec<Note>) -> Result<i64, Error> {
    let mut wtr = Writer::from_path(file_path)?;
    for note in notes {
        wtr.serialize(CSVSecureNote {
            title: note.title().to_string(),
            note: note.content().to_string(),
        })?;
    }
    wtr.flush()?;
    Ok(notes.len() as i64)
}

pub fn save_config_path(config_file: &str, path: &str) -> Result<(), Error> {
    let config_path = dir_path().join(config_file);
    let exists = config_path.exists();
    let mut file = OpenOptions::new()
        .create(!exists)
        .write(true)
        .truncate(true)
        .open(config_path)?;
    file.write_all(String::from(path).as_bytes())?;
    Ok(())
}

pub(crate) fn save_vault_path(path: &str) -> Result<(), Error> {
    save_config_path(".vault_path", path)
}

pub(crate) fn save_totp_vault_path(path: &str) -> Result<(), Error> {
    save_config_path(".totp_vault_path", path)
}

pub(crate) fn save_keyfile_path(path: &str) -> Result<(), Error> {
    save_config_path(".keyfile_path", path)
}

pub fn has_vault_path() -> bool {
    config_file_exists(".vault_path")
}

pub fn has_totp_vault_path() -> bool {
    config_file_exists(".totp_vault_path")
}

pub fn has_keyfile_path() -> bool {
    config_file_exists(".keyfile_path")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::vault::entities::Credential;
    use tempfile::NamedTempFile;

    #[test]
    fn test_csv_export_includes_note() {
        let cred = Credential::new(None, "pass123", "google.com", "user@gmail.com", Some("work account"), None);
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_str().unwrap().to_string();
        write_credentials_to_csv(&path, &vec![cred]).unwrap();
        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("work account"), "CSV should contain the note value");
        assert!(content.contains("note"), "CSV header should contain 'note'");
    }

    #[test]
    fn test_csv_roundtrip_without_note() {
        let cred = Credential::new(None, "pass123", "google.com", "user@gmail.com", None, None);
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_str().unwrap().to_string();
        write_credentials_to_csv(&path, &vec![cred]).unwrap();
        let imported = read_from_csv(&path).unwrap();
        assert_eq!(imported.len(), 1);
        assert_eq!(imported[0].note(), None);
        assert_eq!(imported[0].service(), "google.com");
    }

    #[test]
    fn test_csv_roundtrip_with_note() {
        let cred = Credential::new(None, "pass123", "google.com", "user@gmail.com", Some("shared login"), None);
        let tmp = NamedTempFile::new().unwrap();
        let path = tmp.path().to_str().unwrap().to_string();
        write_credentials_to_csv(&path, &vec![cred]).unwrap();
        let imported = read_from_csv(&path).unwrap();
        assert_eq!(imported.len(), 1);
        assert_eq!(imported[0].note(), Some("shared login"));
    }
}