tail-fin-common 0.6.4

Shared infrastructure for tail-fin: error types, page_fetch, cookies, CDP helpers
Documentation
use std::path::Path;

use crate::TailFinError;

/// Build an HTTP Cookie header string from name-value pairs.
pub fn build_cookie_header(cookies: &[(String, String)]) -> String {
    cookies
        .iter()
        .map(|(name, value)| format!("{}={}", name, value))
        .collect::<Vec<_>>()
        .join("; ")
}

/// Parse a Netscape/curl cookie file into `(name, value)` pairs.
///
/// Ignores comment lines (`#`) and blank lines. Accepts the 7-field
/// tab-separated format: `domain flag path secure expires name value`.
pub fn parse_netscape_cookies(data: &str) -> Vec<(String, String)> {
    let mut cookies = Vec::new();
    for line in data.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let parts: Vec<&str> = line.splitn(7, '\t').collect();
        if parts.len() < 6 {
            continue;
        }
        let name = parts[5].to_string();
        let value = if parts.len() > 6 {
            parts[6].to_string()
        } else {
            String::new()
        };
        cookies.push((name, value));
    }
    cookies
}

/// Load a Netscape cookie file and return `(name, value)` pairs.
///
/// Returns `TailFinError::AuthRequired` if the file is empty or contains no cookies.
pub fn load_netscape_file(path: &Path) -> Result<Vec<(String, String)>, TailFinError> {
    let data = std::fs::read_to_string(path)
        .map_err(|e| TailFinError::Io(format!("Cannot read cookie file: {}", e)))?;
    let cookies = parse_netscape_cookies(&data);
    if cookies.is_empty() {
        return Err(TailFinError::AuthRequired);
    }
    Ok(cookies)
}

