aboutsummaryrefslogtreecommitdiff
path: root/src/cookies.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/cookies.rs')
-rw-r--r--src/cookies.rs138
1 files changed, 138 insertions, 0 deletions
diff --git a/src/cookies.rs b/src/cookies.rs
new file mode 100644
index 0000000..4e33dcc
--- /dev/null
+++ b/src/cookies.rs
@@ -0,0 +1,138 @@
+use serde::Deserialize;
+use serde_json::Value;
+use std::{collections::HashMap, fs, path::Path};
+
+/// One cookie entry; domain/path could be added later if needed.
+#[derive(Debug, Clone, Deserialize)]
+pub struct CookieEntry {
+ pub name: String,
+ pub value: String,
+}
+
+/// The input JSON can be either a map or a list of cookie entries.
+#[derive(Debug, Deserialize)]
+#[serde(untagged)]
+enum CookiesJson {
+ Map(HashMap<String, String>),
+ List(Vec<CookieEntry>),
+}
+
+/// Normalized cookie store (name -> value). We keep it simple for now.
+/// If later we need domain/path scoping, we can extend this type.
+#[derive(Debug, Clone)]
+pub struct CookieStore {
+ map: HashMap<String, String>,
+}
+
+impl CookieStore {
+ /// Create a CookieStore from a serde_json::Value (already parsed).
+ fn from_value(v: Value) -> anyhow::Result<Self> {
+ // Try to deserialize into either a map or a list.
+ let cj: CookiesJson = serde_json::from_value(v)?;
+ let mut map = HashMap::new();
+
+ match cj {
+ CookiesJson::Map(m) => {
+ // Direct mapping: { "name": "value", ... }
+ map.extend(m);
+ }
+ CookiesJson::List(list) => {
+ // Keep last occurrence on duplicates.
+ for e in list {
+ map.insert(e.name, e.value);
+ }
+ }
+ }
+
+ Ok(Self { map })
+ }
+
+ /// Load cookies from a file path.
+ pub fn load_from(path: &Path) -> anyhow::Result<Self> {
+ let raw = fs::read_to_string(path)?;
+ let v: Value = serde_json::from_str(&raw)?;
+ Self::from_value(v)
+ }
+
+ /// Number of cookies.
+ pub fn len(&self) -> usize {
+ self.map.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.map.is_empty()
+ }
+
+ /// Return a sorted list of cookie names (safe to log).
+ pub fn cookie_names(&self) -> Vec<String> {
+ let mut names: Vec<_> = self.map.keys().cloned().collect();
+ names.sort();
+ names
+ }
+
+ /// Render the `Cookie` header value, e.g.: "a=1; b=2".
+ /// Deterministic order (by name) to help testing and reproducibility.
+ pub fn to_header_value(&self) -> String {
+ let mut pairs: Vec<_> = self.map.iter().collect();
+ pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
+ pairs
+ .into_iter()
+ .map(|(k, v)| format!("{k}={v}"))
+ .collect::<Vec<_>>()
+ .join("; ")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::CookieStore;
+ use serde_json::json;
+
+ #[test]
+ fn loads_from_map() {
+ let v = json!({
+ "sess": "abc",
+ "OptanonConsent": "xyz"
+ });
+ let store = CookieStore::from_value(v).unwrap();
+ assert_eq!(store.len(), 2);
+ let names = store.cookie_names();
+ assert_eq!(
+ names,
+ vec!["OptanonConsent".to_string(), "sess".to_string()]
+ );
+ let header = store.to_header_value();
+ assert_eq!(header, "OptanonConsent=xyz; sess=abc");
+ }
+
+ #[test]
+ fn loads_from_list() {
+ let v = json!([
+ { "name": "sess", "value": "abc" },
+ { "name": "OptanonConsent", "value": "xyz", "domain": "learning.oreilly.com" }
+ ]);
+ let store = CookieStore::from_value(v).unwrap();
+ assert_eq!(store.len(), 2);
+ assert_eq!(store.cookie_names(), vec!["OptanonConsent", "sess"]);
+ assert_eq!(store.to_header_value(), "OptanonConsent=xyz; sess=abc");
+ }
+
+ #[test]
+ fn duplicate_names_keep_last() {
+ let v = json!([
+ { "name": "sess", "value": "OLD" },
+ { "name": "sess", "value": "NEW" }
+ ]);
+ let store = CookieStore::from_value(v).unwrap();
+ assert_eq!(store.len(), 1);
+ assert_eq!(store.to_header_value(), "sess=NEW");
+ }
+
+ #[test]
+ fn invalid_json_fails() {
+ let v = serde_json::Value::String("not-json-shape".to_string());
+ let err = CookieStore::from_value(v).unwrap_err();
+ let msg = format!("{err}");
+ assert!(msg.to_lowercase().contains("did not match any variant"));
+ }
+}