qqq-cli 0.7.0

Local-first task queue for coding agent sessions
use anyhow::{Result, ensure};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{collections::HashSet, io::Read};

pub const MANIFEST_NAME: &str = "manifest.json";
pub const DATABASE_NAME: &str = "qqq.db";
pub const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FileMeta {
    pub bytes: u64,
    pub sha256: String,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ImageMeta {
    pub path: String,
    pub bytes: u64,
    pub sha256: String,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
    pub version: u32,
    pub database: FileMeta,
    pub images: Vec<ImageMeta>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub upgrade: Option<Upgrade>,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Upgrade {
    pub project: String,
    pub source_schema: i64,
    pub target_schema: i64,
    pub created_at: String,
}

impl Manifest {
    pub fn validate(&self) -> Result<()> {
        match (self.version, &self.upgrade) {
            (1, None) => (),
            (2, Some(upgrade)) => {
                ensure!(
                    upgrade.source_schema > 0 && upgrade.target_schema > upgrade.source_schema,
                    "Invalid upgrade snapshot schema metadata"
                );
                ensure!(
                    std::path::Path::new(&upgrade.project).is_absolute()
                        && !upgrade.created_at.is_empty(),
                    "Invalid upgrade snapshot project metadata"
                );
            }
            _ => anyhow::bail!("Unsupported snapshot format version"),
        }
        ensure!(self.database.bytes > 0, "Snapshot database is empty");
        validate_hash(&self.database.sha256)?;
        let mut seen = HashSet::new();
        for image in &self.images {
            validate_image_path(&image.path)?;
            validate_hash(&image.sha256)?;
            ensure!(seen.insert(&image.path), "Duplicate image path in manifest");
        }
        Ok(())
    }
}

fn validate_hash(value: &str) -> Result<()> {
    ensure!(
        value.len() == 64
            && value
                .bytes()
                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),
        "Invalid SHA-256 in snapshot manifest"
    );
    Ok(())
}

pub fn validate_image_path(path: &str) -> Result<(i64, i64, &str)> {
    let parts: Vec<_> = path.split('/').collect();
    ensure!(
        parts.len() == 3 && parts[0] == "images",
        "Invalid image path"
    );
    let task = canonical_id(parts[1])?;
    let (id, extension) = parts[2]
        .split_once('.')
        .ok_or_else(|| anyhow::anyhow!("Invalid image path"))?;
    let image = canonical_id(id)?;
    ensure!(
        matches!(extension, "png" | "jpg" | "gif" | "webp"),
        "Invalid image extension"
    );
    Ok((task, image, extension))
}

fn canonical_id(value: &str) -> Result<i64> {
    let id: i64 = value.parse()?;
    ensure!(id > 0 && id.to_string() == value, "Invalid image identity");
    Ok(id)
}

pub fn hash_reader(mut reader: impl Read) -> Result<FileMeta> {
    let mut hash = Sha256::new();
    let mut bytes = 0_u64;
    let mut chunk = [0_u8; 64 * 1024];
    loop {
        let count = reader.read(&mut chunk)?;
        if count == 0 {
            break;
        }
        bytes = bytes
            .checked_add(count as u64)
            .ok_or_else(|| anyhow::anyhow!("Snapshot size overflow"))?;
        hash.update(&chunk[..count]);
    }
    Ok(FileMeta {
        bytes,
        sha256: format!("{:x}", hash.finalize()),
    })
}