wp-self-update 0.1.6

Self-update execution library for wp-labs binaries
Documentation
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UpdateChannel {
    Stable,
    Beta,
    Alpha,
}

impl UpdateChannel {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Stable => "stable",
            Self::Beta => "beta",
            Self::Alpha => "alpha",
        }
    }
}

#[derive(Debug, Clone)]
pub struct SourceConfig {
    pub channel: UpdateChannel,
    pub kind: SourceKind,
}

#[derive(Debug, Clone)]
pub enum SourceKind {
    Manifest {
        updates_base_url: String,
        updates_root: Option<PathBuf>,
    },
    GithubLatest {
        repo: GithubRepo,
    },
}

#[derive(Debug, Clone)]
pub struct CheckRequest {
    pub product: String,
    pub source: SourceConfig,
    pub current_version: String,
    pub branch: String,
}

#[derive(Debug, Clone)]
pub struct UpdateRequest {
    pub product: String,
    pub target: UpdateTarget,
    pub source: SourceConfig,
    pub current_version: String,
    pub install_dir: Option<PathBuf>,
    pub yes: bool,
    pub dry_run: bool,
    pub force: bool,
}

#[derive(Debug, Serialize)]
pub struct CheckReport {
    pub product: String,
    pub channel: String,
    pub branch: String,
    pub source: String,
    pub manifest_format: String,
    pub current_version: String,
    pub latest_version: String,
    pub update_available: bool,
    pub platform_key: String,
    pub artifact: String,
    pub sha256: String,
}

#[derive(Debug, Serialize)]
pub struct UpdateReport {
    pub product: String,
    pub channel: String,
    pub source: String,
    pub current_version: String,
    pub latest_version: String,
    pub install_dir: String,
    pub artifact: String,
    pub dry_run: bool,
    pub updated: bool,
    pub status: String,
}

#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum VersionRelation {
    UpdateAvailable,
    UpToDate,
    AheadOfChannel,
}

#[derive(Debug)]
pub struct ResolvedRelease {
    pub version: String,
    pub target: String,
    pub artifact: String,
    pub sha256: String,
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct GithubRepo {
    pub owner: String,
    pub name: String,
    pub url: String,
}

impl GithubRepo {
    pub fn parse(raw: &str) -> Result<Self, String> {
        let value = raw.trim().trim_end_matches('/');
        if value.is_empty() {
            return Err("GitHub repository cannot be empty".to_string());
        }

        let (owner, name) = if let Some(rest) = value.strip_prefix("https://github.com/") {
            parse_repo_segments(rest)?
        } else if let Some(rest) = value.strip_prefix("http://github.com/") {
            parse_repo_segments(rest)?
        } else if value.contains('/') && !value.contains("://") {
            parse_repo_segments(value)?
        } else {
            return Err(format!(
                "unsupported GitHub repository reference '{}': use https://github.com/<owner>/<repo> or <owner>/<repo>",
                raw
            ));
        };

        Ok(Self {
            url: format!("https://github.com/{owner}/{name}"),
            owner,
            name,
        })
    }

    pub fn latest_release_api_url(&self) -> String {
        format!(
            "https://api.github.com/repos/{}/{}/releases/latest",
            self.owner, self.name
        )
    }
}

fn parse_repo_segments(raw: &str) -> Result<(String, String), String> {
    let mut parts = raw
        .split('/')
        .filter(|segment| !segment.is_empty())
        .take(2)
        .map(str::to_string)
        .collect::<Vec<_>>();
    if parts.len() != 2 {
        return Err(format!(
            "invalid GitHub repository reference '{}': expected <owner>/<repo>",
            raw
        ));
    }
    if let Some(name) = parts.get_mut(1) {
        if let Some(trimmed) = name.strip_suffix(".git") {
            *name = trimmed.to_string();
        }
    }
    Ok((parts.remove(0), parts.remove(0)))
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub enum UpdateTarget {
    Product(UpdateProduct),
    Auto,
    Bins(Vec<String>),
}

#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UpdateProduct {
    Suite,
    Wparse,
    Wpgen,
    Wprescue,
    Wproj,
}

impl UpdateProduct {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Suite => "suite",
            Self::Wparse => "wparse",
            Self::Wpgen => "wpgen",
            Self::Wprescue => "wprescue",
            Self::Wproj => "wproj",
        }
    }

    pub fn bins(self) -> &'static [&'static str] {
        match self {
            Self::Suite => &["wparse", "wpgen", "wprescue", "wproj"],
            Self::Wparse => &["wparse"],
            Self::Wpgen => &["wpgen"],
            Self::Wprescue => &["wprescue"],
            Self::Wproj => &["wproj"],
        }
    }

    pub fn owned_bins(self) -> Vec<String> {
        self.bins().iter().map(|bin| (*bin).to_string()).collect()
    }
}

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

    #[test]
    fn parse_github_repo_from_full_url() {
        let repo = GithubRepo::parse("https://github.com/wp-labs/wpl-check").unwrap();
        assert_eq!(repo.owner, "wp-labs");
        assert_eq!(repo.name, "wpl-check");
        assert_eq!(repo.url, "https://github.com/wp-labs/wpl-check");
    }

    #[test]
    fn parse_github_repo_from_short_form() {
        let repo = GithubRepo::parse("wp-labs/wpl-check").unwrap();
        assert_eq!(repo.owner, "wp-labs");
        assert_eq!(repo.name, "wpl-check");
    }
}