use std::path::PathBuf;
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::cache;
use crate::team;
const VERSION: u32 = 1;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
#[serde(default)]
pub team: Vec<String>,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub sort: Option<String>,
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub shiny: bool,
}
impl Session {
fn sanitized(mut self) -> Self {
let mut seen = Vec::with_capacity(self.team.len());
self.team.retain(|name| {
let keep = !name.trim().is_empty() && !seen.contains(name);
if keep {
seen.push(name.clone());
}
keep
});
self.team.truncate(team::MAX_MEMBERS);
self
}
}
#[derive(Serialize, Deserialize)]
struct Stored {
version: u32,
session: Session,
}
pub async fn load() -> Session {
let Some(path) = path() else {
return Session::default();
};
match tokio::fs::read(path).await {
Ok(bytes) => decode(&bytes),
Err(_) => Session::default(),
}
}
pub async fn store(session: &Session) {
let Some(path) = path() else {
return;
};
if let Some(bytes) = encode(session) {
cache::write_atomic(path, &bytes).await;
}
}
fn encode(session: &Session) -> Option<Vec<u8>> {
let stored = Stored {
version: VERSION,
session: session.clone(),
};
serde_json::to_vec_pretty(&stored).ok()
}
fn decode(bytes: &[u8]) -> Session {
let Ok(stored) = serde_json::from_slice::<Stored>(bytes) else {
return Session::default();
};
if stored.version != VERSION {
return Session::default();
}
stored.session.sanitized()
}
fn path() -> Option<&'static PathBuf> {
static PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
PATH.get_or_init(|| {
let base = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
.or_else(|| std::env::var_os("LOCALAPPDATA").map(PathBuf::from))?;
Some(base.join("pokeductor").join("session.json"))
})
.as_ref()
}
#[cfg(test)]
mod tests {
use super::*;
fn session_with_team(names: &[&str]) -> Session {
Session {
team: names.iter().map(|n| n.to_string()).collect(),
..Session::default()
}
}
#[test]
fn a_session_survives_the_round_trip() {
let session = Session {
team: vec!["snorlax".into(), "gyarados".into()],
language: Some("tr".into()),
sort: Some("name".into()),
theme: Some("dmg".into()),
shiny: true,
};
let bytes = encode(&session).expect("encode");
assert_eq!(decode(&bytes), session);
}
#[test]
fn a_file_from_another_version_reads_as_a_fresh_session() {
let raw = br#"{"version":999,"session":{"team":["pikachu"],"shiny":true}}"#;
assert_eq!(decode(raw), Session::default());
}
#[test]
fn nonsense_reads_as_a_fresh_session() {
assert_eq!(decode(b"not json at all"), Session::default());
assert_eq!(decode(b""), Session::default());
}
#[test]
fn settings_a_file_omits_stay_at_their_defaults() {
let raw = br#"{"version":1,"session":{"team":["pikachu"]}}"#;
let session = decode(raw);
assert_eq!(session.team, ["pikachu"]);
assert_eq!(session.language, None);
assert_eq!(session.sort, None);
assert_eq!(session.theme, None, "a file from before palettes existed");
assert!(!session.shiny);
}
#[test]
fn an_oversized_party_is_trimmed_to_the_limit() {
let names = ["a", "b", "c", "d", "e", "f", "g", "h", "i"];
let session = session_with_team(&names).sanitized();
assert_eq!(session.team.len(), team::MAX_MEMBERS);
assert_eq!(session.team[0], "a");
}
#[test]
fn repeats_and_blanks_are_dropped_in_order() {
let session = session_with_team(&["snorlax", "", "gyarados", "snorlax", " "]).sanitized();
assert_eq!(session.team, ["snorlax", "gyarados"]);
}
}