cargo-gra 0.6.2

Cargo subcommand for gtk-rust-app.
Documentation
// SPDX-License-Identifier: GPL-3.0-or-later

use std::{
    fs::{create_dir_all, File},
    io::Write,
    path::{Path, PathBuf},
};

use crate::ProjectDescriptor;

pub fn build_flatpak(
    project_dir: &Path,
    project_descriptor: &ProjectDescriptor,
    gra_gen_dir: &Path,
) {
    let data_dir = gra_gen_dir.join("data");
    create_dir_all(&data_dir).expect("Could not create data build directory.");

    super::desktop::create_desktop_file(project_descriptor, &data_dir)
        .expect("Could not create desktop file.");

    let dev_flatpak_manifest = include_str!("../../data/flatpak-dev.template.yml");
    create_flatpak_yml(
        project_descriptor,
        &data_dir,
        dev_flatpak_manifest,
        Some(".dev"),
    )
    .expect("Could not create flatpak yml.");

    let publish_flatpak_manifest = include_str!("../../data/flatpak.template.yml");
    create_flatpak_yml(
        project_descriptor,
        &data_dir,
        publish_flatpak_manifest,
        None,
    )
    .expect("Could not create flatpak yml.");

    if let Err(e) = create_app_descriptor_xml(project_descriptor, &data_dir) {
        eprintln!("[gra] {}", e);
        return;
    }
    if let Err(e) = create_images(project_dir, project_descriptor, &data_dir) {
        eprintln!("[gra] {}", e);
    }
}

fn create_images(
    project_dir: &Path,
    descriptor: &ProjectDescriptor,
    path: &Path,
) -> std::io::Result<()> {
    let app_desc = &descriptor.app;

    let file64 = path.join(format!("{}.64.png", &app_desc.id));
    println!("[gra] Generate {:?}", file64);
    std::fs::copy(project_dir.join("./assets/icon.64.png"), &file64)?;

    let file128 = path.join(format!("{}.128.png", &app_desc.id));
    println!("[gra] Generate {:?}", file128);
    std::fs::copy(project_dir.join("./assets/icon.128.png"), &file128)?;

    let file_svg = path.join(format!("{}.svg", &app_desc.id));
    println!("[gra] Generate {:?}", file_svg);
    std::fs::copy(project_dir.join("./assets/icon.svg"), &file_svg)?;
    Ok(())
}

fn create_flatpak_yml(
    descriptor: &ProjectDescriptor,
    path: &Path,
    template: &str,
    infix: Option<&str>,
) -> std::io::Result<()> {
    let app_desc = &descriptor.app;

    let mut path = PathBuf::from(path);
    path.push(format!("{}{}.yml", app_desc.id, infix.unwrap_or("")));

    println!("[gra] Generate {:?}", path);
    let mut file = File::create(path)?;

    let permissions = app_desc
        .permissions
        .iter()
        .map(|p| format!("- --{}", p))
        .collect::<Vec<String>>()
        .join("\n  ");
    file.write_all(
        template
            .replace(
                "{name}",
                descriptor
                    .app
                    .name
                    .as_ref()
                    .unwrap_or(&descriptor.package.name),
            )
            .replace("{id}", &app_desc.id)
            .replace("{permissions}", &permissions)
            .replace(
                "{runtime}",
                &app_desc
                    .flatpak_runtime_version
                    .as_ref()
                    .map(|s| format!("\"{}\"", s))
                    .unwrap_or_else(|| "\"43\"".to_string()),
            )
            .replace(
                "{modules}",
                &app_desc
                    .flatpak_modules
                    .as_ref()
                    .map(|modules| modules.join("\n"))
                    .unwrap_or_default(),
            )
            .as_bytes(),
    )?;
    Ok(())
}

fn as_tag_list<T>(
    elements: Option<&Vec<T>>,
    to_tag: impl Fn(&T) -> String + 'static,
    indent: Option<usize>,
) -> Option<String> {
    elements.map(|v| {
        v.iter()
            .map(to_tag)
            .collect::<Vec<String>>()
            .join(&format!("\n{}", &" ".repeat(indent.unwrap_or(0))))
    })
}

fn to_tag(value: &str, tagname: &str, linebreaks: bool, indentation: usize) -> String {
    if linebreaks {
        format!(
            "{0}<{1}>\n{2}\n{0}</{1}>",
            " ".repeat(indentation),
            tagname,
            value
        )
    } else {
        format!("{0}<{1}>{2}</{1}>", " ".repeat(indentation), tagname, value)
    }
}

