aviutl2-cli 0.8.3

AviUtl2のプラグイン・スクリプト開発に便利なコマンドラインツール
use anyhow::{Context, Result, bail};
use fs_err as fs;
use fs_err::File;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};

use crate::config::{Artifact, ConfigLoadOpts, PlacementMethod, load_config};
use crate::util::{
    check_and_warn_symlink_capability, copy_to_destination, create_symlink, development_dir,
    extract_zip, find_aviutl2_data_dir, prepare_snapshot_path, remove_path,
};

const API_BASE: &str = "https://api.aviutl2.jp";

pub fn aviutl2(opts: &ConfigLoadOpts) -> Result<()> {
    let config = load_config(opts)?;
    let dev = config
        .development
        .as_ref()
        .context("development 設定が必要です")?;
    let install_dir = development_dir(dev)?;
    aviutl2_in(&install_dir, &dev.aviutl2_version)?;
    init_config(&install_dir)?;
    Ok(())
}

pub fn aviutl2_in(install_dir: &std::path::Path, aviutl2_version: &str) -> Result<()> {
    fs::create_dir_all(install_dir)
        .with_context(|| format!("ディレクトリ作成に失敗しました: {}", install_dir.display()))?;
    let aviutl2_version = resolve_version(aviutl2_version)?;
    if let Ok(current_version) = fs::read_to_string(install_dir.join(".aviutl2-version"))
        && current_version == aviutl2_version
    {
        tracing::info!("AviUtl2 のバージョンが一致しています: {}", aviutl2_version);
        return Ok(());
    }

    let zip_path = download_aviutl2_zip(&aviutl2_version)?;
    extract_zip(&zip_path, install_dir)?;
    fs::remove_file(&zip_path).ok();
    tracing::info!("AviUtl2 を展開しました: {}", install_dir.display());
    let mut version = File::create(install_dir.join(".aviutl2-version"))?;
    version.write_all(aviutl2_version.as_bytes())?;
    Ok(())
}

fn init_config(install_dir: &std::path::Path) -> Result<()> {
    static CONFIG: &str = dedent::dedent!(
        r#"
        [Window.log]
        hide=0
        [Logger]
        ViewLogLevel=1
        FileLogLevel=1
        "#
    );
    let data_dir = install_dir.join("data");
    let config_path = data_dir.join("aviutl2.ini");
    if config_path.exists() {
        tracing::info!(
            "既に aviutl2.ini が存在するため、初期設定の書き込みをスキップします: {}",
            config_path.display()
        );
        return Ok(());
    }
    fs::create_dir_all(config_path.parent().unwrap()).with_context(|| {
        format!(
            "ディレクトリ作成に失敗しました: {}",
            config_path.parent().unwrap().display()
        )
    })?;
    fs::write(&config_path, CONFIG).with_context(|| {
        format!(
            "初期設定の書き込みに失敗しました: {}",
            config_path.display()
        )
    })?;
    tracing::info!("初期設定を書き込みました: {}", config_path.display());
    Ok(())
}

pub fn artifacts(
    force: bool,
    profile: Option<String>,
    refresh: bool,
    opts: &ConfigLoadOpts,
) -> Result<()> {
    let config = load_config(opts)?;
    let dev = config
        .development
        .as_ref()
        .context("development 設定が必要です")?;
    let install_dir = development_dir(dev)?;
    let profile = profile.as_deref().unwrap_or(&dev.profile);
    let artifacts = super::develop::resolve_artifacts(&config, Some(profile), None, refresh)?;
    let data_dir = find_aviutl2_data_dir(&install_dir)?;

    let has_symlink_artifact = artifacts
        .iter()
        .any(|a| matches!(a.placement_method, PlacementMethod::Symlink));
    if has_symlink_artifact {
        check_and_warn_symlink_capability()?;
    }

    for artifact in artifacts {
        let source = artifact.source;
        let dest = data_dir.join(&artifact.destination);
        match artifact.placement_method {
            PlacementMethod::Symlink => {
                let relative_source = if source.is_absolute() {
                    source.clone()
                } else {
                    std::env::current_dir()
                        .with_context(|| "カレントディレクトリの取得に失敗しました")?
                        .join(&source)
                };
                let to_source_relative =
                    pathdiff::diff_paths(&relative_source, dest.parent().unwrap()).with_context(
                        || {
                            format!(
                                "シンボリックリンクの相対パス計算に失敗しました: {} -> {}",
                                dest.display(),
                                relative_source.display()
                            )
                        },
                    )?;
                create_symlink(&to_source_relative, &dest, force)?
            }
            _ => {
                if !source.exists() {
                    tracing::warn!("source が見つかりません: {}", source.display());
                    continue;
                }
                copy_to_destination(&source, &dest, force)?
            }
        }
    }

    let catalog = crate::catalog::load_catalog_index(refresh)?;
    crate::catalog::sync(&data_dir, &catalog, &dev.catalog_dependencies)?;

    tracing::info!("成果物のシンボリックリンクを作成しました");
    save_prepare_snapshot(&config.artifacts, &dev.aviutl2_version)?;
    Ok(())
}

