aviutl2-cli 0.8.3

AviUtl2のプラグイン・スクリプト開発に便利なコマンドラインツール
use anyhow::{Context, Result, bail};
use std::collections::HashSet;
use std::path::PathBuf;
use std::process::Command;

use crate::config::{BuildCommand, Config, ConfigLoadOpts, PlacementMethod, load_config};
use crate::util::{copy_to_destination, development_dir, find_aviutl2_data_dir, resolve_source};

pub struct ResolvedArtifact {
    pub source: PathBuf,
    pub destination: PathBuf,
    pub build_plan: ResolvedBuild,
    pub placement_method: PlacementMethod,
}

pub struct ResolvedBuild {
    pub commands: Vec<String>,
    pub group: Option<String>,
}

pub fn run(
    profile: Option<String>,
    skip_start: bool,
    refresh: bool,
    args: Vec<String>,
    opts: &ConfigLoadOpts,
) -> Result<()> {
    let config = load_config(opts)?;
    let dev = config
        .development
        .as_ref()
        .context("development 設定が必要です")?;
    warn_if_prepare_snapshot_changed(&config, &dev.aviutl2_version)?;
    let install_dir = development_dir(dev)?;
    let profile = profile.as_deref().unwrap_or(&dev.profile);
    run_optional_commands(Some(&dev.prebuild), &config.build_group)?;
    let artifacts = resolve_artifacts(&config, Some(profile), None, refresh)?;
    let data_dir = find_aviutl2_data_dir(&install_dir)?;
    let mut anything_copied = false;
    let mut executed_groups = HashSet::new();
    for artifact in artifacts {
        run_build_plan(&artifact.build_plan, &mut executed_groups)?;
        let dest = data_dir.join(&artifact.destination);
        let needs_copy = matches!(artifact.placement_method, PlacementMethod::Copy);
        if needs_copy {
            copy_to_destination(&artifact.source, &dest, true)?;
            anything_copied = true;
        }
    }

    if anything_copied {
        tracing::info!("成果物を配置しました");
    }
    run_optional_commands(Some(&dev.postbuild), &config.build_group)?;

    if !skip_start {
        let aviutl_exe = data_dir.parent().unwrap_or(&data_dir).join("aviutl2.exe");
        if aviutl_exe.exists() {
            tracing::info!("AviUtl2 を起動します: {}", aviutl_exe.display());
            Command::new(aviutl_exe)
                .args(args)
                .spawn()
                .with_context(|| "AviUtl2 の起動に失敗しました")?;
        } else {
            tracing::warn!("AviUtl2.exe が見つかりません: {}", aviutl_exe.display());
        }
    }
    Ok(())
}

fn warn_if_prepare_snapshot_changed(config: &Config, aviutl2_version: &str) -> Result<()> {
    let Some(snapshot) = super::prepare::load_prepare_snapshot()? else {
        return Ok(());
    };
    let mut ordered = std::collections::BTreeMap::new();
    for (name, artifact) in &config.artifacts {
        ordered.insert(name.clone(), artifact.clone());
    }
    let current = super::prepare::PrepareSnapshot {
        aviutl2_version: aviutl2_version.to_string(),
        artifacts: ordered,
    };
    if snapshot.aviutl2_version != current.aviutl2_version
        || snapshot.artifacts != current.artifacts
    {
        tracing::warn!(
            "prepare 実行時の設定と現在の設定が異なります。必要なら `au2 prepare` を再実行してください。"
        );
    }
    Ok(())
}

