aviutl2-cli 0.8.3

AviUtl2のプラグイン・スクリプト開発に便利なコマンドラインツール
use anyhow::{Result, bail};
use fs_err as fs;
use std::path::PathBuf;
use strum::IntoEnumIterator;

#[derive(Debug, Clone)]
struct InitConfig {
    project_id: String,
    project_name: String,
    project_type: ProjectType,
    i18n: bool,
}

#[derive(Debug, Clone, Copy)]
enum ProjectType {
    PluginCpp { plugin_type: PluginType },
    PluginRust { plugin_type: PluginType },
    Script { script_type: ScriptType },
    None,
}

#[derive(Debug, Clone, Copy, strum::EnumString, strum::Display, strum::EnumIter)]
enum PluginType {
    #[strum(serialize = "入力プラグイン")]
    Input,
    #[strum(serialize = "出力プラグイン")]
    Output,
    #[strum(serialize = "フィルタプラグイン")]
    Filter,
    #[strum(serialize = "スクリプトモジュール")]
    ScriptModule,
    #[strum(serialize = "汎用プラグイン")]
    Common,
}
impl PluginType {
    pub fn suffix(&self) -> &'static str {
        match self {
            PluginType::Input => "aui2",
            PluginType::Output => "auo2",
            PluginType::Filter => "auf2",
            PluginType::ScriptModule => "mod2",
            PluginType::Common => "aux2",
        }
    }
}

#[derive(Debug, Clone, Copy, strum::EnumString, strum::Display, strum::EnumIter)]
enum ScriptType {
    #[strum(serialize = "アニメーション効果")]
    Anm,
    #[strum(serialize = "カスタムオブジェクト")]
    Obj,
    #[strum(serialize = "カメラ効果")]
    Cam,
    #[strum(serialize = "シーンチェンジ")]
    Scn,
    #[strum(serialize = "トラックバー移動方法")]
    Tra,
}
impl ScriptType {
    pub fn suffix(&self) -> &'static str {
        match self {
            ScriptType::Anm => "anm2",
            ScriptType::Obj => "obj2",
            ScriptType::Cam => "cam2",
            ScriptType::Scn => "scn2",
            ScriptType::Tra => "tra2",
        }
    }
}

pub fn run() -> Result<()> {
    let path = PathBuf::from("aviutl2.toml");
    if path.exists() {
        bail!("aviutl2.toml は既に存在します");
    }
    let current_dir = std::env::current_dir()?;

    let init_config = if dialoguer::console::user_attended() {
        ask_init_config(&current_dir)?
    } else {
        let project_name = current_dir
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("my_aviutl2_project")
            .to_string();
        let project_id = slugify(&project_name);
        InitConfig {
            project_id,
            project_name,
            project_type: ProjectType::PluginCpp {
                plugin_type: PluginType::Common,
            },
            i18n: false,
        }
    };
    let template = init_template(&init_config);
    fs::write(&path, template)?;
    tracing::info!("aviutl2.toml を作成しました");

    let gitignore_path = PathBuf::from(".gitignore");
    if gitignore_path.exists() {
        let mut content = fs::read_to_string(&gitignore_path)?;
        content.push_str("\n# AviUtl2 CLI\n/.aviutl2-cli\n/release\n");
        fs::write(&gitignore_path, content)?;
        tracing::info!(".gitignore を更新しました");
    } else {
        fs::write(&gitignore_path, "# AviUtl2 CLI\n/.aviutl2-cli\n/release\n")?;
        tracing::info!(".gitignore を作成しました");
    }
    Ok(())
}

fn ask_init_config(current_dir: &std::path::Path) -> Result<InitConfig> {
    let current_dir_name = current_dir
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("my_aviutl2_project");
    let project_name = dialoguer::Input::new()
        .with_prompt("プロジェクト名を入力してください")
        .default(current_dir_name.to_string())
        .interact_text()?;
    let project_id = dialoguer::Input::new()
        .with_prompt("プロジェクトIDを入力してください")
        .default(slugify(&project_name))
        .interact_text()?;

    let project_type = dialoguer::Select::new()
        .with_prompt("プロジェクトの種類を選択してください")
        .items([
            "プラグイン(C++)",
            "プラグイン(Rust)",
            "スクリプト",
            "テンプレートなし",
        ])
        .interact()?;

    let project_type = match project_type {
        0 | 1 => {
            let plugin_type = dialoguer::Select::new()
                .with_prompt("プラグインの種類を選択してください")
                .items(
                    PluginType::iter()
                        .map(|t| t.to_string())
                        .collect::<Vec<_>>(),
                )
                .interact()?;
            let plugin_type = PluginType::iter().nth(plugin_type).unwrap();
            if project_type == 0 {
                ProjectType::PluginCpp { plugin_type }
            } else {
                ProjectType::PluginRust { plugin_type }
            }
        }
        2 => {
            let script_type = dialoguer::Select::new()
                .with_prompt("スクリプトの種類を選択してください")
                .items(
                    ScriptType::iter()
                        .map(|t| t.to_string())
                        .collect::<Vec<_>>(),
                )
                .interact()?;
            let script_type = ScriptType::iter().nth(script_type).unwrap();
            ProjectType::Script { script_type }
        }
        3 => ProjectType::None,
        _ => unreachable!(),
    };

    let i18n = !matches!(project_type, ProjectType::None)
        && dialoguer::Confirm::new()
            .with_prompt("英語対応を追加しますか?")
            .interact()?;

    Ok(InitConfig {
        project_id,
        project_name,
        project_type,
        i18n,
    })
}