fn create_app_descriptor_xml(descriptor: &ProjectDescriptor, path: &Path) -> std::io::Result<()> {
    let template = include_str!("../../data/appdata.template.xml");

    let app_desc = &descriptor.app;

    let mut path = PathBuf::from(path);
    path.push(format!("{}.appdata.xml", app_desc.id));

    println!("[gra] Generate {:?}", path);
    let mut file = File::create(path)?;

    file.write_all(
        template
            .replace("{id}", &app_desc.id)
            .replace("{name}", descriptor
                .app
                .name
                .as_ref()
                .unwrap_or(&descriptor.package.name))
            .replace("{summary}", &app_desc.summary)
            .replace("{description}", &app_desc.description.replace('\n', "\n        "))
            .replace(
                "{license}",
                descriptor.package.license.as_ref().unwrap_or(&"".into()),
            )
            .replace(
                "{homepage}",
                descriptor.package.homepage.as_ref().unwrap_or(&"".into()),
            )
            .replace(
                "{repository}",
                descriptor.package.repository.as_ref().unwrap_or(&"".into()),
            )
            .replace("{metadata_license}", &app_desc.metadata_license)
            .replace(
                "{recommends}",
                &as_tag_list(Some(&app_desc.recommends), serialize_recommend,
                None)
                .map(|s| to_tag(&s, "recommends", true, 4))
                .unwrap_or_else(|| "".into())
            )
            .replace(
                "{requires}",
                &as_tag_list(Some(&app_desc.requires), serialize_recommend,
                None)
                .map(|s| to_tag(&s, "requires", true, 4))
                .unwrap_or_else(|| "".into())
            )
            .replace(
                "{categories}",
                &as_tag_list(Some(&app_desc.categories), |category| {
                    to_tag(category, "category", false, 8)
                },None)
                .map(|s| to_tag(&s, "categories", true, 4))
                .unwrap_or_else(|| "".into()),
            )
            .replace(
                "{releases}",
                &as_tag_list(app_desc.releases.as_ref(), |r| {
                    format!(r#"
        <release version="{}" date="{}">
            <description>
                {}
            </description>
        </release>"#,
                        r.version,
                        r.date,
                        r.description.replace('\n', "\n                ")
                        )
                },Some(4))
                .map(|s| to_tag(&s, "releases", true, 4))
                .unwrap_or_else(|| "".into()),
            )
            .replace(
                "{screenshots}",
                &as_tag_list(app_desc.screenshots.as_ref(), |s| {
                    format!("        <screenshot {}><image  type=\"source\">{}</image></screenshot>", 
                        s.type_.as_ref().map(|t| format!("type=\"{}\"", t)).unwrap_or_else(|| "".into()), 
                        s.url
                    )
                },None)
                .map(|s| to_tag(&s, "screenshots", true, 4))
                .unwrap_or_else(|| "".into()),
            )
            .replace(
                "{author}",
                &to_tag(&descriptor.package.authors.as_ref().map(|authors| authors
                     .first()
                     .map(|name|
                        name.split_once('<')
                            .map(|t| t.0.trim().to_string())
                            .unwrap_or_else(|| name.clone())
                    ).unwrap_or_else(|| "".into())).unwrap_or_else(|| "".into())
                , "developer_name", false, 0)
            )
            .replace(
                "{content_rating}",
                &as_tag_list(app_desc.content_rating.as_ref(), |c| {
                    format!("        <content_attribute type=\"{}\">{}</content_attribute>", c.id, c.value)
                },None)
                .map(|cr| format!("    <content_rating type=\"oars-1.1\">\n{}\n    </content_rating>", cr))
                .unwrap_or_else(|| "    <content_rating type=\"oars-1.1\" />".into())
            )
            .as_bytes(),
    )?;
    Ok(())
}

