locum-cli 0.1.2

Reads locum's captures: what a frame drew and with what, what the capture is missing, and the textures, buffers and targets in it.
//! Finding and opening a capture, and the blob store its keys refer to.

use std::path::{Path, PathBuf};

use crate::join::Names;
use locum_capture::blobs::Blobs;
use rusqlite::{Connection, OpenFlags, Params, Row};

/// locum's folder: `LOCUM_DIR`, or `%LOCALAPPDATA%\locum`.
pub fn locum_dir() -> PathBuf {
    if let Some(d) = std::env::var_os("LOCUM_DIR").filter(|d| !d.is_empty()) {
        return PathBuf::from(d);
    }
    std::env::var_os("LOCALAPPDATA")
        .map(|d| PathBuf::from(d).join("locum"))
        .unwrap_or_else(|| PathBuf::from("."))
}

pub struct Capture {
    pub db: Connection,
    pub path: PathBuf,
    pub blobs: Blobs,
    /// What its resources were made from, by the indexes given.
    pub names: Names,
}

/// The captures under `dir`, newest first, in any of the layouts locum and the run scripts leave: `dir` is locum's
/// folder or a run's `locum-log` (`captures/<name>/capture.db`), a `captures` folder (`<name>/capture.db`), a run
/// (`locum-log/captures/<name>/capture.db`), or a folder of runs (`<run>/locum-log/captures/<name>/capture.db`).
pub fn list(dir: &Path) -> Vec<PathBuf> {
    let subdirs = |d: &Path| -> Vec<PathBuf> {
        std::fs::read_dir(d)
            .into_iter()
            .flatten()
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.is_dir())
            .collect()
    };
    let mut folders = vec![
        dir.join("captures"),
        dir.to_path_buf(),
        dir.join("locum-log").join("captures"),
    ];
    folders.extend(
        subdirs(dir)
            .into_iter()
            .map(|run| run.join("locum-log").join("captures")),
    );
    let mut found: Vec<(std::time::SystemTime, PathBuf)> = folders
        .iter()
        .flat_map(|f| subdirs(f))
        .map(|p| p.join("capture.db"))
        .filter(|p| p.is_file())
        .map(|p| {
            (
                p.metadata()
                    .and_then(|m| m.modified())
                    .unwrap_or(std::time::UNIX_EPOCH),
                p,
            )
        })
        .collect();
    found.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
    found.dedup_by(|a, b| a.1 == b.1);
    found.into_iter().map(|(_, p)| p).collect()
}

/// `paths` as lines for an error: at most ten, then how many more.
fn listed(paths: &[PathBuf]) -> String {
    let mut lines: Vec<String> = paths
        .iter()
        .take(10)
        .map(|p| format!("\n  {}", p.display()))
        .collect();
    if paths.len() > 10 {
        lines.push(format!(
            "\n  ... {} more (`locum-cli captures <folder>` lists them)",
            paths.len() - 10
        ));
    }
    lines.concat()
}

