webapp-rs 0.1.0

A simple CLI tool to create webapps (only support firefox and linux for now.
use anyhow::{Context, Result};
use dirs::data_local_dir;
use reqwest::blocking::get;
use std::fs;
use std::path::{Path, PathBuf};
use url::Url;

use crate::WEBAPP_PATH;

/// Répertoire de stockage des icônes personnalisées
fn icons_dir() -> Result<PathBuf> {
    let data_local =
        data_local_dir().ok_or_else(|| anyhow::anyhow!("No data local directory found"))?;
    let icons_dir = data_local.join(WEBAPP_PATH).join("icons");
    fs::create_dir_all(&icons_dir)
        .with_context(|| format!("Failed to create icons directory: {:?}", icons_dir))?;
    Ok(icons_dir)
}

/// Renvoie l'extension d'un fichier (sans le point)
fn get_extension(path: &Path) -> Option<String> {
    path.extension()
        .map(|ext| ext.to_string_lossy().to_string())
}

/// Crée le chemin de l'icône stockée
fn get_stored_icon_path(codename: &str, ext: &str) -> PathBuf {
    icons_dir()
        .expect("Failed to get icons dir")
        .join(format!("{}.{}", codename, ext))
}

/// Résout le chemin de l'icône
/// - Si c'est un nom système (ex: "firefox"): retourne tel quel
/// - Si c'est un chemin local: copie et retourne le nouveau chemin
/// - Si c'est une URL HTTPS: télécharge et retourne le nouveau chemin
pub fn resolve_icon(codename: &str, icon_input: &str) -> Result<String> {
    // Nom système: pas de slash ni https://
    if !icon_input.contains('/') && !icon_input.starts_with("http") {
        return Ok(icon_input.to_string());
    }

    let input_path = Path::new(icon_input);

    // URL HTTPS
    if icon_input.starts_with("https://") {
        let ext = icon_extension_from_url(icon_input);
        let output_path = get_stored_icon_path(codename, &ext);
        download_icon(icon_input, &output_path)?;
        return Ok(output_path.to_string_lossy().to_string());
    }

    // Fichier local
    if input_path.exists() {
        let ext = get_extension(input_path).context("Cannot determine icon extension from file")?;
        let output_path = get_stored_icon_path(codename, &ext);
        copy_icon(input_path, &output_path)?;
        return Ok(output_path.to_string_lossy().to_string());
    }

    // Si on arrive là, c'est que l'input n'est pas valide
    Err(anyhow::anyhow!("Invalid icon path: {}", icon_input))
}

/// Détermine l'extension depuis une URL
fn icon_extension_from_url(url: &str) -> String {
    let default_ext = "png".to_string();

    if let Some(query_start) = url.find('?') {
        let url_without_query = &url[..query_start];
        Path::new(url_without_query)
            .extension()
            .map(|ext| ext.to_string_lossy().to_string())
            .unwrap_or(default_ext)
    } else {
        Path::new(url)
            .extension()
            .map(|ext| ext.to_string_lossy().to_string())
            .unwrap_or(default_ext)
    }
}

/// Copie une icône locale
fn copy_icon(src: &Path, dest: &Path) -> Result<()> {
    fs::copy(src, dest)
        .with_context(|| format!("Failed to copy icon from {:?} to {:?}", src, dest))?;
    Ok(())
}

/// Télécharge une icône depuis une URL
fn download_icon(url: &str, dest: &Path) -> Result<()> {
    let response = get(url).with_context(|| format!("Failed to download icon from {}", url))?;

    let mut file = fs::File::create(dest)
        .with_context(|| format!("Failed to create icon file at {:?}", dest))?;

    let content = response
        .bytes()
        .with_context(|| format!("Failed to read response from {}", url))?;

    use std::io::Write;
    file.write_all(&content)
        .with_context(|| format!("Failed to write icon to {:?}", dest))?;

    Ok(())
}

/// Supprime l'icône personnalisée si elle existe
pub fn delete_icon(codename: &str) -> Result<()> {
    let icons_dir = icons_dir()?;

    // Trouver l'icône stockée (extension inconnue)
    let mut found = vec![];
    for entry in fs::read_dir(&icons_dir)
        .with_context(|| format!("Failed to read icons directory: {:?}", icons_dir))?
    {
        let entry = entry?;
        let path = entry.path();
        if let Some(name) = path.file_name() {
            let name_str = name.to_string_lossy();
            if name_str.starts_with(&format!("{}.", codename)) {
                found.push(path);
            }
        }
    }

    // Supprimer les fichiers trouvés (normalement 1 seule icône)
    for icon_path in found {
        fs::remove_file(&icon_path)
            .with_context(|| format!("Failed to delete icon at {:?}", icon_path))?;
    }

    Ok(())
}

/// Récupère automatiquement la favicon depuis l'URL donnée
pub fn fetch_favicon(url: &str, codename: &str) -> Result<String> {
    let parsed_url = Url::parse(url).with_context(|| format!("Failed to parse URL: {}", url))?;
    let base_url = format!(
        "{}://{}",
        parsed_url.scheme(),
        parsed_url.host_str().unwrap()
    );

    // Tenter de récupérer la favicon depuis le HTML
    if let Some(favicon_url) =
        fetch_favicon_from_html(&base_url).context("Failed to fetch and parse HTML")?
    {
        return download_and_store_favicon(&favicon_url, codename);
    }

    // Fallback: /favicon.ico
    let ico_url = format!("{}/favicon.ico", base_url);
    if test_url_exists(&ico_url) {
        return download_and_store_favicon(&ico_url, codename);
    }

    // Dernier fallback: service Google
    let domain = parsed_url.host_str().unwrap();
    let google_url = format!("https://www.google.com/s2/favicons?domain={}&sz=64", domain);
    download_and_store_favicon(&google_url, codename)
}

/// Parse le HTML pour trouver les <link rel="icon">
fn fetch_favicon_from_html(base_url: &str) -> Result<Option<String>> {
    let response =
        get(base_url).with_context(|| format!("Failed to fetch HTML from {}", base_url))?;

    let html = response
        .text()
        .with_context(|| format!("Failed to read HTML from {}", base_url))?;

    // Regex pour trouver <link rel="icon"> et <link rel="apple-touch-icon">
    let icon_regex = regex::Regex::new(
        r#"<link[^>]+rel=["'](?:icon|apple-touch-icon|shortcut icon)["'][^>]+href=["']([^"']+)["']"#,
    )?;

    for cap in icon_regex.captures_iter(&html) {
        if let Some(href) = cap.get(1) {
            let href_str = href.as_str();

            // Résoudre les URLs relatives
            let favicon_url = if href_str.starts_with("http") {
                href_str.to_string()
            } else if href_str.starts_with("//") {
                format!("{}:{}", base_url.split(':').next().unwrap(), href_str)
            } else if href_str.starts_with('/') {
                format!("{}{}", base_url, href_str)
            } else {
                format!("{}/{}", base_url, href_str)
            };

            // Vérifier que l'URL existe
            if test_url_exists(&favicon_url) {
                return Ok(Some(favicon_url));
            }
        }
    }

    Ok(None)
}

/// Vérifie si une URL est accessible
fn test_url_exists(url: &str) -> bool {
    get(url)
        .map(|resp| resp.status().is_success())
        .unwrap_or(false)
}

/// Télécharge et stocke la favicon
fn download_and_store_favicon(url: &str, codename: &str) -> Result<String> {
    let ext = icon_extension_from_url(url);
    let output_path = get_stored_icon_path(codename, &ext);
    download_icon(url, &output_path)?;
    Ok(output_path.to_string_lossy().to_string())
}