squigit-rs 0.1.2

Public Rust facade for Squigit application services
Documentation
// Copyright 2026 a7mddra
// SPDX-License-Identifier: Apache-2.0

//! Shared update refresh and decision logic for Squigit shells.

use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;

use chrono::{NaiveDate, Utc};
use semver::Version;
use serde::Deserialize;
use thiserror::Error;

use crate::storage::{self, ProductVersion, StorageError, VersionFile, VersionType};
use crate::urls::SQUIGIT_RELEASES_URL;

const UPDATE_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);

#[derive(Debug, Error)]
pub enum UpdateError {
    #[error("Network request failed: {0}")]
    Network(String),
    #[error("Invalid releases.json payload: {0}")]
    InvalidRemote(String),
    #[error("Invalid CalVer format: {0}")]
    InvalidCalVer(String),
    #[error("Invalid SemVer format: {0}")]
    InvalidSemVer(String),
    #[error("Version storage failed: {0}")]
    Storage(#[from] StorageError),
    #[error("Version-store lock task failed: {0}")]
    LockTask(String),
}

pub type Result<T> = std::result::Result<T, UpdateError>;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum UpdateProduct {
    App,
    Cli,
    Ocr,
}

impl UpdateProduct {
    pub fn key(self) -> &'static str {
        match self {
            Self::App => "app",
            Self::Cli => "cli",
            Self::Ocr => "ocr",
        }
    }

