use std::collections::{BTreeMap, BTreeSet, HashMap}; use once_cell::sync::Lazy; use regex::Regex; use wikitext_parser::{Attribute, TextPiece}; use crate::{ manifest::{placeholder, Os, Store, Tag}, resource::ResourceFile, should_cancel, Error, Regularity, State, }; const SAVE_INTERVAL: u32 = 100; async fn make_client() -> Result { mediawiki::api::Api::new("https://www.pcgamingwiki.com/w/api.php") .await .map_err(Error::WikiClient) } #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] pub struct WikiCache(pub BTreeMap); impl ResourceFile for WikiCache { const FILE_NAME: &'static str = "data/wiki-game-cache.yaml"; } /// The parser does not handle HTML tags, so we remove some tags that are only used for annotations. /// Others, like `code` and `sup`, are used both for path segments and annotations, /// so we can't assume how to replace them properly. fn preprocess_text(raw: &str) -> String { let mut out = raw.to_string(); static HTML_COMMENT: Lazy = Lazy::new(|| Regex::new(r"").unwrap()); static HTML_REF: Lazy = Lazy::new(|| Regex::new(r".+?").unwrap()); for (pattern, replacement) in [(&HTML_COMMENT, ""), (&HTML_REF, "")] { out = pattern.replace_all(&out, replacement).to_string(); } out } async fn get_page_title(id: u64) -> Result, Error> { let wiki = make_client().await?; let params = wiki.params_into(&[("action", "query"), ("pageids", id.to_string().as_str())]); let res = wiki.get_query_api_json_all(¶ms).await?; for page in res["query"]["pages"] .as_object() .ok_or(Error::WikiData("query.pages"))? .values() { let found_id = page["pageid"].as_u64().ok_or(Error::WikiData("query.pages[].pageid"))?; if found_id == id { let title = page["title"].as_str(); return Ok(title.map(|x| x.to_string())); } } Ok(None) } async fn is_game_article(query: &str) -> Result { let wiki = make_client().await?; let params = wiki.params_into(&[("action", "query"), ("prop", "categories"), ("titles", query)]); let res = wiki.get_query_api_json_all(¶ms).await?; for page in res["query"]["pages"] .as_object() .ok_or(Error::WikiData("query.pages"))? .values() { let title = page["title"].as_str().ok_or(Error::WikiData("query.pages[].title"))?; if title == query { for category in page["categories"] .as_array() .ok_or(Error::WikiData("query.pages[].categories"))? { let category_name = category["title"] .as_str() .ok_or(Error::WikiData("query.pages[].categories[].title"))?; if category_name == "Category:Games" { return Ok(true); } } } } Ok(false) } impl WikiCache { pub async fn flag_recent_changes(&mut self, meta: &mut WikiMetaCache) -> Result<(), Error> { struct RecentChange { page_id: u64, } let start = meta.last_checked_recent_changes - chrono::Duration::minutes(1); let end = chrono::Utc::now(); println!("Getting recent changes from {} to {}", start, end); let wiki = make_client().await?; let params = wiki.params_into(&[ ("action", "query"), ("list", "recentchanges"), ("rcprop", "title|ids|redirect"), ("rcdir", "newer"), ("rcstart", &start.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)), ("rcend", &end.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)), ("rclimit", "500"), ("rcnamespace", "0"), ("rctype", "edit|new"), ]); let res = wiki.get_query_api_json_all(¶ms).await?; let mut changes = BTreeMap::::new(); for change in res["query"]["recentchanges"] .as_array() .ok_or(Error::WikiData("query.recentchanges"))? { let title = change["title"] .as_str() .ok_or(Error::WikiData("query.recentchanges[].title"))? .to_string(); let page_id = change["pageid"] .as_u64() .ok_or(Error::WikiData("query.recentchanges[].pageid"))?; let redirect = change["redirect"].is_string(); if !redirect { // We don't need the entries for the redirect pages themselves. // We'll update our data when we get to the entry for the new page name. changes.insert(title, RecentChange { page_id }); } } for (title, RecentChange { page_id }) in changes { if self.0.contains_key(&title) { // Existing entry has been edited. println!("[E ] {}", &title); self.0 .entry(title.to_string()) .and_modify(|x| x.state = State::Outdated); } else { // Check for a rename. let mut old_name = None; for (existing_name, existing_info) in &self.0 { if existing_info.page_id == page_id { // We have a confirmed rename. println!("[ M ] {} <<< {}", &title, existing_name); old_name = Some(existing_name.clone()); break; } } match old_name { None => { // Brand new page. match is_game_article(&title).await { Ok(true) => { // It's a game, so add it to the cache. println!("[ C] {}", &title); self.0.insert( title.to_string(), WikiCacheEntry { page_id, state: State::Outdated, ..Default::default() }, ); } Ok(false) => { // Ignore since it's not relevant. } Err(e) => { eprintln!("Unable to check if article is for a game: {} | {}", &title, e); } } } Some(old_name) => { if let Some(mut info) = self.0.remove(&old_name) { info.page_id = page_id; info.state = State::Outdated; info.renamed_from.push(old_name); self.0.insert(title, info); } } } } } meta.last_checked_recent_changes = end; Ok(()) } pub async fn add_new_games(&mut self) -> Result<(), Error> { let wiki = make_client().await?; let params = wiki.params_into(&[ ("action", "query"), ("list", "categorymembers"), ("cmtitle", "Category:Games"), ("cmlimit", "500"), ]); let res = wiki.get_query_api_json_all(¶ms).await?; for page in res["query"]["categorymembers"] .as_array() .ok_or(Error::WikiData("query.categorymembers"))? { if should_cancel() { break; } let title = page["title"] .as_str() .ok_or(Error::WikiData("query.categorymembers[].title"))?; let page_id = page["pageid"] .as_u64() .ok_or(Error::WikiData("query.categorymembers[].pageid"))?; if self.0.contains_key(title) { continue; } let mut old_name = None; for (existing_name, existing_info) in &self.0 { if existing_info.page_id == page_id { old_name = Some(existing_name.to_string()); } } match old_name { None => { self.0.insert( title.to_string(), WikiCacheEntry { page_id, state: State::Outdated, ..Default::default() }, ); } Some(old_name) => { let mut data = self.0[&old_name].clone(); data.state = State::Outdated; if !data.renamed_from.contains(&old_name) { data.renamed_from.push(old_name.clone()); } self.0.insert(title.to_string(), data); self.0.remove(&old_name); } } } Ok(()) } pub async fn refresh( &mut self, outdated_only: bool, titles: Option>, limit: Option, from: Option, ) -> Result<(), Error> { let mut i = 0; let titles: Vec<_> = titles.unwrap_or_else(|| { self.0 .iter() .filter(|(_, v)| !outdated_only || v.state == State::Outdated) .skip_while(|(k, _)| from.as_ref().is_some_and(|from| from != *k)) .take(limit.unwrap_or(usize::MAX)) .map(|(k, _)| k.to_string()) .collect() }); for title in &titles { if should_cancel() { break; } let cached = self.0.get(title).cloned().unwrap_or_default(); println!("Wiki: {}", title); let latest = WikiCacheEntry::fetch_from_page(title.clone()).await; match latest { Ok(mut latest) => { latest.renamed_from = cached.renamed_from.clone(); if let Some(new_title) = latest.new_title.take() { println!(" page {} redirected to '{}'", cached.page_id, &new_title); latest.renamed_from.push(title.to_string()); self.0.remove(title); self.0.insert(new_title, latest); } else { self.0.insert(title.to_string(), latest); } } Err(Error::PageMissing) => { // Couldn't find it by name, so try again by ID. // This can happen for pages moved without leaving a redirect. // (If they have a redirect, then the recent changes code takes care of it.) let Some(new_title) = get_page_title(cached.page_id).await? else { // Page no longer exists. println!(" page no longer exists"); self.0.remove(title); continue; }; println!(" page {} renamed to '{}'", cached.page_id, &new_title); if new_title.starts_with("File:") || new_title.starts_with("Company:") { println!(" page is no longer a game"); self.0.remove(title); continue; } let mut latest = match WikiCacheEntry::fetch_from_page(new_title.clone()).await { Ok(x) => x, Err(Error::PageMissing) => { println!(" page does not exist"); self.0.remove(title); continue; } Err(e) => { return Err(e); } }; let new_title = latest.new_title.take().unwrap_or(new_title); let mut cached = self.0[title].clone(); cached.renamed_from.push(title.clone()); latest.renamed_from = cached.renamed_from; self.0.insert(new_title.clone(), latest); self.0.remove(title); } Err(e) => { return Err(e); } } i += 1; if i % SAVE_INTERVAL == 0 { self.save(); println!("\n:: saved\n"); } } Ok(()) } } #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct WikiCacheEntry { #[serde(skip_serializing_if = "State::is_handled")] pub state: State, #[serde(skip_serializing_if = "Option::is_none")] pub gog: Option, #[serde(skip_serializing_if = "BTreeSet::is_empty")] pub gog_side: BTreeSet, #[serde(skip_serializing_if = "Option::is_none")] pub lutris: Option, pub page_id: u64, #[serde(skip_serializing_if = "Vec::is_empty")] pub renamed_from: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub steam: Option, #[serde(skip_serializing_if = "BTreeSet::is_empty")] pub steam_side: BTreeSet, #[serde(skip_serializing_if = "Vec::is_empty")] pub templates: Vec, /// This will be set after resolving a redirect. #[serde(skip)] pub new_title: Option, } impl WikiCacheEntry { pub async fn fetch_from_page(article: String) -> Result { let mut out = WikiCacheEntry { state: State::Updated, ..Default::default() }; let wiki = make_client().await?; let params = wiki.params_into(&[ ("action", "parse"), ("prop", "wikitext"), ("page", &article), ("redirects", "1"), ]); let res = wiki .get_query_api_json_all(¶ms) .await .map_err(|_| Error::PageMissing)?; if res["error"]["code"].as_str() == Some("missingtitle") { return Err(Error::PageMissing); } out.page_id = res["parse"]["pageid"].as_u64().ok_or(Error::WikiData("parse.pageid"))?; let received_title = res["parse"]["title"].as_str().ok_or(Error::WikiData("parse.title"))?; if received_title != article { out.new_title = Some(received_title.to_string()); } let raw_wikitext = res["parse"]["wikitext"]["*"] .as_str() .ok_or(Error::WikiData("parse.wikitext"))?; let wikitext = wikitext_parser::parse_wikitext(raw_wikitext, article, |e| println!(" Error: {}", e)); for template in wikitext.list_double_brace_expressions() { if let TextPiece::DoubleBraceExpression { tag, attributes } = &template { match tag.to_string().to_lowercase().trim() { "infobox game" => { for attribute in attributes { match attribute.name.as_deref() { Some("steam appid") => { if let Ok(value) = preprocess_text(&attribute.value.to_string()).parse::() { if value > 0 { out.steam = Some(value); } } } Some("steam appid side") => { out.steam_side = preprocess_text(&attribute.value.to_string()) .split(',') .filter_map(|x| x.trim().parse::().ok()) .filter(|x| *x > 0) .collect(); } Some("gogcom id") => { if let Ok(value) = preprocess_text(&attribute.value.to_string()).parse::() { if value > 0 { out.gog = Some(value); } } } Some("gogcom id side") => { out.gog_side = preprocess_text(&attribute.value.to_string()) .split(',') .filter_map(|x| x.trim().parse::().ok()) .filter(|x| *x > 0) .collect(); } Some("lutris") => { let value = preprocess_text(&attribute.value.to_string()); if !value.is_empty() { out.lutris = Some(value); } } _ => {} } } } "game data" => { for attribute in attributes { for template in &attribute.value.pieces { if let TextPiece::DoubleBraceExpression { tag, attributes } = &template { let is_save = tag.to_string().to_lowercase() == "game data/saves"; let is_config = tag.to_string().to_lowercase() == "game data/config"; if !is_save && !is_config { continue; } // Ignore templates with an empty path parameter. if attributes.len() > 1 && attributes[1].value.to_string().is_empty() { continue; } out.templates.push(template.to_string()); } } } } _ => {} } } } Ok(out) } pub fn parse_paths(&self, article: String) -> Vec { self.parse_all_paths(article) .into_iter() .filter(|x| x.usable()) .collect() } fn parse_all_paths(&self, article: String) -> Vec { let mut out = vec![]; for raw in &self.templates { let preprocessed = preprocess_text(raw); let parsed = wikitext_parser::parse_wikitext(&preprocessed, article.clone(), |_| ()); for template in parsed.list_double_brace_expressions() { if let TextPiece::DoubleBraceExpression { tag, attributes } = &template { let is_save = tag.to_string() == "Game data/saves"; let is_config = tag.to_string() == "Game data/config"; if (!is_save && !is_config) || attributes.len() < 2 { continue; } let platform = attributes[0].value.to_string(); for attribute in attributes.iter().skip(1) { let info = flatten_path(attribute) .with_platform(&platform) .with_tags(is_save, is_config) .normalize(); out.push(info); } } } } out } pub fn any_irregular_paths(&self, article: String) -> bool { for path in self.parse_all_paths(article) { if path.irregular() || path.semiregular() { return true; } } false } } #[derive(Debug, Clone, Copy)] pub enum PathKind { File, Registry, } #[derive(Debug, Default)] pub struct WikiPath { pub composite: String, pub regularity: Regularity, pub kind: Option, pub store: Option, pub os: Option, pub tags: BTreeSet, } impl WikiPath { fn incorporate(&mut self, other: Self) { self.regularity = self.regularity.worst(other.regularity); if other.kind.is_some() { self.kind = other.kind; } if other.store.is_some() { self.store = other.store; } if other.os.is_some() { self.os = other.os; } } pub fn incorporate_text(&mut self, text: &str) { if text.contains(['<', '>']) { self.regularity = Regularity::Irregular; } else { self.composite += text; } } pub fn incorporate_raw(&mut self, other: Self) { self.incorporate_text(&other.composite); self.incorporate(other) } pub fn incorporate_path(&mut self, other: Self) { if let Some(mapped) = MAPPED_PATHS.get(other.composite.to_lowercase().as_str()) { self.composite += mapped.manifest; if mapped.kind.is_some() { self.kind = mapped.kind; } if mapped.store.is_some() { self.store = mapped.store; } if mapped.os.is_some() { self.os = mapped.os; } } else if !other.composite.is_empty() { self.regularity = Regularity::Irregular; } self.incorporate(other) } pub fn normalize(mut self) -> Self { self.composite = self.composite.trim().trim_end_matches(['/', '\\']).replace('\\', "/"); if self.composite == "~" || self.composite.starts_with("~/") { self.composite = self.composite.replacen('~', placeholder::HOME, 1); } static CONSECUTIVE_SLASHES: Lazy = Lazy::new(|| Regex::new(r"/{2,}").unwrap()); static UNNECESSARY_DOUBLE_STAR_1: Lazy = Lazy::new(|| Regex::new(r"([^/*])\*{2,}").unwrap()); static UNNECESSARY_DOUBLE_STAR_2: Lazy = Lazy::new(|| Regex::new(r"\*{2,}([^/*])").unwrap()); static ENDING_WILDCARD: Lazy = Lazy::new(|| Regex::new(r"(/\*)+$").unwrap()); static APP_DATA: Lazy = Lazy::new(|| Regex::new(r"(?i)%appdata%").unwrap()); static APP_DATA_ROAMING: Lazy = Lazy::new(|| Regex::new(r"(?i)%userprofile%/AppData/Roaming").unwrap()); static APP_DATA_LOCAL: Lazy = Lazy::new(|| Regex::new(r"(?i)%localappdata%").unwrap()); static APP_DATA_LOCAL_2: Lazy = Lazy::new(|| Regex::new(r"(?i)%userprofile%/AppData/Local/").unwrap()); static USER_PROFILE: Lazy = Lazy::new(|| Regex::new(r"(?i)%userprofile%").unwrap()); static DOCUMENTS: Lazy = Lazy::new(|| Regex::new(r"(?i)%userprofile%/Documents").unwrap()); for (pattern, replacement) in [ (&CONSECUTIVE_SLASHES, "/"), (&UNNECESSARY_DOUBLE_STAR_1, "${1}*"), (&UNNECESSARY_DOUBLE_STAR_2, "*${1}"), (&ENDING_WILDCARD, ""), (&APP_DATA, placeholder::WIN_APP_DATA), (&APP_DATA_ROAMING, placeholder::WIN_APP_DATA), (&APP_DATA_LOCAL, placeholder::WIN_LOCAL_APP_DATA), (&APP_DATA_LOCAL_2, &format!("{}/", placeholder::WIN_LOCAL_APP_DATA)), (&USER_PROFILE, placeholder::HOME), (&DOCUMENTS, placeholder::WIN_DOCUMENTS), ] { self.composite = pattern.replace_all(&self.composite, replacement).to_string(); } if self.kind.is_none() { self.kind = Some(PathKind::File); } self } pub fn with_platform(mut self, platform: &str) -> Self { match platform.to_lowercase().trim() { "windows" => { self.os = Some(Os::Windows); } "os x" => { self.os = Some(Os::Mac); } "linux" => { self.os = Some(Os::Linux); } "dos" => { self.os = Some(Os::Dos); } "steam" => { self.store = Some(Store::Steam); } "microsoft store" => { self.os = Some(Os::Windows); self.store = Some(Store::Microsoft); } "gog.com" => { self.store = Some(Store::Gog); } "epic games" => { self.store = Some(Store::Epic); } "uplay" => { self.store = Some(Store::Uplay); } "origin" => { self.store = Some(Store::Origin); } _ => {} } self } pub fn with_tags(mut self, save: bool, config: bool) -> Self { if save { self.tags.insert(Tag::Save); } if config { self.tags.insert(Tag::Config); } self } fn too_broad(&self) -> bool { use placeholder::{BASE, HOME, ROOT, STORE_USER_ID, WIN_DIR, WIN_DOCUMENTS}; let placeholders: Vec<_> = MAPPED_PATHS.values().map(|x| x.manifest).collect(); if placeholders.iter().any(|x| *x == self.composite) { return true; } // These paths are present whether or not the game is installed. // If possible, they should be narrowed down on the wiki. let broad = vec![ format!("{BASE}/{STORE_USER_ID}"), // because `` is handled as `*` format!("{HOME}/Documents"), format!("{HOME}/Saved Games"), format!("{HOME}/AppData"), format!("{HOME}/AppData/Local"), format!("{HOME}/AppData/Local/Packages"), format!("{HOME}/AppData/LocalLow"), format!("{HOME}/AppData/Roaming"), format!("{HOME}/Documents/My Games"), format!("{HOME}/Library/Application Support"), format!("{HOME}/Library/Preferences"), format!("{HOME}/Telltale Games"), format!("{ROOT}/config"), format!("{WIN_DIR}/win.ini"), format!("{WIN_DOCUMENTS}/My Games"), format!("{WIN_DOCUMENTS}/Telltale Games"), ]; if broad.iter().any(|x| *x == self.composite) { return true; } // Several games/episodes are grouped together here. if self .composite .starts_with(&format!("{WIN_DOCUMENTS}/Telltale Games/*/")) { return true; } // Drive letters: static DRIVES: Lazy = Lazy::new(|| Regex::new(r"^[a-zA-Z]:$").unwrap()); if DRIVES.is_match(&self.composite) { return true; } // Root: if self.composite == "/" { return true; } // Relative path wildcard: if self.composite.starts_with('*') { return true; } false } fn irregular(&self) -> bool { self.regularity == Regularity::Irregular || self.composite.contains("{{") } fn semiregular(&self) -> bool { self.regularity == Regularity::Semiregular } pub fn usable(&self) -> bool { !self.composite.is_empty() && !self.irregular() && !self.too_broad() && !self.composite.starts_with("./") && !self.composite.starts_with("../") } } #[derive(Debug, Default)] pub struct MappedPath { pub manifest: &'static str, pub os: Option, pub store: Option, pub kind: Option, } pub fn flatten_path(attribute: &Attribute) -> WikiPath { let mut out = WikiPath::default(); for piece in &attribute.value.pieces { match piece { TextPiece::Text { text, .. } => { out.incorporate_text(text); } TextPiece::DoubleBraceExpression { tag, attributes } => match tag.to_string().to_lowercase().trim() { "p" | "path" => { for attribute in attributes { let flat = flatten_path(attribute); out.incorporate_path(flat); } } "code" | "file" => { // These could be used for a path segment or for a note, but we assume path segment. out.regularity = Regularity::Semiregular; out.composite += "*"; } "localizedpath" => { for attribute in attributes { let flat = flatten_path(attribute); out.incorporate_raw(flat); } } "note" | "cn" => { // Ignored. } _ => { out.regularity = Regularity::Irregular; } }, TextPiece::InternalLink { .. } => {} TextPiece::ListItem { .. } => {} } } out } /// https://www.pcgamingwiki.com/wiki/Template:Path static MAPPED_PATHS: Lazy> = Lazy::new(|| { HashMap::from_iter([ // General ( "game", MappedPath { manifest: placeholder::BASE, ..Default::default() }, ), ( "uid", MappedPath { manifest: placeholder::STORE_USER_ID, ..Default::default() }, ), ( "steam", MappedPath { manifest: placeholder::ROOT, store: Some(Store::Steam), ..Default::default() }, ), ( "uplay", MappedPath { manifest: placeholder::ROOT, store: Some(Store::Uplay), ..Default::default() }, ), ( "ubisoftconnect", MappedPath { manifest: placeholder::ROOT, store: Some(Store::Uplay), ..Default::default() }, ), // Windows registry ( "hkcu", MappedPath { manifest: "HKEY_CURRENT_USER", os: Some(Os::Windows), kind: Some(PathKind::Registry), ..Default::default() }, ), ( "hkey_current_user", MappedPath { manifest: "HKEY_CURRENT_USER", os: Some(Os::Windows), kind: Some(PathKind::Registry), ..Default::default() }, ), ( "hklm", MappedPath { manifest: "HKEY_LOCAL_MACHINE", os: Some(Os::Windows), kind: Some(PathKind::Registry), ..Default::default() }, ), ( "hkey_local_machine", MappedPath { manifest: "HKEY_LOCAL_MACHINE", os: Some(Os::Windows), kind: Some(PathKind::Registry), ..Default::default() }, ), ( "wow64", MappedPath { manifest: "WOW6432Node", os: Some(Os::Windows), kind: Some(PathKind::Registry), ..Default::default() }, ), // Windows filesystem ( "username", MappedPath { manifest: placeholder::OS_USER_NAME, os: Some(Os::Windows), ..Default::default() }, ), ( "userprofile", MappedPath { manifest: placeholder::HOME, os: Some(Os::Windows), ..Default::default() }, ), ( "userprofile\\documents", MappedPath { manifest: placeholder::WIN_DOCUMENTS, os: Some(Os::Windows), ..Default::default() }, ), ( "userprofile\\appdata\\locallow", MappedPath { manifest: "/AppData/LocalLow", os: Some(Os::Windows), ..Default::default() }, ), ( "appdata", MappedPath { manifest: placeholder::WIN_APP_DATA, os: Some(Os::Windows), ..Default::default() }, ), ( "localappdata", MappedPath { manifest: placeholder::WIN_LOCAL_APP_DATA, os: Some(Os::Windows), ..Default::default() }, ), ( "public", MappedPath { manifest: placeholder::WIN_PUBLIC, os: Some(Os::Windows), ..Default::default() }, ), ( "allusersprofile", MappedPath { manifest: placeholder::WIN_PROGRAM_DATA, os: Some(Os::Windows), ..Default::default() }, ), ( "programdata", MappedPath { manifest: placeholder::WIN_PROGRAM_DATA, os: Some(Os::Windows), ..Default::default() }, ), ( "programfiles", MappedPath { manifest: "C:/Program Files", os: Some(Os::Windows), ..Default::default() }, ), ( "windir", MappedPath { manifest: placeholder::WIN_DIR, os: Some(Os::Windows), ..Default::default() }, ), ( "syswow64", MappedPath { manifest: "/SysWOW64", os: Some(Os::Windows), ..Default::default() }, ), // Mac ( "osxhome", MappedPath { manifest: placeholder::HOME, os: Some(Os::Mac), ..Default::default() }, ), // Linux ( "linuxhome", MappedPath { manifest: placeholder::HOME, os: Some(Os::Linux), ..Default::default() }, ), ( "xdgdatahome", MappedPath { manifest: placeholder::XDG_DATA, os: Some(Os::Linux), ..Default::default() }, ), ( "xdgconfighome", MappedPath { manifest: placeholder::XDG_CONFIG, os: Some(Os::Linux), ..Default::default() }, ), ]) }); #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct WikiMetaCache { pub last_checked_recent_changes: chrono::DateTime, } impl ResourceFile for WikiMetaCache { const FILE_NAME: &'static str = "data/wiki-meta-cache.yaml"; fn initialize(mut self) -> Self { self.last_checked_recent_changes = chrono::Utc::now() - chrono::Duration::days(1); self } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_is_game_article() { assert!(matches!(is_game_article("Celeste").await, Ok(true))); assert!(matches!(is_game_article("Template:Path").await, Ok(false))); } }