fn serialize_recommend(re: &crate::Recommend) -> String {
    match re {
        crate::Recommend::Display(v) => {
            if let Some(v) = v.strip_prefix('>') {
                format!("        <display compare=\"gt\">{}</display>", v)
            } else if let Some(v) = v.strip_prefix(">=") {
                format!("        <display compare=\"ge\">{}</display>", v)
            } else if let Some(v) = v.strip_prefix('<') {
                format!("        <display compare=\"lt\">{}</display>", v)
            } else if let Some(v) = v.strip_prefix("<=") {
                format!("        <display compare=\"le\">{}</display>", v)
            } else if let Some(v) = v.strip_prefix("==") {
                format!("        <display compare=\"eq\">{}</display>", v)
            } else if let Some(v) = v.strip_prefix("!=") {
                format!("        <display compare=\"ne\">{}</display>", v)
            } else {
                format!("        <display>{}</display>", v)
            }
        }
        crate::Recommend::DisplayLength(v) => {
            if let Some(v) = v.strip_prefix('>') {
                format!(
                    "        <display_length compare=\"gt\">{}</display_length>",
                    v
                )
            } else if let Some(v) = v.strip_prefix(">=") {
                format!(
                    "        <display_length compare=\"ge\">{}</display_length>",
                    v
                )
            } else if let Some(v) = v.strip_prefix('<') {
                format!(
                    "        <display_length compare=\"lt\">{}</display_length>",
                    v
                )
            } else if let Some(v) = v.strip_prefix("<=") {
                format!(
                    "        <display_length compare=\"le\">{}</display_length>",
                    v
                )
            } else if let Some(v) = v.strip_prefix("==") {
                format!(
                    "        <display_length compare=\"eq\">{}</display_length>",
                    v
                )
            } else if let Some(v) = v.strip_prefix("!=") {
                format!(
                    "        <display_length compare=\"ne\">{}</display_length>",
                    v
                )
            } else {
                format!("        <display_length>{}</display_length>", v)
            }
        }
        crate::Recommend::Control(v) => to_tag(v, "control", false, 8),
    }
}

#[cfg(test)]
mod tests {
    use std::{collections::HashMap, env::temp_dir, vec};

    use crate::AppDescriptor;

    use super::*;

    fn desc() -> ProjectDescriptor {
        ProjectDescriptor {
            package: crate::PackageDescriptor {
                name: String::from("example"),
                version: String::from("0.1.0"),
                authors: Some(vec![String::from("Foo Bar")]),
                homepage: Some(String::from("https://foo.bar")),
                license: None,
                repository: None,
            },
            app: AppDescriptor {
                id: String::from("org.example.Test"),
                name: Some(String::from("Test")),
                generic_name: Some(String::from("Test")),
                summary: String::from("This is a test"),
                description: String::from("This is a test description"),
                categories: vec![String::from("GTK")],
                metadata_license: String::from("Foo"),
                screenshots: None,
                releases: None,
                content_rating: None,
                requires: vec![],
                recommends: vec![],
                permissions: vec!["share=network".into(), "socket=x11".into()],
                resources: None,
                flatpak_modules: None,
                flatpak_runtime_version: None,
                mimetype: None,
            },
            actions: Some(HashMap::new()),
            settings: Some(HashMap::new()),
        }
    }

    #[test]
    fn test_flatpak_dev_manifest_generation() {
        let temp = temp_dir().join("test_flatpak_dev_manifest_generation");
        create_dir_all(&temp).unwrap();
        let dev_flatpak_manifest = include_str!("../../data/flatpak-dev.template.yml");
        create_flatpak_yml(&desc(), temp.as_path(), dev_flatpak_manifest, None)
            .expect("Could not generate org.example.Test.yml");

        let content = std::fs::read_to_string(temp.join("org.example.Test.yml")).unwrap();

        let unreplaced_tag = regex::Regex::new(r"\{.*\}").unwrap();
        assert!(!unreplaced_tag.is_match(&content));
    }

    #[test]
    fn test_flatpak_prod_manifest_generation() {
        let temp = temp_dir().join("test_flatpak_prod_manifest_generation");
        create_dir_all(&temp).unwrap();
        let manifest = include_str!("../../data/flatpak.template.yml");
        create_flatpak_yml(&desc(), temp.as_path(), manifest, None)
            .expect("Could not generate org.example.Test.yml");

        let mut content = std::fs::read_to_string(temp.join("org.example.Test.yml")).unwrap();

        let unreplaced_tag = regex::Regex::new(r"(?m)\{(.*)\}").unwrap();
        assert!(unreplaced_tag.is_match(&content));

        assert!(content.contains("{sources}"));
        content = content.replace("{sources}", "");

        assert!(!unreplaced_tag.is_match(&content));
    }
}