cockup 0.2.3

Yet another backup tool for various configurations
use super::{atomic_write, cache_dir, now, read_cache, utc_time, validate_name};
use crate::report;
use serde::{Deserialize, Serialize};
use std::{fs, io::Read, os::unix::fs::PermissionsExt, path::Path, time::Duration};

const SOURCE: &str =
    "https://api.github.com/repos/huaium/cockup/contents/ingredients/library?ref=main";
const FILE: &str = ".catalog.json";
const MAX_RESPONSE: usize = 2 * 1024 * 1024;
const REFRESH_AFTER: u64 = 24 * 60 * 60;

#[derive(Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Catalog {
    version: u8,
    source: String,
    downloaded_at_unix: u64,
    checked_at_unix: u64,
    etag: Option<String>,
    names: Vec<String>,
}

#[derive(Deserialize)]
struct Entry {
    name: String,
    path: String,
    #[serde(rename = "type")]
    kind: String,
}

fn cached(dir: &Path) -> Option<Catalog> {
    let bytes = fs::read(dir.join(FILE)).ok()?;
    if bytes.len() > MAX_RESPONSE {
        return None;
    }
    let catalog: Catalog = serde_json::from_slice(&bytes).ok()?;
    if catalog.version != 1 || catalog.source != SOURCE || catalog.names.len() >= 1000 {
        return None;
    }
    if catalog
        .names
        .iter()
        .any(|name| validate_name(name).is_err())
        || catalog.names.windows(2).any(|pair| pair[0] >= pair[1])
    {
        return None;
    }
    Some(catalog)
}

fn save(dir: &Path, catalog: &Catalog) -> Result<(), String> {
    fs::create_dir_all(dir).map_err(|error| error.to_string())?;
    fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
        .map_err(|error| error.to_string())?;
    let bytes = serde_json::to_vec_pretty(catalog).map_err(|error| error.to_string())?;
    atomic_write(&dir.join(FILE), &bytes)
}

fn fetch(etag: Option<&str>) -> Result<(u16, Option<String>, Vec<u8>), String> {
    #[cfg(debug_assertions)]
    let test_url = std::env::var("COCKUP_INGREDIENT_CATALOG_TEST_URL").ok();
    #[cfg(not(debug_assertions))]
    let test_url: Option<String> = None;
    let local_test = test_url
        .as_deref()
        .is_some_and(|url| url.starts_with("http://127.0.0.1:"));
    if test_url.is_some() && !local_test {
        return Err("Ingredient catalog test URL must use loopback HTTP".into());
    }
    let url = test_url.as_deref().unwrap_or(SOURCE);
    let config = ureq::Agent::config_builder()
        .https_only(!local_test)
        .timeout_global(Some(Duration::from_secs(15)))
        .http_status_as_error(false)
        .build();
    let agent = ureq::Agent::new_with_config(config);
    let mut request = agent
        .get(url)
        .header("Accept", "application/vnd.github+json")
        .header("User-Agent", "cockup");
    if let Some(etag) = etag {
        request = request.header("If-None-Match", etag);
    }
    let mut response = request.call().map_err(|error| error.to_string())?;
    let status = response.status().as_u16();
    let etag = response
        .headers()
        .get("etag")
        .and_then(|value| value.to_str().ok())
        .map(str::to_owned);
    let mut body = Vec::new();
    if status == 200 {
        response
            .body_mut()
            .as_reader()
            .take((MAX_RESPONSE + 1) as u64)
            .read_to_end(&mut body)
            .map_err(|error| error.to_string())?;
        if body.len() > MAX_RESPONSE {
            return Err("Ingredient catalog response is too large".into());
        }
    }
    Ok((status, etag, body))
}

fn names(body: &[u8]) -> Result<Vec<String>, String> {
    let entries: Vec<Entry> = serde_json::from_slice(body)
        .map_err(|error| format!("Invalid GitHub ingredient catalog: {error}"))?;
    if entries.len() >= 1000 {
        return Err("GitHub ingredient directory reached its 1,000-file listing limit".into());
    }
    let mut names = Vec::new();
    for entry in entries {
        if entry.kind != "file" || !entry.name.ends_with(".yaml") {
            continue;
        }
        if entry.path != format!("ingredients/library/{}", entry.name) {
            return Err("GitHub ingredient catalog contains an unexpected path".into());
        }
        let name = entry.name.trim_end_matches(".yaml");
        validate_name(name)?;
        names.push(name.to_string());
    }
    names.sort();
    names.dedup();
    Ok(names)
}

