aboutsummaryrefslogtreecommitdiff
path: root/libs/nak/src/game_finder/vdf.rs
diff options
context:
space:
mode:
Diffstat (limited to 'libs/nak/src/game_finder/vdf.rs')
-rw-r--r--libs/nak/src/game_finder/vdf.rs254
1 files changed, 254 insertions, 0 deletions
diff --git a/libs/nak/src/game_finder/vdf.rs b/libs/nak/src/game_finder/vdf.rs
new file mode 100644
index 0000000..c296f27
--- /dev/null
+++ b/libs/nak/src/game_finder/vdf.rs
@@ -0,0 +1,254 @@
+//! Hand-rolled VDF (Valve Data Format) parser
+//!
+//! Parses VDF files like appmanifest_*.acf and libraryfolders.vdf
+//! without external dependencies.
+
+use std::collections::HashMap;
+
+/// A VDF value - either a string or a nested object
+#[derive(Debug, Clone)]
+pub enum VdfValue {
+ String(String),
+ Object(HashMap<String, VdfValue>),
+}
+
+impl VdfValue {
+ /// Get as string reference
+ pub fn as_str(&self) -> Option<&str> {
+ match self {
+ VdfValue::String(s) => Some(s),
+ VdfValue::Object(_) => None,
+ }
+ }
+
+ /// Get as object reference
+ pub fn as_object(&self) -> Option<&HashMap<String, VdfValue>> {
+ match self {
+ VdfValue::String(_) => None,
+ VdfValue::Object(o) => Some(o),
+ }
+ }
+
+ /// Get a nested value by key
+ pub fn get(&self, key: &str) -> Option<&VdfValue> {
+ self.as_object()?.get(key)
+ }
+
+ /// Get a string value by key
+ pub fn get_str(&self, key: &str) -> Option<&str> {
+ self.get(key)?.as_str()
+ }
+}
+
+/// Parse a VDF file content into a root object
+pub fn parse_vdf(content: &str) -> Option<VdfValue> {
+ let mut chars = content.chars().peekable();
+ parse_object(&mut chars)
+}
+
+/// Parse an object (including the root level)
+fn parse_object<I: Iterator<Item = char>>(chars: &mut std::iter::Peekable<I>) -> Option<VdfValue> {
+ let mut map = HashMap::new();
+
+ loop {
+ skip_whitespace_and_comments(chars);
+
+ match chars.peek() {
+ None => break,
+ Some('}') => {
+ chars.next();
+ break;
+ }
+ Some('"') => {
+ // Parse key
+ let key = parse_quoted_string(chars)?;
+ skip_whitespace_and_comments(chars);
+
+ // Check what follows - string value or object
+ match chars.peek() {
+ Some('"') => {
+ // String value
+ let value = parse_quoted_string(chars)?;
+ map.insert(key, VdfValue::String(value));
+ }
+ Some('{') => {
+ // Object value
+ chars.next(); // consume '{'
+ let value = parse_object(chars)?;
+ map.insert(key, value);
+ }
+ _ => return None, // Unexpected token
+ }
+ }
+ _ => {
+ // Skip unexpected characters
+ chars.next();
+ }
+ }
+ }
+
+ Some(VdfValue::Object(map))
+}
+
+/// Parse a quoted string "..."
+fn parse_quoted_string<I: Iterator<Item = char>>(
+ chars: &mut std::iter::Peekable<I>,
+) -> Option<String> {
+ // Expect opening quote
+ if chars.next() != Some('"') {
+ return None;
+ }
+
+ let mut result = String::new();
+
+ loop {
+ match chars.next() {
+ None => return None, // Unterminated string
+ Some('"') => break,
+ Some('\\') => {
+ // Handle escape sequences
+ match chars.next() {
+ Some('n') => result.push('\n'),
+ Some('t') => result.push('\t'),
+ Some('\\') => result.push('\\'),
+ Some('"') => result.push('"'),
+ Some(c) => {
+ result.push('\\');
+ result.push(c);
+ }
+ None => return None,
+ }
+ }
+ Some(c) => result.push(c),
+ }
+ }
+
+ Some(result)
+}
+
+/// Skip whitespace and // comments
+fn skip_whitespace_and_comments<I: Iterator<Item = char>>(chars: &mut std::iter::Peekable<I>) {
+ loop {
+ // Skip whitespace
+ while chars.peek().is_some_and(|c| c.is_whitespace()) {
+ chars.next();
+ }
+
+ // Check for // comment
+ // Need to peek ahead without cloning - consume first / and check second
+ if chars.peek() == Some(&'/') {
+ // Consume first /
+ chars.next();
+ if chars.peek() == Some(&'/') {
+ // It's a comment - skip to end of line
+ chars.next();
+ while chars.peek().is_some_and(|c| *c != '\n') {
+ chars.next();
+ }
+ continue;
+ }
+ // Not a comment, but we consumed a '/' - this shouldn't happen in valid VDF
+ // Just continue processing
+ }
+
+ break;
+ }
+}
+
+/// Parse an appmanifest_*.acf file and extract app info
+#[derive(Debug, Clone)]
+pub struct AppManifest {
+ pub app_id: String,
+ pub name: String,
+ pub install_dir: String,
+ pub state_flags: u32,
+}
+
+impl AppManifest {
+ /// Parse from VDF content
+ pub fn from_vdf(content: &str) -> Option<Self> {
+ let root = parse_vdf(content)?;
+ let app_state = root.get("AppState")?;
+
+ Some(Self {
+ app_id: app_state.get_str("appid")?.to_string(),
+ name: app_state.get_str("name")?.to_string(),
+ install_dir: app_state.get_str("installdir")?.to_string(),
+ state_flags: app_state.get_str("StateFlags")?.parse().unwrap_or(0),
+ })
+ }
+
+ /// Check if the game is fully installed (StateFlags == 4)
+ pub fn is_installed(&self) -> bool {
+ self.state_flags == 4
+ }
+}
+
+/// Parse libraryfolders.vdf and extract library paths
+pub fn parse_library_folders(content: &str) -> Vec<String> {
+ let mut paths = Vec::new();
+
+ let Some(root) = parse_vdf(content) else {
+ return paths;
+ };
+
+ let Some(library_folders) = root.get("libraryfolders").and_then(|v| v.as_object()) else {
+ return paths;
+ };
+
+ // Library folders are keyed by index: "0", "1", "2", etc.
+ for value in library_folders.values() {
+ if let Some(path) = value.get_str("path") {
+ paths.push(path.to_string());
+ }
+ }
+
+ paths
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_appmanifest() {
+ let content = r#"
+"AppState"
+{
+ "appid" "489830"
+ "Universe" "1"
+ "name" "Skyrim Special Edition"
+ "StateFlags" "4"
+ "installdir" "Skyrim Special Edition"
+}
+"#;
+ let manifest = AppManifest::from_vdf(content).unwrap();
+ assert_eq!(manifest.app_id, "489830");
+ assert_eq!(manifest.name, "Skyrim Special Edition");
+ assert_eq!(manifest.install_dir, "Skyrim Special Edition");
+ assert!(manifest.is_installed());
+ }
+
+ #[test]
+ fn test_parse_library_folders() {
+ let content = r#"
+"libraryfolders"
+{
+ "0"
+ {
+ "path" "/home/user/.local/share/Steam"
+ "label" ""
+ }
+ "1"
+ {
+ "path" "/mnt/games/SteamLibrary"
+ "label" "Games"
+ }
+}
+"#;
+ let paths = parse_library_folders(content);
+ assert_eq!(paths.len(), 2);
+ assert!(paths.contains(&"/home/user/.local/share/Steam".to_string()));
+ assert!(paths.contains(&"/mnt/games/SteamLibrary".to_string()));
+ }
+}