/// The capture named by `arg`: a `capture.db`, a folder holding one, a folder with one capture under it in the
/// layouts [`list`] knows (a run folder), `<folder>/<name>` for the capture `name` under such a folder, a capture's
/// name under locum's folder, or `last` for the newest there. Blobs come from `blobs`, else `LOCUM_BLOBS`, else the
/// store the capture names, else `blobs` beside the `captures` folder.
pub fn open(arg: &str, blobs: Option<&Path>) -> Result<Capture, String> {
    let given = PathBuf::from(arg);
    let path = if given.is_file() {
        given
    } else if given.join("capture.db").is_file() {
        given.join("capture.db")
    } else if given.is_dir() && !list(&given).is_empty() {
        let found = list(&given);
        if found.len() > 1 {
            return Err(format!(
                "{arg} holds {} captures; name one:{}",
                found.len(),
                listed(&found)
            ));
        }
        found.into_iter().next().unwrap_or_default()
    } else if let Some(found) = given
        .parent()
        .filter(|p| !p.as_os_str().is_empty() && p.is_dir())
        .zip(given.file_name())
        .map(|(dir, name)| {
            list(dir)
                .into_iter()
                .filter(|p| p.parent().and_then(Path::file_name) == Some(name))
                .collect::<Vec<_>>()
        })
        .filter(|found| !found.is_empty())
    {
        if found.len() > 1 {
            return Err(format!(
                "{arg} names {} captures; give one's path:{}",
                found.len(),
                listed(&found)
            ));
        }
        found.into_iter().next().unwrap_or_default()
    } else if arg == "last" {
        list(&locum_dir())
            .into_iter()
            .next()
            .ok_or_else(|| format!("no captures in {}", locum_dir().join("captures").display()))?
    } else {
        let named = locum_dir().join("captures").join(arg).join("capture.db");
        if given.join("draws.jsonl").is_file() {
            return Err(format!(
                "{arg} holds a capture in locum's first format (draws.jsonl, from before capture.db), which this \
                 CLI does not read; capture the frame again"
            ));
        }
        if !named.is_file() {
            return Err(format!(
                "{arg} is not a capture.db, a folder with one or with captures under it (a run folder), or a \
                 capture in {}",
                locum_dir().join("captures").display()
            ));
        }
        named
    };
    let db = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
        .map_err(|e| format!("{}: {e}", path.display()))?;
    let api: String = db
        .query_row("SELECT value FROM meta WHERE key='api'", [], |r| r.get(0))
        .map_err(|e| format!("capture API: {e}"))?;
    let version: String = db
        .query_row("SELECT value FROM meta WHERE key='schema_version'", [], |r| {
            r.get(0)
        })
        .map_err(|e| format!("capture schema: {e}"))?;
    use locum_capture::db::{D3D12_SCHEMA_OLDEST, D3D12_SCHEMA_VERSION, SCHEMA_VERSION};
    let readable = match api.as_str() {
        "d3d11" => Some(SCHEMA_VERSION..=SCHEMA_VERSION),
        "d3d12" => Some(D3D12_SCHEMA_OLDEST..=D3D12_SCHEMA_VERSION),
        _ => None,
    };
    if !readable.is_some_and(|r| version.parse().is_ok_and(|v| r.contains(&v))) {
        return Err(format!(
            "capture API {api:?}, schema {version:?} is not readable by this locum-cli"
        ));
    }
    let named: Option<String> = db
        .query_row("SELECT value FROM meta WHERE key = 'blobs'", [], |r| r.get(0))
        .ok();
    let root = blobs
        .map(Path::to_path_buf)
        .or_else(|| {
            std::env::var_os("LOCUM_BLOBS")
                .filter(|d| !d.is_empty())
                .map(PathBuf::from)
        })
        .or_else(|| named.map(PathBuf::from).filter(|p| p.is_dir()))
        .or_else(|| {
            path.parent()
                .and_then(Path::parent)
                .and_then(Path::parent)
                .map(|d| d.join("blobs"))
        })
        .unwrap_or_else(|| PathBuf::from("blobs"));
    Ok(Capture {
        db,
        path,
        blobs: Blobs::new(root),
        names: Names::default(),
    })
}

impl Capture {
    /// The capture, naming its resources from the indexes at `paths`.
    pub fn named(mut self, paths: &[PathBuf]) -> Result<Capture, String> {
        self.names = Names::open(paths)?;
        Ok(self)
    }

    pub fn meta(&self, key: &str) -> Option<String> {
        self.db
            .query_row("SELECT value FROM meta WHERE key = ?1", [key], |r| r.get(0))
            .ok()
    }

    /// The meta value `key` as JSON (`counts`, `request`).
    pub fn meta_json(&self, key: &str) -> serde_json::Value {
        self.meta(key)
            .and_then(|v| serde_json::from_str(&v).ok())
            .unwrap_or_default()
    }

    pub fn blob(&self, key: &str) -> Result<Vec<u8>, String> {
        self.blobs
            .get(key)
            .map_err(|e| format!("blob {key} is not in {} ({e})", self.blobs.root().display()))
    }

    /// Every row of `sql`, each through `f`.
    pub fn rows<T, P: Params>(
        &self,
        sql: &str,
        params: P,
        f: impl FnMut(&Row) -> rusqlite::Result<T>,
    ) -> Vec<T> {
        let mut q = match self.db.prepare(sql) {
            Ok(q) => q,
            Err(e) => panic!("{sql}: {e}"),
        };
        q.query_map(params, f)
            .and_then(Iterator::collect)
            .unwrap_or_else(|e| panic!("{sql}: {e}"))
    }

    /// The one row of `sql`, if there is one.
    pub fn row<T, P: Params>(
        &self,
        sql: &str,
        params: P,
        f: impl FnOnce(&Row) -> rusqlite::Result<T>,
    ) -> Option<T> {
        match self.db.query_row(sql, params, f) {
            Ok(v) => Some(v),
            Err(rusqlite::Error::QueryReturnedNoRows) => None,
            Err(e) => panic!("{sql}: {e}"),
        }
    }

    pub fn count<P: Params>(&self, sql: &str, params: P) -> i64 {
        self.row(sql, params, |r| r.get(0)).unwrap_or(0)
    }

    /// Whether the capture has table `name`: captures from before a table was added lack it.
    pub fn has_table(&self, name: &str) -> bool {
        self.count(
            "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
            [name],
        ) > 0
    }
}