fn catalog(dir: &Path, refresh: bool, allow_stale: bool) -> Result<Catalog, String> {
    let previous = cached(dir);
    let current_time = now();
    if !refresh
        && let Some(catalog) = &previous
        && catalog.checked_at_unix <= current_time
        && current_time - catalog.checked_at_unix < REFRESH_AFTER
    {
        return Ok(catalog.clone());
    }
    let response = fetch(
        previous
            .as_ref()
            .and_then(|catalog| catalog.etag.as_deref()),
    );
    match response {
        Ok((200, etag, body)) => {
            let catalog = Catalog {
                version: 1,
                source: SOURCE.into(),
                downloaded_at_unix: current_time,
                checked_at_unix: current_time,
                etag,
                names: names(&body)?,
            };
            save(dir, &catalog)?;
            Ok(catalog)
        }
        Ok((304, _, _)) => {
            let mut catalog = previous.ok_or("GitHub returned 304 without a cached catalog")?;
            catalog.checked_at_unix = current_time;
            save(dir, &catalog)?;
            Ok(catalog)
        }
        Ok((404, _, _)) => Err("GitHub ingredient library was not found".into()),
        Ok((status, _, _)) if allow_stale => {
            fallback(previous, &format!("GitHub returned HTTP {status}"))
        }
        Ok((status, _, _)) => Err(format!(
            "Cannot refresh ingredient catalog: GitHub returned HTTP {status}"
        )),
        Err(error) if allow_stale => fallback(previous, &error),
        Err(error) => Err(format!("Cannot refresh ingredient catalog: {error}")),
    }
}

fn fallback(previous: Option<Catalog>, reason: &str) -> Result<Catalog, String> {
    if let Some(catalog) = previous {
        report::warning(&format!(
            "Could not refresh ingredient catalog ({reason}); using cached list"
        ));
        Ok(catalog)
    } else {
        Err(format!("Cannot fetch ingredient catalog: {reason}"))
    }
}

pub(crate) fn search(query: &str) -> Result<(), String> {
    let query = query.trim();
    if query.is_empty() {
        return Err("Search query must not be empty".into());
    }
    let normalized = query
        .split_whitespace()
        .collect::<Vec<_>>()
        .join("-")
        .to_ascii_lowercase();
    let dir = cache_dir()?;
    let catalog = catalog(&dir, false, true)?;
    let matches: Vec<_> = catalog
        .names
        .iter()
        .filter(|name| name.contains(&normalized))
        .collect();
    if matches.is_empty() {
        println!("No ingredients match `{query}`.");
    } else {
        for name in matches {
            if read_cache(name, &dir).is_some() {
                println!("{name} [cached]");
            } else {
                println!("{name}");
            }
        }
    }
    Ok(())
}

pub(crate) fn status(dir: &Path) -> Result<(), String> {
    if let Some(catalog) = cached(dir) {
        let age = now().saturating_sub(catalog.checked_at_unix);
        let freshness = if catalog.checked_at_unix <= now() && age < REFRESH_AFTER {
            "fresh"
        } else {
            "stale"
        };
        println!("Catalog: {} entries ({freshness})", catalog.names.len());
        println!("  Source: {}", catalog.source);
        println!("  Downloaded: {}", utc_time(catalog.downloaded_at_unix));
        println!("  Checked: {}", utc_time(catalog.checked_at_unix));
        println!("  ETag: {}", catalog.etag.as_deref().unwrap_or("none"));
    } else {
        println!("Catalog: not cached");
    }
    Ok(())
}

pub(crate) fn refresh(dir: &Path) -> Result<(), String> {
    let catalog = catalog(dir, true, false)?;
    println!(
        "Ingredient catalog checked against GitHub ({} entries).",
        catalog.names.len()
    );
    Ok(())
}

pub(crate) fn delete(dir: &Path) -> Result<(), String> {
    let path = dir.join(FILE);
    match fs::remove_file(&path) {
        Ok(()) => println!("Deleted cached ingredient catalog."),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            println!("Ingredient catalog is not cached.");
        }
        Err(error) => return Err(format!("{}: {error}", path.display())),
    }
    Ok(())
}