use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct CookieJar {
by_host: HashMap<String, Vec<(String, String)>>,
}
impl CookieJar {
pub fn new() -> Self {
Self::default()
}
pub fn load(workspace: &Path) -> Self {
let path = workspace.join(".mnml").join("cookies.json");
let Ok(text) = std::fs::read_to_string(&path) else {
return Self::new();
};
let Ok(parsed): Result<serde_json::Value, _> = serde_json::from_str(&text) else {
eprintln!(
"cookie_jar: {} is not valid JSON; starting fresh",
path.display()
);
return Self::new();
};
let mut by_host = HashMap::new();
if let Some(obj) = parsed.as_object() {
for (host, val) in obj {
let mut pairs = Vec::new();
if let Some(host_obj) = val.as_object() {
for (name, value) in host_obj {
if let Some(v) = value.as_str() {
pairs.push((name.clone(), v.to_string()));
}
}
}
if !pairs.is_empty() {
by_host.insert(host.to_lowercase(), pairs);
}
}
}
Self { by_host }
}
pub fn save(&self, workspace: &Path) -> Result<PathBuf, String> {
let path = workspace.join(".mnml").join("cookies.json");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
}
let mut out = serde_json::Map::new();
for (host, pairs) in &self.by_host {
let mut obj = serde_json::Map::new();
for (n, v) in pairs {
obj.insert(n.clone(), serde_json::Value::String(v.clone()));
}
out.insert(host.clone(), serde_json::Value::Object(obj));
}
let text = serde_json::to_string_pretty(&serde_json::Value::Object(out))
.map_err(|e| format!("serialize: {e}"))?;
std::fs::write(&path, text).map_err(|e| format!("write: {e}"))?;
Ok(path)
}
pub fn record_set_cookie(&mut self, host: &str, set_cookie: &str) {
let host = host.to_lowercase();
let head = set_cookie.split(';').next().unwrap_or("").trim();
let Some((name, value)) = head.split_once('=') else {
return;
};
let name = name.trim().to_string();
let value = value.trim().to_string();
if name.is_empty() {
return;
}
let entries = self.by_host.entry(host).or_default();
if let Some(existing) = entries.iter_mut().find(|(n, _)| *n == name) {
existing.1 = value;
} else {
entries.push((name, value));
}
}
pub fn cookie_header_for(&self, host: &str) -> Option<String> {
let host = host.to_lowercase();
let pairs = self.by_host.get(&host)?;
if pairs.is_empty() {
return None;
}
Some(
pairs
.iter()
.map(|(n, v)| format!("{n}={v}"))
.collect::<Vec<_>>()
.join("; "),
)
}
pub fn clear(&mut self) {
self.by_host.clear();
}
pub fn remove(&mut self, host: &str, name: &str) -> bool {
let host = host.to_lowercase();
let Some(entries) = self.by_host.get_mut(&host) else {
return false;
};
let pre = entries.len();
entries.retain(|(n, _)| n != name);
let removed = entries.len() < pre;
if entries.is_empty() {
self.by_host.remove(&host);
}
removed
}
pub fn total(&self) -> usize {
self.by_host.values().map(|v| v.len()).sum()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str, &str)> {
self.by_host.iter().flat_map(|(h, ps)| {
ps.iter()
.map(move |(n, v)| (h.as_str(), n.as_str(), v.as_str()))
})
}
pub fn host_of(url: &str) -> Option<String> {
let after = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
let host = after.split(['/', '?', '#']).next()?;
let host = host.split(':').next()?; if host.is_empty() {
None
} else {
Some(host.to_lowercase())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_and_emit() {
let mut jar = CookieJar::new();
jar.record_set_cookie("example.com", "sessionid=abc; Path=/; HttpOnly");
jar.record_set_cookie("example.com", "csrf=xyz");
let header = jar.cookie_header_for("example.com").unwrap();
assert_eq!(header, "sessionid=abc; csrf=xyz");
}
#[test]
fn host_case_insensitive() {
let mut jar = CookieJar::new();
jar.record_set_cookie("Example.COM", "a=1");
assert!(jar.cookie_header_for("example.com").is_some());
}
#[test]
fn host_of_strips_scheme_path_port() {
assert_eq!(
CookieJar::host_of("https://api.example.com:8443/users?x=1"),
Some("api.example.com".to_string())
);
}
#[test]
fn record_updates_existing() {
let mut jar = CookieJar::new();
jar.record_set_cookie("x", "k=v1");
jar.record_set_cookie("x", "k=v2");
assert_eq!(jar.cookie_header_for("x"), Some("k=v2".to_string()));
}
}