floe-core 0.3.6

Core library for Floe, a YAML-driven technical ingestion tool.
Documentation
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::path::PathBuf;

use crate::io::storage::StorageClient;
use crate::FloeResult;

#[derive(Debug, Clone)]
pub struct ObjectRef {
    pub uri: String,
    pub key: String,
    pub last_modified: Option<String>,
    pub size: Option<u64>,
}

pub fn join_prefix(prefix: &str, name: &str) -> String {
    let left = prefix.trim_matches('/');
    let right = name.trim_matches('/');
    if left.is_empty() {
        right.to_string()
    } else if right.is_empty() {
        left.to_string()
    } else {
        format!("{left}/{right}")
    }
}

pub fn normalize_separators(value: &str) -> String {
    value.replace('\\', "/").trim_matches('/').to_string()
}

pub fn stable_sort_refs(mut refs: Vec<ObjectRef>) -> Vec<ObjectRef> {
    refs.sort_by(|a, b| a.uri.cmp(&b.uri));
    refs
}

pub fn object_ref(
    uri: String,
    key: String,
    last_modified: Option<String>,
    size: Option<u64>,
) -> ObjectRef {
    ObjectRef {
        uri,
        key,
        last_modified,
        size,
    }
}

pub fn filter_by_suffixes(refs: Vec<ObjectRef>, suffixes: &[String]) -> Vec<ObjectRef> {
    let suffixes = suffixes
        .iter()
        .map(|suffix| suffix.to_ascii_lowercase())
        .collect::<Vec<_>>();
    refs.into_iter()
        .filter(|obj| {
            let lower = obj.uri.to_ascii_lowercase();
            !lower.ends_with('/') && suffixes.iter().any(|suffix| lower.ends_with(suffix))
        })
        .collect()
}

pub fn ensure_parent_dir(path: &Path) -> FloeResult<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    Ok(())
}

pub fn temp_path_for_key(temp_dir: &Path, key: &str) -> PathBuf {
    let mut hasher = DefaultHasher::new();
    key.hash(&mut hasher);
    let hash = hasher.finish();
    let name = Path::new(key)
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("object");
    let sanitized = sanitize_filename(name);
    temp_dir.join(format!("{hash:016x}_{sanitized}"))
}

pub fn file_name_from_key(key: &str) -> Option<String> {
    Path::new(key)
        .file_name()
        .map(|name| name.to_string_lossy().to_string())
}

pub fn file_stem_from_name(name: &str) -> Option<String> {
    Path::new(name)
        .file_stem()
        .map(|stem| stem.to_string_lossy().to_string())
}

pub fn exists_by_key(client: &dyn StorageClient, key: &str) -> FloeResult<bool> {
    if key.is_empty() {
        return Ok(false);
    }
    let refs = client.list(key)?;
    Ok(refs.iter().any(|object| object.key == key))
}

pub fn copy_via_temp(client: &dyn StorageClient, src_uri: &str, dst_uri: &str) -> FloeResult<()> {
    let temp_dir = tempfile::TempDir::new()?;
    let temp_path = client.download_to_temp(src_uri, temp_dir.path())?;
    client.upload_from_path(&temp_path, dst_uri)?;
    Ok(())
}

fn sanitize_filename(name: &str) -> String {
    name.chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
                ch
            } else {
                '_'
            }
        })
        .collect()
}