pub fn resolve_artifacts(
    config: &Config,
    profile: Option<&str>,
    include: Option<&[String]>,
    refresh: bool,
) -> Result<Vec<ResolvedArtifact>> {
    let mut resolved = Vec::new();
    for (name, artifact) in &config.artifacts {
        if let Some(include) = include
            && !include.iter().any(|item| item == name)
        {
            continue;
        }
        let profile_data = profile.and_then(|p| {
            artifact
                .profiles
                .as_ref()
                .and_then(|profiles| profiles.get(p))
        });
        let enabled = profile_data
            .and_then(|p| p.enabled)
            .or(artifact.enabled)
            .unwrap_or(true);
        if !enabled {
            continue;
        }
        let source = profile_data
            .and_then(|p| p.source.clone())
            .or_else(|| artifact.source.clone())
            .with_context(|| format!("artifacts.{}.source が必要です", name))?;
        let source = resolve_source(&source, refresh)?;
        let build = profile_data
            .and_then(|p| p.build.clone())
            .or_else(|| artifact.build.clone());
        let build_plan = resolve_build_plan(build.as_ref(), &config.build_group)?;
        let placement_method = artifact
            .placement_method
            .unwrap_or(PlacementMethod::Symlink);
        resolved.push(ResolvedArtifact {
            source,
            destination: PathBuf::from(&artifact.destination),
            build_plan,
            placement_method,
        });
    }
    Ok(resolved)
}

pub fn run_build_plan(plan: &ResolvedBuild, executed_groups: &mut HashSet<String>) -> Result<()> {
    if let Some(group) = &plan.group {
        if executed_groups.contains(group) {
            return Ok(());
        }
        run_build_commands(&plan.commands)?;
        executed_groups.insert(group.clone());
        return Ok(());
    }
    run_build_commands(&plan.commands)
}

pub fn run_build_commands(commands: &[String]) -> Result<()> {
    for cmd in commands {
        tracing::info!("コマンド実行: {}", cmd);
        let status = run_shell_command(cmd)?;
        if !status.success() {
            bail!("ビルドコマンドが失敗しました: {}", cmd);
        }
    }
    Ok(())
}

pub(crate) fn run_optional_commands(
    commands: Option<&BuildCommand>,
    build_groups: &std::collections::HashMap<String, BuildCommand>,
) -> Result<()> {
    let commands = resolve_build_commands(commands, build_groups)?;
    if !commands.is_empty() {
        run_build_commands(&commands)?;
    }
    Ok(())
}

fn resolve_build_commands(
    command: Option<&BuildCommand>,
    build_groups: &std::collections::HashMap<String, BuildCommand>,
) -> Result<Vec<String>> {
    let mut visiting = std::collections::HashSet::new();
    resolve_build_commands_inner(command, build_groups, &mut visiting)
}

fn resolve_build_plan(
    command: Option<&BuildCommand>,
    build_groups: &std::collections::HashMap<String, BuildCommand>,
) -> Result<ResolvedBuild> {
    let commands = resolve_build_commands(command, build_groups)?;
    let group = match command {
        Some(BuildCommand::Group(group_ref)) => Some(group_ref.group.clone()),
        _ => None,
    };
    Ok(ResolvedBuild { commands, group })
}

fn resolve_build_commands_inner(
    command: Option<&BuildCommand>,
    build_groups: &std::collections::HashMap<String, BuildCommand>,
    visiting: &mut std::collections::HashSet<String>,
) -> Result<Vec<String>> {
    match command {
        None => Ok(Vec::new()),
        Some(BuildCommand::Single(cmd)) => Ok(vec![cmd.clone()]),
        Some(BuildCommand::Multiple(cmds)) => Ok(cmds.clone()),
        Some(BuildCommand::Group(group_ref)) => {
            let name = &group_ref.group;
            let group = build_groups
                .get(name)
                .with_context(|| format!("build_group.{} が見つかりません", name))?;
            if !visiting.insert(name.clone()) {
                bail!("build_group の循環参照を検出しました: {}", name);
            }
            let resolved = resolve_build_commands_inner(Some(group), build_groups, visiting);
            visiting.remove(name);
            resolved
        }
    }
}

fn run_shell_command(command: &str) -> Result<std::process::ExitStatus> {
    if cfg!(windows) {
        Command::new("cmd")
            .args(["/C", command])
            .status()
            .map_err(Into::into)
    } else {
        Command::new("sh")
            .args(["-c", command])
            .status()
            .map_err(Into::into)
    }
}