arcthis 0.5.1

A command-line tool for reading and managing compressed files
use std::fmt::Write as _;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;

use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tempfile::NamedTempFile;

use crate::archive::{Archive, ArchiveOpenOptions};
use crate::error::{ArcthisError, Result};
use crate::lifecycle::{CollisionPolicy, commit_staged_path};
use crate::model::{ArchiveEntry, ArchiveFormat};

const INDEX_SCHEMA_VERSION: &str = "1";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IndexAction {
    Created,
    Refreshed,
    Reused,
    Deleted,
    WouldCreate,
    WouldRefresh,
    WouldReuse,
    WouldDelete,
    Missing,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IndexResult {
    pub archive: PathBuf,
    pub index_path: PathBuf,
    pub action: IndexAction,
    pub entries_indexed: u64,
    pub dry_run: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Fingerprint {
    size: u64,
    modified_seconds: u64,
    modified_nanos: u32,
}

#[derive(Debug, Serialize, Deserialize)]
struct IndexDocument {
    schema_version: String,
    fingerprint: Fingerprint,
    format: ArchiveFormat,
    entries: Vec<ArchiveEntry>,
}

pub fn maintain_index(
    archive_path: &Path,
    open_options: &ArchiveOpenOptions,
    refresh: bool,
    delete: bool,
    dry_run: bool,
) -> Result<IndexResult> {
    let canonical = canonical_archive(archive_path)?;
    let index_path = cache_path(&canonical, open_options.index_directory.as_deref())?;
    let existed = index_path.exists();
    if delete {
        if !dry_run && existed {
            fs::remove_file(&index_path)
                .map_err(|error| ArcthisError::io("deleting archive index", error))?;
        }
        return Ok(IndexResult {
            archive: canonical,
            index_path,
            action: if !existed {
                IndexAction::Missing
            } else if dry_run {
                IndexAction::WouldDelete
            } else {
                IndexAction::Deleted
            },
            entries_indexed: 0,
            dry_run,
        });
    }

    if !refresh
        && let Some(entries) = load_cached_entries(
            &canonical,
            detect_archive_format(&canonical, open_options)?,
            open_options.index_directory.as_deref(),
        )?
    {
        return Ok(IndexResult {
            archive: canonical,
            index_path,
            action: if dry_run {
                IndexAction::WouldReuse
            } else {
                IndexAction::Reused
            },
            entries_indexed: u64::try_from(entries.len()).unwrap_or(u64::MAX),
            dry_run,
        });
    }

    let archive = Archive::open_with_options(canonical.as_path(), open_options)?;
    let entries = if refresh {
        archive.refresh_entries()?
    } else {
        archive.entries()?
    };
    if dry_run {
        return Ok(IndexResult {
            archive: canonical,
            index_path,
            action: if existed {
                IndexAction::WouldRefresh
            } else {
                IndexAction::WouldCreate
            },
            entries_indexed: entries.len() as u64,
            dry_run: true,
        });
    }
    write_index(&canonical, archive.format(), &entries, &index_path)?;
    Ok(IndexResult {
        archive: canonical,
        index_path,
        action: if existed {
            IndexAction::Refreshed
        } else {
            IndexAction::Created
        },
        entries_indexed: entries.len() as u64,
        dry_run: false,
    })
}

fn detect_archive_format(path: &Path, open_options: &ArchiveOpenOptions) -> Result<ArchiveFormat> {
    Archive::open_with_options(path, open_options).map(|archive| archive.format())
}

pub(crate) fn load_cached_entries(
    archive_path: &Path,
    format: ArchiveFormat,
    index_directory: Option<&Path>,
) -> Result<Option<Vec<ArchiveEntry>>> {
    let Ok(canonical) = archive_path.canonicalize() else {
        return Ok(None);
    };
    let path = cache_path(&canonical, index_directory)?;
    let bytes = match fs::read(path) {
        Ok(bytes) => bytes,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => return Err(ArcthisError::io("reading archive index", error)),
    };
    let document: IndexDocument = match serde_json::from_slice(&bytes) {
        Ok(document) => document,
        Err(_) => return Ok(None),
    };
    if document.schema_version != INDEX_SCHEMA_VERSION
        || document.format != format
        || document.fingerprint != fingerprint(&canonical)?
    {
        return Ok(None);
    }
    Ok(Some(document.entries))
}

fn write_index(
    archive_path: &Path,
    format: ArchiveFormat,
    entries: &[ArchiveEntry],
    index_path: &Path,
) -> Result<()> {
    let parent = index_path
        .parent()
        .ok_or_else(|| ArcthisError::UnsupportedOperation {
            message: "archive index path has no parent".to_owned(),
        })?;
    fs::create_dir_all(parent)
        .map_err(|error| ArcthisError::io("creating archive index directory", error))?;
    let mut temporary = NamedTempFile::new_in(parent)
        .map_err(|error| ArcthisError::io("creating temporary archive index", error))?;
    let document = IndexDocument {
        schema_version: INDEX_SCHEMA_VERSION.to_owned(),
        fingerprint: fingerprint(archive_path)?,
        format,
        entries: entries.to_vec(),
    };
    serde_json::to_writer(temporary.as_file_mut(), &document).map_err(|error| {
        ArcthisError::io("serializing archive index", std::io::Error::other(error))
    })?;
    temporary
        .as_file_mut()
        .flush()
        .map_err(|error| ArcthisError::io("flushing archive index", error))?;
    temporary
        .as_file()
        .sync_all()
        .map_err(|error| ArcthisError::io("syncing archive index", error))?;
    let (_, staged) = temporary
        .keep()
        .map_err(|error| ArcthisError::io("preserving staged archive index", error.error))?;
    commit_staged_path(&staged, index_path, CollisionPolicy::Overwrite)
}

fn canonical_archive(path: &Path) -> Result<PathBuf> {
    path.canonicalize()
        .map_err(|error| ArcthisError::io("canonicalizing archive for index", error))
}

fn fingerprint(path: &Path) -> Result<Fingerprint> {
    let metadata = fs::metadata(path)
        .map_err(|error| ArcthisError::io("reading indexed archive metadata", error))?;
    let duration = metadata
        .modified()
        .map_err(|error| ArcthisError::io("reading indexed archive modification time", error))?
        .duration_since(UNIX_EPOCH)
        .map_err(|_| ArcthisError::UnsupportedOperation {
            message: "archive modification time predates Unix epoch".to_owned(),
        })?;
    Ok(Fingerprint {
        size: metadata.len(),
        modified_seconds: duration.as_secs(),
        modified_nanos: duration.subsec_nanos(),
    })
}

fn cache_path(canonical: &Path, index_directory: Option<&Path>) -> Result<PathBuf> {
    let root = if let Some(directory) = index_directory {
        directory.to_path_buf()
    } else {
        ProjectDirs::from("dev", "arcthis", "arcthis")
            .ok_or_else(|| ArcthisError::UnsupportedOperation {
                message: "cannot determine the system cache directory".to_owned(),
            })?
            .cache_dir()
            .to_path_buf()
    };
    let mut hasher = Sha256::new();
    hasher.update(canonical.as_os_str().as_encoded_bytes());
    let digest = hasher.finalize();
    let name = digest
        .iter()
        .fold(String::with_capacity(64), |mut output, byte| {
            let _ = write!(output, "{byte:02x}");
            output
        });
    Ok(root.join("indexes").join(format!("{name}.json")))
}