use std::collections::HashSet;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default)]
pub struct ReviewConfig {
pub hidden_from_reviewer: Vec<String>,
}
const KNOWN_REVIEW_KEYS: &[&str] = &["hidden_from_reviewer"];
#[must_use]
pub fn review_config_path(repo_root: &Path) -> PathBuf {
repo_root.join(".travelagent").join("review.toml")
}
#[derive(Debug, Clone, Default)]
pub struct ReviewConfigOutcome {
pub config: ReviewConfig,
pub warnings: Vec<String>,
pub sections_present: HashSet<String>,
pub path: PathBuf,
}
impl ReviewConfigOutcome {
#[must_use]
pub fn has_section(&self, key: &str) -> bool {
self.sections_present.contains(key)
}
}
pub fn load_review_config(repo_root: &Path) -> Result<ReviewConfigOutcome> {
load_review_config_from_path(&review_config_path(repo_root))
}
fn load_review_config_from_path(path: &Path) -> Result<ReviewConfigOutcome> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == ErrorKind::NotFound => {
return Ok(ReviewConfigOutcome {
path: path.to_path_buf(),
..Default::default()
});
}
Err(err) => return Err(err.into()),
};
parse_review_config_str(&contents).map(|mut outcome| {
outcome.path = path.to_path_buf();
outcome
})
}
pub fn parse_review_config_str(contents: &str) -> Result<ReviewConfigOutcome> {
let value: toml::Value = toml::from_str(contents)?;
let table = value
.as_table()
.ok_or_else(|| anyhow!("review.toml root must be a TOML table"))?;
let sections_present: HashSet<String> = table.keys().map(String::clone).collect();
let mut warnings = Vec::new();
for key in table.keys() {
if !KNOWN_REVIEW_KEYS.contains(&key.as_str()) {
warnings.push(format!(
"[repo] Warning: Unknown review.toml key '{key}', ignoring"
));
}
}
let config = match toml::from_str::<ReviewConfig>(contents) {
Ok(cfg) => cfg,
Err(e) => {
warnings.push(format!(
"[repo] Warning: review.toml could not be parsed ({e}); using defaults"
));
ReviewConfig::default()
}
};
Ok(ReviewConfigOutcome {
config,
warnings,
sections_present,
path: PathBuf::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn missing_file_returns_default_outcome() {
let dir = tempdir().unwrap();
let outcome = load_review_config(dir.path()).unwrap();
assert_eq!(outcome.config.hidden_from_reviewer, Vec::<String>::new());
assert!(outcome.warnings.is_empty());
assert!(outcome.sections_present.is_empty());
}
#[test]
fn valid_file_parses_hidden_from_reviewer() {
let dir = tempdir().unwrap();
let subdir = dir.path().join(".travelagent");
fs::create_dir_all(&subdir).unwrap();
fs::write(
subdir.join("review.toml"),
r#"hidden_from_reviewer = ["tests/**", "**/*_test.*"]"#,
)
.unwrap();
let outcome = load_review_config(dir.path()).unwrap();
assert_eq!(
outcome.config.hidden_from_reviewer,
vec!["tests/**".to_string(), "**/*_test.*".to_string()]
);
assert!(outcome.warnings.is_empty());
assert!(outcome.sections_present.contains("hidden_from_reviewer"));
}
#[test]
fn unknown_key_produces_warning_but_preserves_known_keys() {
let outcome = parse_review_config_str(
r#"
hidden_from_reviewer = ["tests/**"]
banana = 42
"#,
)
.unwrap();
assert_eq!(
outcome.config.hidden_from_reviewer,
vec!["tests/**".to_string()]
);
assert_eq!(outcome.warnings.len(), 1);
assert!(outcome.warnings[0].contains("banana"));
}
#[test]
fn malformed_content_falls_back_to_default_with_warning() {
let outcome = parse_review_config_str("hidden_from_reviewer = 42").unwrap();
assert!(outcome.config.hidden_from_reviewer.is_empty());
assert!(
outcome
.warnings
.iter()
.any(|w| w.contains("could not be parsed")),
"{:?}",
outcome.warnings
);
}
#[test]
fn has_section_distinguishes_absent_from_default() {
let absent = parse_review_config_str("").unwrap();
assert!(!absent.has_section("hidden_from_reviewer"));
let present = parse_review_config_str("hidden_from_reviewer = []").unwrap();
assert!(present.has_section("hidden_from_reviewer"));
assert_eq!(
present.config.hidden_from_reviewer,
Vec::<String>::new(),
"parsed value still matches default — the distinction lives \
in `sections_present`, not the config struct"
);
}
#[test]
fn empty_file_is_valid_and_yields_default() {
let outcome = parse_review_config_str("").unwrap();
assert!(outcome.config.hidden_from_reviewer.is_empty());
assert!(outcome.warnings.is_empty());
}
}