rmcl 0.3.1

A fully featured Minecraft TUI launcher
// mojang-format launch profile types. mirrors the on-disk JSON schema
// used by vanilla versions, forge installer output, neoforge installer
// output, fabric profiles, and quilt profiles. parsing is lossless for
// the fields we care about; unknown fields are silently dropped (serde
// default behavior) - which is fine because we write upstream JSON
// byte-for-byte on the install side.

use serde::{Deserialize, Serialize};

use super::rules::Rule;

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchProfile {
    pub id: String,
    pub inherits_from: Option<String>,
    pub main_class: Option<String>,
    #[serde(default)]
    pub libraries: Vec<Library>,
    pub arguments: Option<Arguments>,
    pub minecraft_arguments: Option<String>,
    pub asset_index: Option<AssetIndex>,
    pub assets: Option<String>,
    pub java_version: Option<JavaVersion>,
    pub downloads: Option<VersionDownloads>,
    pub release_time: Option<String>,
    pub time: Option<String>,
    #[serde(rename = "type")]
    pub type_: Option<String>,
    // present only in rmcl <= 0.3.0's stripped loader-profile shape.
    // we deserialize it so the launch-time legacy-detection predicate
    // can confirm "this really is our old format, not an upstream
    // profile that happens to omit arguments". skipped on serialize so
    // we never propagate this field outward.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub game_arguments: Option<Vec<String>>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
pub struct Arguments {
    #[serde(default)]
    pub game: Vec<Argument>,
    #[serde(default)]
    pub jvm: Vec<Argument>,
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum Argument {
    Literal(String),
    Conditional {
        rules: Vec<Rule>,
        value: ArgumentValue,
    },
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ArgumentValue {
    Single(String),
    Multiple(Vec<String>),
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct Library {
    pub name: String,
    pub downloads: Option<LibraryDownloads>,
    pub rules: Option<Vec<Rule>>,
    pub url: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct LibraryDownloads {
    pub artifact: Option<Artifact>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct Artifact {
    pub url: String,
    pub path: String,
    pub sha1: String,
    pub size: u64,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct AssetIndex {
    pub id: String,
    pub url: String,
    pub sha1: String,
    pub size: Option<u64>,
    pub total_size: Option<u64>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JavaVersion {
    pub component: Option<String>,
    pub major_version: u32,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct VersionDownloads {
    pub client: Download,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct Download {
    pub url: String,
    pub sha1: String,
    pub size: u64,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::launch_profile::rules::RuleAction;

    const MODERN_FIXTURE: &str = r#"{
        "id": "1.20.1",
        "type": "release",
        "mainClass": "net.minecraft.client.main.Main",
        "assetIndex": {
            "id": "5",
            "url": "https://example.invalid/5.json",
            "sha1": "0000000000000000000000000000000000000000"
        },
        "javaVersion": {
            "component": "java-runtime-gamma",
            "majorVersion": 17
        },
        "libraries": [
            {
                "name": "org.lwjgl:lwjgl:3.3.1",
                "downloads": {
                    "artifact": {
                        "url": "https://example.invalid/lwjgl.jar",
                        "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar",
                        "sha1": "1111111111111111111111111111111111111111",
                        "size": 100
                    }
                },
                "rules": [
                    { "action": "allow", "os": { "name": "linux" } }
                ]
            }
        ],
        "arguments": {
            "game": [
                "--username", "${auth_player_name}",
                {
                    "rules": [{ "action": "allow", "features": { "is_demo_user": true } }],
                    "value": "--demo"
                }
            ],
            "jvm": [
                "-Djava.library.path=${natives_directory}",
                {
                    "rules": [{ "action": "allow", "os": { "name": "osx" } }],
                    "value": ["-XstartOnFirstThread"]
                }
            ]
        }
    }"#;

    const LEGACY_FIXTURE: &str = r#"{
        "id": "1.7.10",
        "type": "release",
        "mainClass": "net.minecraft.client.main.Main",
        "minecraftArguments": "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory}",
        "assetIndex": {
            "id": "1.7.10",
            "url": "https://example.invalid/1.7.10.json",
            "sha1": "0000000000000000000000000000000000000000"
        },
        "libraries": []
    }"#;

    const LOADER_FIXTURE: &str = r#"{
        "id": "1.20.1-forge-47.2.0",
        "inheritsFrom": "1.20.1",
        "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
        "libraries": [
            { "name": "net.minecraftforge:forge:47.2.0" }
        ],
        "arguments": {
            "game": ["--launchTarget", "forge_client"],
            "jvm": [
                "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler",
                "-DlibraryDirectory=${library_directory}"
            ]
        }
    }"#;

    #[test]
    fn parses_modern_arguments_object() {
        let profile: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap();
        assert_eq!(profile.id, "1.20.1");
        assert_eq!(
            profile.main_class.as_deref(),
            Some("net.minecraft.client.main.Main")
        );
        assert!(profile.inherits_from.is_none());
        assert!(profile.minecraft_arguments.is_none());

        let args = profile.arguments.as_ref().expect("arguments present");
        assert_eq!(args.game.len(), 3);
        assert_eq!(args.jvm.len(), 2);

        // first game arg should be a literal "--username"
        match &args.game[0] {
            Argument::Literal(s) => assert_eq!(s, "--username"),
            _ => panic!("expected literal"),
        }
        // third game arg should be a conditional with a single-string value
        match &args.game[2] {
            Argument::Conditional { rules, value } => {
                assert_eq!(rules.len(), 1);
                assert_eq!(rules[0].action, RuleAction::Allow);
                assert!(matches!(value, ArgumentValue::Single(_)));
            }
            _ => panic!("expected conditional"),
        }
        // second jvm arg should be a conditional with a multi-string value
        match &args.jvm[1] {
            Argument::Conditional { value, .. } => {
                assert!(matches!(value, ArgumentValue::Multiple(_)));
            }
            _ => panic!("expected conditional"),
        }
    }

    #[test]
    fn parses_legacy_minecraft_arguments_string() {
        let profile: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap();
        assert_eq!(profile.id, "1.7.10");
        assert!(profile.arguments.is_none());
        assert!(
            profile
                .minecraft_arguments
                .as_deref()
                .unwrap()
                .contains("${version_name}")
        );
        assert!(profile.libraries.is_empty());
    }

    #[test]
    fn parses_loader_profile_with_inherits_from() {
        let profile: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap();
        assert_eq!(profile.id, "1.20.1-forge-47.2.0");
        assert_eq!(profile.inherits_from.as_deref(), Some("1.20.1"));
        assert!(profile.asset_index.is_none()); // inherited from parent
        let args = profile.arguments.as_ref().unwrap();
        assert_eq!(args.game.len(), 2);
        assert_eq!(args.jvm.len(), 3);
    }

    #[test]
    fn modern_profile_round_trips() {
        let original: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap();
        let serialized = serde_json::to_string(&original).unwrap();
        let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap();
        assert_eq!(original, reparsed);
    }

    #[test]
    fn loader_profile_round_trips() {
        let original: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap();
        let serialized = serde_json::to_string(&original).unwrap();
        let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap();
        assert_eq!(original, reparsed);
    }

    #[test]
    fn legacy_profile_round_trips() {
        let original: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap();
        let serialized = serde_json::to_string(&original).unwrap();
        let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap();
        assert_eq!(original, reparsed);
    }
}