use std::path::{Path, PathBuf};
use crate::join::Names;
use locum_capture::blobs::Blobs;
use rusqlite::{Connection, OpenFlags, Params, Row};
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,
pub names: Names,
}
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()
}
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()
}
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 {
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()
}
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()))
}
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}"))
}
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)
}
pub fn has_table(&self, name: &str) -> bool {
self.count(
"SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[name],
) > 0
}
}