pasejo 2026.5.17

passage re-implementation in Rust for teams
// SPDX-FileCopyrightText: The pasejo Authors
// SPDX-License-Identifier: 0BSD

use std::collections::BTreeMap;

pub struct ParsedSecret {
    pub password: Option<String>,
    pub notes: Vec<String>,
    pub fields: BTreeMap<String, String>,
}

pub fn parse_secret(secret: &str) -> ParsedSecret {
    let mut password: Option<String> = None;
    let mut notes: Vec<String> = vec![];
    let mut fields: BTreeMap<String, String> = BTreeMap::new();

    for line in secret.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        if line.contains(": ") {
            if let Some((key, value)) = line.split_once(": ") {
                fields.insert(String::from(key), String::from(value));
            }
        } else if password.is_none() {
            password = Some(String::from(line));
        } else {
            notes.push(String::from(line));
        }
    }

    ParsedSecret {
        password,
        notes,
        fields,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_input_yields_empty_parsed_secret() {
        let parsed = parse_secret("");
        assert!(parsed.password.is_none());
        assert!(parsed.notes.is_empty());
        assert!(parsed.fields.is_empty());
    }

    #[test]
    fn first_non_field_line_becomes_password() {
        let parsed = parse_secret("hunter2");
        assert_eq!(parsed.password.as_deref(), Some("hunter2"));
        assert!(parsed.notes.is_empty());
        assert!(parsed.fields.is_empty());
    }

    #[test]
    fn lines_with_colon_separator_become_fields() {
        let parsed = parse_secret("hunter2\nuser: alice\nurl: https://example.com");
        assert_eq!(parsed.password.as_deref(), Some("hunter2"));
        assert_eq!(parsed.fields.get("user").map(String::as_str), Some("alice"));
        assert_eq!(
            parsed.fields.get("url").map(String::as_str),
            Some("https://example.com")
        );
        assert!(parsed.notes.is_empty());
    }

    #[test]
    fn additional_non_field_lines_become_notes() {
        let parsed = parse_secret("hunter2\nfirst note\nsecond note");
        assert_eq!(parsed.password.as_deref(), Some("hunter2"));
        assert_eq!(parsed.notes, vec!["first note", "second note"]);
    }

    #[test]
    fn empty_lines_are_skipped() {
        let parsed = parse_secret("\n\nhunter2\n\nnote\n");
        assert_eq!(parsed.password.as_deref(), Some("hunter2"));
        assert_eq!(parsed.notes, vec!["note"]);
    }

    #[test]
    fn lines_are_trimmed_before_inspection() {
        let parsed = parse_secret("  hunter2  \n  user: alice  ");
        assert_eq!(parsed.password.as_deref(), Some("hunter2"));
        assert_eq!(parsed.fields.get("user").map(String::as_str), Some("alice"));
    }

    #[test]
    fn line_without_colon_space_is_not_a_field() {
        // The parser specifically looks for ": " (colon + space), so "user:alice"
        // is treated as the password (first non-field line), not a field.
        let parsed = parse_secret("user:alice");
        assert_eq!(parsed.password.as_deref(), Some("user:alice"));
        assert!(parsed.fields.is_empty());
    }
}