fn init_template(config: &InitConfig) -> String {
    let mut template = format!(
        dedent::dedent!(
            r#"
            #:schema ./.aviutl2-cli/aviutl2.schema.json
            # 設定ファイルについては https://github.com/sevenc-nanashi/aviutl2-cli を参照してください。
            [project]
            id = "{project_id}"
            name = "{project_name}"
            version = "0.1.0"

            [development]
            aviutl2_version = "latest"
            "#
        ),
        project_id = config.project_id,
        project_name = config.project_name
    );
    template.push('\n');

    let project_filename = slugify(&config.project_id.split(".").last().unwrap());

    if config.i18n {
        template.push('\n');
        template.push_str(&format!(
            dedent::dedent!(
                r#"
                [artifacts.English-{project_filename}-aul2]
                destination = "Language/English.{project_filename}.aul2"
                source = "./i18n/English.{project_filename}.aul2"

                [artifacts.English-aul2]
                enabled = false
                destination = "Language/English.aul2"
                source = "https://raw.githubusercontent.com/aviutl2/aviutl2_community_translation/refs/heads/main/locales/original_english.aul2"

                [artifacts.English-aul2.profiles.debug]
                enabled = true
                "#
            ),
            project_filename = project_filename
        ));
        template.push('\n');
    }
    match config.project_type {
        ProjectType::PluginCpp { plugin_type } => {
            let suffix = plugin_type.suffix();
            template.push('\n');
            template.push_str(&format!(
                dedent::dedent!(
                    r#"
                    [artifacts.{project_filename}-{suffix}]
                    destination = "Plugin/{project_filename}.{suffix}"

                    [artifacts.{project_filename}-{suffix}.profiles.debug]
                    build = ["cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug", "cmake --build build --config Debug"]
                    source = "build/Debug/{project_filename}.dll"

                    [artifacts.{project_filename}-{suffix}.profiles.release]
                    build = ["cmake -S . -B build -DCMAKE_BUILD_TYPE=Release", "cmake --build build --config Release"]
                    source = "build/Release/{project_filename}.dll"
                    "#
                ),
                suffix = suffix,
                project_filename = project_filename
            ));
            template.push('\n');
        }
        ProjectType::PluginRust { plugin_type } => {
            let suffix = plugin_type.suffix();
            template.push('\n');
            template.push_str(&format!(
                dedent::dedent!(
                    r#"
                    [artifacts.{project_filename}-{suffix}]
                    destination = "Plugin/{project_filename}.{suffix}"

                    [artifacts.{project_filename}-{suffix}.profiles.debug]
                    build = "cargo build"
                    source = "target/debug/{project_filename}.dll"

                    [artifacts.{project_filename}-{suffix}.profiles.release]
                    build = "cargo build --release"
                    source = "target/release/{project_filename}.dll"
                    "#
                ),
                project_filename = project_filename,
                suffix = suffix
            ));
            template.push('\n');
        }
        ProjectType::Script { script_type } => {
            let suffix = script_type.suffix();
            template.push('\n');
            template.push_str(&format!(
                dedent::dedent!(
                    r#"
                    [artifacts.{project_filename}-{suffix}]
                    destination = "Script/{project_filename}.{suffix}"
                    source = "src/{project_filename}.lua"
                    "#
                ),
                project_filename = project_filename,
                suffix = suffix
            ));
            template.push('\n');
        }
        ProjectType::None => {}
    }

    template.trim_end_matches('\n').to_string()
}

fn slugify(s: &str) -> String {
    let mut slug = String::with_capacity(s.len());
    for c in s.chars() {
        if c.is_ascii_alphanumeric() {
            slug.push(c.to_ascii_lowercase());
        } else if !slug.ends_with('_') {
            slug.push('_');
        }
    }
    slug.trim_matches('_').to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_slugify() {
        assert_eq!(slugify("My Project"), "my_project");
        assert_eq!(slugify("Hello, World!"), "hello_world");
        assert_eq!(slugify("AviUtl2 Plugin"), "aviutl2_plugin");
        assert_eq!(slugify("  Leading and trailing  "), "leading_and_trailing");
        assert_eq!(slugify("Multiple   Spaces"), "multiple_spaces");
        assert_eq!(
            slugify("Special-Characters!@#$%^&*()"),
            "special_characters"
        );
        assert_eq!(slugify("Already_Slugified"), "already_slugified");
        assert_eq!(slugify(""), "");
    }

    #[test]
    fn test_init_template() {
        let config = InitConfig {
            project_id: "my_plugin".to_string(),
            project_name: "My Plugin".to_string(),
            project_type: ProjectType::PluginCpp {
                plugin_type: PluginType::Filter,
            },
            i18n: true,
        };
        let template = init_template(&config);
        insta::assert_snapshot!(template);
    }
}