    pub fn display_name(self) -> &'static str {
        match self {
            Self::App => "Squigit",
            Self::Cli => "Squigit CLI",
            Self::Ocr => "Squigit OCR",
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum UpdateShell {
    App,
    Cli,
}

#[derive(Clone, Debug)]
pub struct PendingUpdate {
    pub product: UpdateProduct,
    pub product_name: String,
    pub current_version: String,
    pub latest_version: String,
    pub released_at: String,
    pub content: String,
}

#[derive(Clone, Debug, Default)]
pub struct UpdateRefreshContext {
    pub app_version: Option<String>,
    pub ocr_resource_dir: Option<PathBuf>,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RefreshSource {
    Network,
    Cache,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RefreshOutcome {
    pub source: RefreshSource,
}

#[derive(Debug, Deserialize)]
struct RemoteVersionFile {
    app: RemoteProductVersion,
    cli: RemoteProductVersion,
    ocr: RemoteProductVersion,
}

#[derive(Debug, Deserialize)]
struct RemoteProductVersion {
    current_version: Option<String>,
    latest_version: String,
    version_type: VersionType,
    released_at: String,
    content: String,
}

impl RemoteProductVersion {
    fn into_stored(self, current_version: Option<String>) -> ProductVersion {
        ProductVersion {
            current_version,
            latest_version: self.latest_version,
            version_type: self.version_type,
            released_at: self.released_at,
            content: self.content,
        }
    }
}

pub async fn refresh_version_file(context: UpdateRefreshContext) -> Result<RefreshOutcome> {
    refresh_version_file_with_cli(context, None).await
}

/// Refresh the shared version file from the CLI without probing another
/// `squigit` executable on PATH.
pub async fn refresh_cli_version_file(cli_version: String) -> Result<RefreshOutcome> {
    refresh_version_file_with_cli(UpdateRefreshContext::default(), Some(cli_version)).await
}

async fn refresh_version_file_with_cli(
    context: UpdateRefreshContext,
    cli_version: Option<String>,
) -> Result<RefreshOutcome> {
    let store = storage::version_store()?;
    let lock_store = store.clone();
    let guard = tokio::task::spawn_blocking(move || lock_store.lock())
        .await
        .map_err(|error| UpdateError::LockTask(error.to_string()))??;

    let cached = guard.load()?;
    let app_version = non_empty(context.app_version).or_else(|| {
        cached
            .as_ref()
            .and_then(|file| file.app.current_version.clone())
    });
    let _ocr_resource_dir = context.ocr_resource_dir;
    let explicit_cli_version = non_empty(cli_version);
    let cli_version_future = async {
        match explicit_cli_version {
            Some(version) => Some(version),
            None => discover_cli_version().await,
        }
    };
    let (remote_result, cli_version, ocr_version) = tokio::join!(
        fetch_remote_versions(),
        cli_version_future,
        discover_ocr_version(),
    );
    let cli_version = cli_version.or_else(|| {
        cached
            .as_ref()
            .and_then(|file| file.cli.current_version.clone())
    });
    let ocr_version = ocr_version.or_else(|| {
        cached
            .as_ref()
            .and_then(|file| file.ocr.current_version.clone())
    });

    let remote_result = remote_result.and_then(|remote| {
        validate_remote_file(&remote)?;
        Ok(remote)
    });

    match remote_result {
        Ok(remote) => {
            let file = VersionFile {
                app: remote.app.into_stored(app_version),
                cli: remote.cli.into_stored(cli_version),
                ocr: remote.ocr.into_stored(ocr_version),
                last_fetch_at: Utc::now(),
            };
            guard.save(&file)?;
            Ok(RefreshOutcome {
                source: RefreshSource::Network,
            })
        }
        Err(network_error) => {
            let Some(mut cached) = cached else {
                return Err(network_error);
            };
            cached.app.current_version = app_version;
            cached.cli.current_version = cli_version;
            cached.ocr.current_version = ocr_version;
            guard.save(&cached)?;
            Ok(RefreshOutcome {
                source: RefreshSource::Cache,
            })
        }
    }
}

pub fn decide_update(shell: UpdateShell) -> Result<Option<PendingUpdate>> {
    let store = storage::version_store()?;
    let Some(file) = store.load()? else {
        return Ok(None);
    };

    let (shell_product, shell_version) = match shell {
        UpdateShell::App => (UpdateProduct::App, &file.app),
        UpdateShell::Cli => (UpdateProduct::Cli, &file.cli),
    };

    match product_is_outdated(shell_version)? {
        Some(true) => return Ok(Some(pending_update(shell_product, shell_version))),
        Some(false) => {}
        None => return Ok(None),
    }

    match product_is_outdated(&file.ocr)? {
        Some(true) => Ok(Some(pending_update(UpdateProduct::Ocr, &file.ocr))),
        Some(false) | None => Ok(None),
    }
}

pub fn is_calver_outdated(current: &str, latest: &str) -> Result<bool> {
    Ok(parse_calver(current)? < parse_calver(latest)?)
}

pub fn is_semver_outdated(current: &str, latest: &str) -> Result<bool> {
    let current =
        Version::parse(current).map_err(|_| UpdateError::InvalidSemVer(current.to_string()))?;
    let latest =
        Version::parse(latest).map_err(|_| UpdateError::InvalidSemVer(latest.to_string()))?;
    Ok(current < latest)
}

fn product_is_outdated(product: &ProductVersion) -> Result<Option<bool>> {
    let Some(current) = product.current_version.as_deref() else {
        return Ok(None);
    };
    compare_versions(current, &product.latest_version, product.version_type).map(Some)
}

fn pending_update(product: UpdateProduct, version: &ProductVersion) -> PendingUpdate {
    PendingUpdate {
        product,
        product_name: product.display_name().to_string(),
        current_version: version.current_version.clone().unwrap_or_default(),
        latest_version: version.latest_version.clone(),
        released_at: version.released_at.clone(),
        content: version.content.clone(),
    }
}

fn compare_versions(current: &str, latest: &str, version_type: VersionType) -> Result<bool> {
    match version_type {
        VersionType::Calver => is_calver_outdated(current, latest),
        VersionType::Semver => is_semver_outdated(current, latest),
    }
}

fn parse_calver(value: &str) -> Result<NaiveDate> {
    let parts = value.split('.').collect::<Vec<_>>();
    if parts.len() != 3
        || parts[0].len() != 2
        || parts[1].len() != 2
        || parts[2].len() != 2
        || parts
            .iter()
            .any(|part| !part.chars().all(|character| character.is_ascii_digit()))
    {
        return Err(UpdateError::InvalidCalVer(value.to_string()));
    }

    let year = parts[0]
        .parse::<i32>()
        .map_err(|_| UpdateError::InvalidCalVer(value.to_string()))?;
    let month = parts[1]
        .parse::<u32>()
        .map_err(|_| UpdateError::InvalidCalVer(value.to_string()))?;
    let day = parts[2]
        .parse::<u32>()
        .map_err(|_| UpdateError::InvalidCalVer(value.to_string()))?;
    NaiveDate::from_ymd_opt(2000 + year, month, day)
        .ok_or_else(|| UpdateError::InvalidCalVer(value.to_string()))
}

async fn fetch_remote_versions() -> Result<RemoteVersionFile> {
    let client = reqwest::Client::builder()
        .timeout(UPDATE_REQUEST_TIMEOUT)
        .build()
        .map_err(|error| UpdateError::Network(error.to_string()))?;
    let response = client
        .get(SQUIGIT_RELEASES_URL)
        .send()
        .await
        .and_then(reqwest::Response::error_for_status)
        .map_err(|error| UpdateError::Network(error.to_string()))?;
    response
        .json::<RemoteVersionFile>()
        .await
        .map_err(|error| UpdateError::InvalidRemote(error.to_string()))
}

fn validate_remote_file(file: &RemoteVersionFile) -> Result<()> {
    validate_remote_product(&file.app)?;
    validate_remote_product(&file.cli)?;
    validate_remote_product(&file.ocr)
}

fn validate_remote_product(product: &RemoteProductVersion) -> Result<()> {
    if product.current_version.is_some() {
        return Err(UpdateError::InvalidRemote(
            "remote current_version must be null".to_string(),
        ));
    }
    compare_versions(
        &product.latest_version,
        &product.latest_version,
        product.version_type,
    )?;
    NaiveDate::parse_from_str(&product.released_at, "%Y-%m-%d")
        .map_err(|error| UpdateError::InvalidRemote(error.to_string()))?;
    Ok(())
}

async fn discover_cli_version() -> Option<String> {
    tokio::task::spawn_blocking(read_cli_version)
        .await
        .ok()
        .flatten()
}

fn read_cli_version() -> Option<String> {
    let mut command = Command::new("squigit");
    command.arg("--version");
    #[cfg(target_os = "windows")]
    {
        use std::os::windows::process::CommandExt;
        command.creation_flags(0x08000000);
    }
    let output = command.output().ok()?;
    if !output.status.success() {
        return None;
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    extract_semver(&format!("{stdout}\n{stderr}"))
}

async fn discover_ocr_version() -> Option<String> {
    tokio::task::spawn_blocking(read_ocr_version)
        .await
        .ok()
        .flatten()
}

fn read_ocr_version() -> Option<String> {
    let sidecar_path = squigit_ocr::sidecar::resolve_sidecar_path();
    squigit_ocr::sidecar::read_sidecar_version(&sidecar_path).ok()
}

fn extract_semver(raw: &str) -> Option<String> {
    raw.split(|character: char| {
        !(character.is_ascii_alphanumeric()
            || character == '.'
            || character == '-'
            || character == '+')
    })
    .filter(|part| !part.is_empty())
    .filter_map(|part| Version::parse(part).ok())
    .next_back()
    .map(|version| version.to_string())
}

fn non_empty(value: Option<String>) -> Option<String> {
    value.and_then(|value| {
        let value = value.trim();
        (!value.is_empty()).then(|| value.to_string())
    })
}