use crate::{
build_cache_dir, build_source_identity, open_index_storage, IndexArgs, IndexStorage, S3Source,
};
use anyhow::Result;
use seagrep_index::SegmentedReader;
use seagrep_s3::{build_index_namespace, list_prefix};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)]
struct RememberedIndex {
location: String,
index_region: Option<String>,
index_endpoint: Option<String>,
}
fn map_path() -> Result<PathBuf> {
let mut path = seagrep_core::cache_home()?;
path.push("seagrep");
path.push("index-locations.json");
Ok(path)
}
fn read_map() -> BTreeMap<String, RememberedIndex> {
let Ok(path) = map_path() else {
return BTreeMap::new();
};
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return BTreeMap::new(),
Err(error) => {
eprintln!(
"note: cannot read remembered-index cache {}: {error}",
path.display()
);
return BTreeMap::new();
}
};
match serde_json::from_slice(&bytes) {
Ok(map) => map,
Err(error) => {
eprintln!(
"note: ignoring corrupt remembered-index cache {}: {error}",
path.display()
);
BTreeMap::new()
}
}
}
fn source_key(source: &S3Source, prefix: &str) -> String {
format!(
"{}\u{0}{}\u{0}{}",
source.endpoint,
source.bucket,
list_prefix(prefix.trim_matches('/'))
)
}
pub(crate) fn remember_index(source: &S3Source, index: &IndexArgs) {
let Some(location) = index.location.clone() else {
return;
};
let entry = RememberedIndex {
location,
index_region: index.index_region.clone(),
index_endpoint: index.index_endpoint.clone(),
};
let mut map = read_map();
let key = source_key(source, &source.prefix);
if map.get(&key) == Some(&entry) {
return;
}
map.insert(key, entry);
let Ok(path) = map_path() else {
return;
};
let Some(dir) = path.parent() else {
return;
};
if std::fs::create_dir_all(dir).is_err() {
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
let Ok(bytes) = serde_json::to_vec_pretty(&map) else {
return;
};
let Ok(mut staged) = tempfile::Builder::new()
.prefix(".index-locations-")
.tempfile_in(dir)
else {
return;
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = staged
.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600));
}
if std::io::Write::write_all(staged.as_file_mut(), &bytes).is_err() {
return;
}
let _ = staged.persist(&path);
}
fn parent_chain(prefix: &str) -> Vec<String> {
let mut chain = Vec::new();
let mut current = prefix.trim_matches('/');
while let Some((parent, _)) = current.rsplit_once('/') {
chain.push(parent.to_owned());
current = parent;
}
if !prefix.trim_matches('/').is_empty() {
chain.push(String::new());
}
chain
}
fn storage_at(source: &S3Source, prefix: &str) -> Result<IndexStorage> {
let root = build_index_namespace(prefix).trim_matches('/').to_owned();
let endpoint = source.client.endpoint_identity();
let cache = build_cache_dir(Some(&endpoint), &source.bucket, &root)?;
Ok(IndexStorage {
client: source.client.clone(),
endpoint,
bucket: source.bucket.clone(),
root,
cache,
})
}
pub(crate) fn discover_fallback(
source: &S3Source,
concurrency: usize,
) -> Result<Option<IndexStorage>> {
let identity = build_source_identity(source);
for candidate in parent_chain(&source.prefix) {
let storage = storage_at(source, &candidate)?;
let present = match storage.store().get_versioned("segments.bin") {
Ok(present) => present.is_some(),
Err(error) => {
eprintln!(
"note: cannot probe index at {}: {error:#}",
storage.location()
);
false
}
};
if !present {
continue;
}
match SegmentedReader::open(storage.store(), storage.cache(), &identity) {
Ok(_) => {
eprintln!(
"note: using index at {} (discovered at a parent prefix; pass --index to override)",
storage.location()
);
return Ok(Some(storage));
}
Err(error) => {
eprintln!("note: skipping index at {}: {error:#}", storage.location());
}
}
}
let map = read_map();
for prefix in std::iter::once(source.prefix.clone()).chain(parent_chain(&source.prefix)) {
let Some(entry) = map.get(&source_key(source, &prefix)).cloned() else {
continue;
};
let args = IndexArgs {
location: Some(entry.location),
index_region: entry.index_region,
index_endpoint: entry.index_endpoint,
};
match open_index_storage(source, &args, concurrency) {
Ok(storage) => {
match SegmentedReader::open(storage.store(), storage.cache(), &identity) {
Ok(_) => {
eprintln!(
"note: using remembered index {} (recorded from an earlier --index run)",
storage.location()
);
return Ok(Some(storage));
}
Err(error) => eprintln!(
"note: ignoring remembered index {}: {error:#}",
storage.location()
),
}
}
Err(error) => eprintln!("note: ignoring remembered index: {error:#}"),
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_map_tolerates_missing_and_corrupt_files() {
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("XDG_CACHE_HOME", dir.path()) };
assert!(read_map().is_empty(), "missing file reads as empty");
let path = map_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"{not json").unwrap();
assert!(read_map().is_empty(), "corrupt file reads as empty");
std::fs::write(&path, b"[1, 2, 3]").unwrap();
assert!(read_map().is_empty(), "wrong shape reads as empty");
unsafe { std::env::remove_var("XDG_CACHE_HOME") };
}
#[test]
fn parent_chain_walks_to_root() {
assert_eq!(parent_chain("raw/rcaeval"), vec!["raw", ""]);
assert_eq!(parent_chain("a/b/c"), vec!["a/b", "a", ""]);
assert_eq!(parent_chain("logs"), vec![""]);
assert!(parent_chain("").is_empty());
assert_eq!(parent_chain("/a/b/"), vec!["a", ""]);
}
}