syntheca 0.1.0

Content-addressable storage on top of apotheca. Bytes go in, BLAKE3 hash comes out.
Documentation
//! `syn` — CLI surface for syntheca (SPEC §5).

use clap::{Parser, Subcommand};
use std::ffi::OsString;
use std::io::{self, Read, Write};
use std::os::unix::ffi::OsStrExt;
use std::path::PathBuf;
use std::process::ExitCode;

use syntheca::{GetError, Hash, Pool, PutError, StatError};

#[derive(Parser)]
#[command(name = "syn", version, about = "syntheca content-addressable store")]
struct Cli {
    /// Pool root directory. Defaults to $HOME/.syntheca/.
    #[arg(long, global = true)]
    pool: Option<PathBuf>,

    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Store bytes by content hash. Prints the hash to stdout.
    Put {
        /// Path to read bytes from. Use "-" to read from standard input.
        path: OsString,
    },
    /// Read bytes for a hash to standard output.
    Get { hash: String },
    /// Print metadata (size, sha256) for a hash.
    Stat { hash: String },
}

fn main() -> ExitCode {
    let cli = Cli::parse();
    let pool_root = cli.pool.unwrap_or_else(default_pool_root);

    let pool = match Pool::open(&pool_root) {
        Ok(p) => p,
        Err(e) => {
            let _ = writeln!(io::stderr(), "syn: open pool {}: {e}", pool_root.display());
            return ExitCode::from(1);
        }
    };

    match cli.cmd {
        Cmd::Put { path } => cmd_put(&pool, path),
        Cmd::Get { hash } => cmd_get(&pool, hash),
        Cmd::Stat { hash } => cmd_stat(&pool, hash),
    }
}

fn cmd_put(pool: &Pool, path: OsString) -> ExitCode {
    let bytes = if path.as_bytes() == b"-" {
        let mut v = Vec::new();
        if let Err(e) = io::stdin().lock().read_to_end(&mut v) {
            let _ = writeln!(io::stderr(), "syn: read stdin: {e}");
            return ExitCode::from(1);
        }
        v
    } else {
        match std::fs::read(&path) {
            Ok(v) => v,
            Err(e) => {
                let _ = writeln!(io::stderr(), "syn: read {:?}: {e}", path);
                return ExitCode::from(1);
            }
        }
    };

    match pool.put(&bytes) {
        Ok(hash) => {
            let mut out = io::stdout().lock();
            if writeln!(out, "{hash}").is_err() {
                return ExitCode::from(1);
            }
            ExitCode::from(0)
        }
        Err(PutError::HashCollision) => {
            let _ = writeln!(
                io::stderr(),
                "syn: hash collision: distinct bytes hashed to an existing entry's name"
            );
            ExitCode::from(1)
        }
        Err(e) => {
            let _ = writeln!(io::stderr(), "syn: put: {e}");
            ExitCode::from(1)
        }
    }
}

fn cmd_get(pool: &Pool, hash_str: String) -> ExitCode {
    let hash = match Hash::from_hex(&hash_str) {
        Ok(h) => h,
        Err(e) => {
            let _ = writeln!(io::stderr(), "syn: invalid hash: {e}");
            return ExitCode::from(1);
        }
    };

    match pool.get(&hash) {
        Ok(bytes) => {
            let mut out = io::stdout().lock();
            if let Err(e) = out.write_all(&bytes) {
                let _ = writeln!(io::stderr(), "syn: write stdout: {e}");
                return ExitCode::from(1);
            }
            ExitCode::from(0)
        }
        Err(GetError::NotFound) => {
            let _ = writeln!(io::stderr(), "syn: not found");
            ExitCode::from(1)
        }
        Err(GetError::IntegrityError) => {
            let _ = writeln!(io::stderr(), "syn: integrity error: stored bytes do not match expected hash");
            ExitCode::from(1)
        }
        Err(e) => {
            let _ = writeln!(io::stderr(), "syn: get: {e}");
            ExitCode::from(1)
        }
    }
}

fn cmd_stat(pool: &Pool, hash_str: String) -> ExitCode {
    let hash = match Hash::from_hex(&hash_str) {
        Ok(h) => h,
        Err(e) => {
            let _ = writeln!(io::stderr(), "syn: invalid hash: {e}");
            return ExitCode::from(1);
        }
    };

    match pool.stat(&hash) {
        Ok(stat) => {
            let mut out = io::stdout().lock();
            if writeln!(out, "size   {}", stat.size).is_err()
                || writeln!(out, "sha256 {}", hex::encode(stat.sha256)).is_err()
            {
                return ExitCode::from(1);
            }
            ExitCode::from(0)
        }
        Err(StatError::NotFound) => {
            let _ = writeln!(io::stderr(), "syn: not found");
            ExitCode::from(1)
        }
        Err(e) => {
            let _ = writeln!(io::stderr(), "syn: stat: {e}");
            ExitCode::from(1)
        }
    }
}

fn default_pool_root() -> PathBuf {
    let home = std::env::var_os("HOME").unwrap_or_else(|| OsString::from("."));
    let mut p = PathBuf::from(home);
    p.push(".syntheca");
    p
}