use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use rpm_spec_analyzer::config::Config;
pub struct ConfigCache {
explicit: Option<PathBuf>,
explicit_cache: Option<Arc<Config>>,
explicit_base_dir: Option<PathBuf>,
by_dir: HashMap<PathBuf, (Arc<Config>, PathBuf)>,
discover_cache: HashMap<PathBuf, Option<PathBuf>>,
default: Arc<Config>,
default_base_dir: Option<PathBuf>,
}
impl ConfigCache {
pub fn new(explicit: Option<PathBuf>) -> Self {
Self {
explicit,
explicit_cache: None,
explicit_base_dir: None,
by_dir: HashMap::new(),
discover_cache: HashMap::new(),
default: Arc::new(Config::default()),
default_base_dir: None,
}
}
pub fn load_or_report(
&mut self,
source_path: &Path,
any_io_error: &mut bool,
) -> Option<Arc<Config>> {
match self.load_for(source_path) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("error: {e:#}");
*any_io_error = true;
None
}
}
}
pub fn load_with_base_dir_or_report(
&mut self,
source_path: &Path,
any_io_error: &mut bool,
) -> Option<(Arc<Config>, PathBuf)> {
match self.load_for_with_base_dir(source_path) {
Ok(pair) => Some(pair),
Err(e) => {
eprintln!("error: {e:#}");
*any_io_error = true;
None
}
}
}
pub fn load_for(&mut self, source_path: &Path) -> Result<Arc<Config>> {
self.load_for_with_base_dir(source_path).map(|(c, _)| c)
}
pub fn load_for_with_base_dir(&mut self, source_path: &Path) -> Result<(Arc<Config>, PathBuf)> {
if let Some(path) = self.explicit.clone() {
if let (Some(cached), Some(base)) = (&self.explicit_cache, &self.explicit_base_dir) {
return Ok((Arc::clone(cached), base.clone()));
}
let cfg = Arc::new(load_from(&path)?);
let base = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
self.explicit_cache = Some(Arc::clone(&cfg));
self.explicit_base_dir = Some(base.clone());
return Ok((cfg, base));
}
let start_dir = canonicalize_or_keep(&start_dir_for(source_path));
if let Some((cfg, base)) = self.by_dir.get(&start_dir) {
return Ok((Arc::clone(cfg), base.clone()));
}
let found = self.discover_with_memo(&start_dir);
let (cfg, base) = match found {
Some(ref path) => {
let cfg = Arc::new(load_from(path)?);
let base = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| start_dir.clone());
(cfg, base)
}
None => {
let base = self
.default_base_dir
.get_or_insert_with(|| start_dir.clone())
.clone();
(Arc::clone(&self.default), base)
}
};
self.by_dir
.insert(start_dir, (Arc::clone(&cfg), base.clone()));
Ok((cfg, base))
}
fn discover_with_memo(&mut self, start: &Path) -> Option<PathBuf> {
let mut visited: Vec<PathBuf> = Vec::new();
let mut dir = start.to_path_buf();
let answer = loop {
if let Some(cached) = self.discover_cache.get(&dir) {
break cached.clone();
}
visited.push(dir.clone());
let candidate = dir.join(".rpmspec.toml");
if candidate.is_file() {
break Some(candidate);
}
if !dir.pop() {
break None;
}
};
for v in visited {
self.discover_cache.insert(v, answer.clone());
}
answer
}
}
fn canonicalize_or_keep(p: &Path) -> PathBuf {
match fs::canonicalize(p) {
Ok(c) => c,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
tracing::debug!(
path = %p.display(),
err = %e,
"canonicalize failed (not found); using path as-is"
);
} else {
tracing::warn!(
path = %p.display(),
err = %e,
"canonicalize failed; cache key may be inconsistent across formats"
);
}
p.to_path_buf()
}
}
}
fn start_dir_for(source_path: &Path) -> PathBuf {
if source_path.as_os_str() == "-" {
return PathBuf::from(".");
}
match fs::metadata(source_path) {
Ok(meta) if meta.is_file() => source_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from(".")),
Ok(_) => source_path.to_path_buf(),
Err(e) => {
tracing::debug!(
path = %source_path.display(),
err = %e,
"could not stat source; using parent directory for config discovery"
);
source_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
}
}
}
fn load_from(path: &Path) -> Result<Config> {
let text = fs::read_to_string(path)
.with_context(|| format!("failed to read config {}", path.display()))?;
Config::from_toml_str(&text)
.with_context(|| format!("failed to parse config {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_config_is_loaded_once() {
let tmp = tempfile::tempdir().unwrap();
let cfg_path = tmp.path().join("explicit.toml");
fs::write(
&cfg_path,
r#"[lints]
missing-changelog = "deny"
"#,
)
.unwrap();
let mut cache = ConfigCache::new(Some(cfg_path.clone()));
let a = cache.load_for(Path::new("anything.spec")).unwrap();
let b = cache.load_for(Path::new("other.spec")).unwrap();
assert!(Arc::ptr_eq(&a, &b), "explicit config should be reused");
}
#[test]
fn discovery_hits_cache_for_same_directory() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join(".rpmspec.toml"),
r#"[format]
preamble-align-column = 12
"#,
)
.unwrap();
let s1 = tmp.path().join("a.spec");
let s2 = tmp.path().join("b.spec");
fs::write(&s1, "").unwrap();
fs::write(&s2, "").unwrap();
let mut cache = ConfigCache::new(None);
let c1 = cache.load_for(&s1).unwrap();
let c2 = cache.load_for(&s2).unwrap();
assert!(Arc::ptr_eq(&c1, &c2), "sibling files should share cache");
assert_eq!(c1.format.preamble_align_column, 12);
}
#[test]
fn missing_config_returns_default() {
let tmp = tempfile::tempdir().unwrap();
let s = tmp.path().join("a.spec");
fs::write(&s, "").unwrap();
let mut cache = ConfigCache::new(None);
let cfg = cache.load_for(&s).unwrap();
assert_eq!(
cfg.format.preamble_align_column,
rpm_spec_analyzer::config::FormatConfig::default().preamble_align_column
);
}
}