/// Write cookies as a Netscape cookie file.
///
/// `cookies` is a slice of JSON cookie objects as returned by CDP `Network.getAllCookies`.
pub fn write_netscape_file(path: &Path, cookies: &[serde_json::Value]) -> Result<(), TailFinError> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| TailFinError::Io(format!("Cannot create directory: {}", e)))?;
    }

    let mut lines = vec![
        "# Netscape HTTP Cookie File".to_string(),
        "# Exported by tail-fin".to_string(),
        String::new(),
    ];

    for cookie in cookies {
        let domain = cookie.get("domain").and_then(|v| v.as_str()).unwrap_or("");
        let flag = if domain.starts_with('.') {
            "TRUE"
        } else {
            "FALSE"
        };
        let path_val = cookie.get("path").and_then(|v| v.as_str()).unwrap_or("/");
        let secure = if cookie
            .get("secure")
            .and_then(|v| v.as_bool())
            .unwrap_or(false)
        {
            "TRUE"
        } else {
            "FALSE"
        };
        let expires = cookie
            .get("expires")
            .and_then(|v| v.as_f64())
            .map(|v| v as u64)
            .unwrap_or(0);
        let name = cookie.get("name").and_then(|v| v.as_str()).unwrap_or("");
        let value = cookie.get("value").and_then(|v| v.as_str()).unwrap_or("");
        lines.push(format!(
            "{}\t{}\t{}\t{}\t{}\t{}\t{}",
            domain, flag, path_val, secure, expires, name, value
        ));
    }

    std::fs::write(path, lines.join("\n"))
        .map_err(|e| TailFinError::Io(format!("Cannot write cookie file: {}", e)))?;
    Ok(())
}

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

    // ── build_cookie_header ──────────────────────────────────────────

    #[test]
    fn test_build_cookie_header() {
        let cookies = vec![
            ("a".to_string(), "1".to_string()),
            ("b".to_string(), "2".to_string()),
        ];
        assert_eq!(build_cookie_header(&cookies), "a=1; b=2");
    }

    #[test]
    fn test_build_cookie_header_empty() {
        let cookies: Vec<(String, String)> = vec![];
        assert_eq!(build_cookie_header(&cookies), "");
    }

    // ── parse_netscape_cookies ──────────────────────────────────────

    #[test]
    fn parse_empty_string_returns_empty_vec() {
        assert!(parse_netscape_cookies("").is_empty());
    }

    #[test]
    fn parse_ignores_comment_lines() {
        let data = "# Netscape HTTP Cookie File\n# another comment\n";
        assert!(parse_netscape_cookies(data).is_empty());
    }

    #[test]
    fn parse_ignores_blank_lines() {
        let data = "\n   \n\n";
        assert!(parse_netscape_cookies(data).is_empty());
    }

    #[test]
    fn parse_valid_7_field_line() {
        let data = ".example.com\tTRUE\t/\tFALSE\t0\tsession_id\tabc123";
        let cookies = parse_netscape_cookies(data);
        assert_eq!(cookies.len(), 1);
        assert_eq!(cookies[0], ("session_id".to_string(), "abc123".to_string()));
    }

    #[test]
    fn parse_6_field_line_gives_empty_value() {
        // Exactly 6 fields — name present but no value field
        let data = ".example.com\tTRUE\t/\tFALSE\t0\ttoken";
        let cookies = parse_netscape_cookies(data);
        assert_eq!(cookies.len(), 1);
        assert_eq!(cookies[0], ("token".to_string(), String::new()));
    }

    #[test]
    fn parse_skips_lines_with_fewer_than_6_fields() {
        let data = "only\ttwo\tfields";
        assert!(parse_netscape_cookies(data).is_empty());
    }

    #[test]
    fn parse_multiple_cookies() {
        let data = "\
# Netscape HTTP Cookie File\n\
.example.com\tTRUE\t/\tFALSE\t0\tsid\tval1\n\
\n\
.other.com\tFALSE\t/path\tTRUE\t999\ttoken\tval2\n";
        let cookies = parse_netscape_cookies(data);
        assert_eq!(cookies.len(), 2);
        assert_eq!(cookies[0].0, "sid");
        assert_eq!(cookies[0].1, "val1");
        assert_eq!(cookies[1].0, "token");
        assert_eq!(cookies[1].1, "val2");
    }

    // ── write_netscape_file ─────────────────────────────────────────

    #[test]
    fn write_and_read_back_roundtrip() {
        let dir = std::env::temp_dir().join("tail_fin_test_cookies");
        let path = dir.join("cookies.txt");

        let cookies = vec![serde_json::json!({
            "domain": ".example.com",
            "path": "/",
            "secure": true,
            "expires": 1700000000.0,
            "name": "sid",
            "value": "abc"
        })];

        write_netscape_file(&path, &cookies).unwrap();

        let contents = std::fs::read_to_string(&path).unwrap();
        // Header lines present
        assert!(contents.starts_with("# Netscape HTTP Cookie File"));
        // Cookie line present with correct fields
        assert!(contents.contains(".example.com\tTRUE\t/\tTRUE\t1700000000\tsid\tabc"));

        // Roundtrip through parse
        let parsed = parse_netscape_cookies(&contents);
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0], ("sid".to_string(), "abc".to_string()));

        // Cleanup
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn write_domain_without_dot_sets_flag_false() {
        let dir = std::env::temp_dir().join("tail_fin_test_cookies_flag");
        let path = dir.join("cookies.txt");

        let cookies = vec![serde_json::json!({
            "domain": "example.com",
            "path": "/",
            "secure": false,
            "expires": 0,
            "name": "tok",
            "value": "x"
        })];

        write_netscape_file(&path, &cookies).unwrap();

        let contents = std::fs::read_to_string(&path).unwrap();
        assert!(contents.contains("example.com\tFALSE\t/\tFALSE\t0\ttok\tx"));

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn write_domain_with_dot_sets_flag_true() {
        let dir = std::env::temp_dir().join("tail_fin_test_cookies_dot");
        let path = dir.join("cookies.txt");

        let cookies = vec![serde_json::json!({
            "domain": ".example.com",
            "path": "/api",
            "secure": false,
            "expires": 0,
            "name": "k",
            "value": "v"
        })];

        write_netscape_file(&path, &cookies).unwrap();

        let contents = std::fs::read_to_string(&path).unwrap();
        assert!(contents.contains(".example.com\tTRUE\t/api\tFALSE\t0\tk\tv"));

        let _ = std::fs::remove_dir_all(&dir);
    }

    // ── load_netscape_file ──────────────────────────────────────────

    #[test]
    fn load_empty_file_returns_auth_required() {
        let dir = std::env::temp_dir().join("tail_fin_test_load_empty");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("empty.txt");
        std::fs::write(&path, "").unwrap();

        let result = load_netscape_file(&path);
        assert!(result.is_err());
        assert!(matches!(result.unwrap_err(), TailFinError::AuthRequired));

        let _ = std::fs::remove_dir_all(&dir);
    }
}