pub fn cleanup_data_generated_by_prepare(opts: &ConfigLoadOpts) -> Result<()> {
    let config = load_config(opts)?;
    let dev = config
        .development
        .as_ref()
        .context("development 設定が必要です")?;
    let install_dir = development_dir(dev)?;
    let data_dir = match find_aviutl2_data_dir(&install_dir) {
        Ok(data_dir) => data_dir,
        Err(err) => {
            tracing::warn!(
                "AviUtl2 の data ディレクトリが見つからないため、prepare の事前削除をスキップします: {err}"
            );
            return Ok(());
        }
    };

    let mut destinations = BTreeSet::new();
    if let Some(snapshot) = load_prepare_snapshot()? {
        for artifact in snapshot.artifacts.into_values() {
            destinations.insert(PathBuf::from(artifact.destination));
        }
    }
    for artifact in config.artifacts.into_values() {
        destinations.insert(PathBuf::from(artifact.destination));
    }

    for destination in destinations {
        let Some(target) = resolve_data_destination(&data_dir, &destination) else {
            tracing::warn!(
                "data 配下でないため削除をスキップします: {}",
                destination.display()
            );
            continue;
        };
        if target.exists() || target.is_symlink() {
            remove_path(&target)?;
            tracing::info!("前回の成果物を削除しました: {}", target.display());
        }
    }

    Ok(())
}

fn resolve_data_destination(data_dir: &Path, destination: &Path) -> Option<PathBuf> {
    if destination.is_absolute() {
        return None;
    }
    let mut normalized = PathBuf::new();
    for component in destination.components() {
        match component {
            Component::Normal(part) => normalized.push(part),
            Component::CurDir => {}
            _ => return None,
        }
    }
    if normalized.as_os_str().is_empty() {
        return None;
    }
    Some(data_dir.join(normalized))
}

#[derive(Serialize, Deserialize)]
pub struct PrepareSnapshot {
    pub aviutl2_version: String,
    pub artifacts: BTreeMap<String, Artifact>,
}

pub fn save_prepare_snapshot(
    artifacts: &std::collections::HashMap<String, Artifact>,
    aviutl2_version: &str,
) -> Result<()> {
    let mut ordered = BTreeMap::new();
    for (name, artifact) in artifacts {
        ordered.insert(name.clone(), artifact.clone());
    }
    let snapshot = PrepareSnapshot {
        aviutl2_version: aviutl2_version.to_string(),
        artifacts: ordered,
    };
    let snapshot_path = prepare_snapshot_path()?;
    if let Some(parent) = snapshot_path.parent() {
        fs::create_dir_all(parent)?;
    }
    let content = serde_json::to_string_pretty(&snapshot)?;
    fs::write(&snapshot_path, content)?;
    Ok(())
}

pub fn load_prepare_snapshot() -> Result<Option<PrepareSnapshot>> {
    let snapshot_path = prepare_snapshot_path()?;
    if !snapshot_path.exists() {
        return Ok(None);
    }
    let content = fs::read_to_string(snapshot_path)?;
    let snapshot = serde_json::from_str(&content)?;
    Ok(Some(snapshot))
}

fn resolve_version(version: &str) -> Result<String> {
    #[derive(serde::Deserialize)]
    struct VersionResponse {
        version: String,
    }

    let mut url = String::from(API_BASE);
    url.push_str(&format!("/versions/{}", version));
    let agent: ureq::Agent = ureq::Agent::config_builder()
        .max_redirects(5)
        .build()
        .into();
    let mut response = agent
        .get(&url)
        .header("User-Agent", "aviutl2-cli")
        .call()
        .with_context(|| format!("AviUtl2 のバージョン情報の取得に失敗しました: {}", version))?;
    let status = response.status();
    if !status.is_success() {
        bail!(
            "AviUtl2 のバージョン情報の取得に失敗しました: {} (HTTP {})",
            version,
            status
        );
    }

    let version_info: VersionResponse = response
        .body_mut()
        .read_json()
        .with_context(|| "AviUtl2 のバージョン情報の解析に失敗しました")?;

    Ok(version_info.version)
}

fn download_aviutl2_zip(version: &str) -> Result<PathBuf> {
    let mut url = String::from(API_BASE);
    url.push_str("/download");
    let agent: ureq::Agent = ureq::Agent::config_builder()
        .max_redirects(5)
        .build()
        .into();
    let response = agent
        .get(&url)
        .query("version", version)
        .query("type", "zip")
        .header("User-Agent", "aviutl2-cli")
        .call()
        .with_context(|| "AviUtl2 のダウンロードに失敗しました")?;
    let status = response.status();
    if !status.is_success() {
        bail!("AviUtl2 のダウンロードに失敗しました: {}", status);
    }

    let (_parts, body) = response.into_parts();
    let mut reader = body.into_reader();
    let mut buf = Vec::new();
    reader.read_to_end(&mut buf)?;

    let file_name = format!("aviutl2-{}.zip", version.replace('/', "_"));
    let mut tmp_path = std::env::temp_dir();
    tmp_path.push(file_name);
    let mut file = File::create(&tmp_path)?;
    file.write_all(&buf)?;
    Ok(tmp_path)
}

#[cfg(test)]
mod tests {
    use super::resolve_data_destination;
    use std::path::{Path, PathBuf};

    #[test]
    fn resolve_data_destination_rejects_escape_path() {
        let data = Path::new("C:/work/data");
        let dest = Path::new("../outside/file.txt");
        assert!(resolve_data_destination(data, dest).is_none());
    }

    #[test]
    fn resolve_data_destination_joins_normal_path() {
        let data = Path::new("C:/work/data");
        let dest = Path::new("Plugin/test.aux2");
        let resolved = resolve_data_destination(data, dest).unwrap();
        assert_eq!(resolved, PathBuf::from("C:/work/data/Plugin/test.aux2"));
    }
}