travelagent-core 1.11.1

Core library for travelagent code review tool
Documentation
//! Per-repo reviewer-side sparring config, loaded from
//! `<repo_root>/.travelagent/review.toml`.
//!
//! # Layering relative to [`crate::config`]
//!
//! This is the **reviewer-local** half of the config layering; it's
//! expected to be user-local (typically gitignored) so it carries
//! settings one reviewer wouldn't want to push onto teammates via
//! a committed file.
//!
//! - **`config.toml`** (the [`crate::config`] module): rendering,
//!   keybinds, theme, forge-host mapping, auto-collapse rules,
//!   session-gc policy, mental-model bounds. Two tiers — the user's
//!   global `~/.config/travelagent/config.toml` plus per-repo
//!   overrides in `<repo_root>/.travelagent/config.toml` — both of
//!   which are candidates for committing. `[merge_overrides]` is how
//!   per-repo wins over global.
//! - **`review.toml`** (this module): reviewer-local flow control —
//!   what the reviewer can see, which files get blinded, and in the
//!   future any other "this one person's review experience" setting.
//!   Never merged with the global tier because there is no global
//!   tier for it; a single file, never committed.
//!
//! Rule of thumb for a new setting: if a teammate opening the repo
//! would want it too, it belongs in `config.toml`. If it's about
//! how *this* reviewer wants to run the review right now, put it
//! here.
//!
//! # Schema
//!
//! ```toml
//! # .travelagent/review.toml
//! hidden_from_reviewer = ["tests/**", "**/*_test.*"]
//! ```
//!
//! Paths use gitignore syntax (same matcher as `.trvignore`). The
//! filter is *toggleable* by the TUI at runtime — see the I3 phase:
//! `--blind-tests` enables it, `:unblind` disables it. The file can
//! also be reloaded mid-session via the `:reload-review-config`
//! command, so a reviewer can edit `review.toml` in another window
//! and pick up the new rules without restarting the TUI.
//!
//! # Loading semantics
//!
//! A missing file is the normal case (returns default outcome, no
//! warnings). A malformed file falls back to default with a
//! section-level warning, mirroring the `[auto_collapse]` /
//! `[mental_model]` pattern in the global config parser. Unknown keys
//! produce per-key warnings and are dropped.

use std::collections::HashSet;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};

use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};

/// Parsed `.travelagent/review.toml`. Kept separate from the global
/// `AppConfig` because the concerns are orthogonal (reviewer flow vs.
/// rendering / keybindings) and mixing them would force every call site
/// that only needs one to drag the other in.
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default)]
pub struct ReviewConfig {
    /// Paths to hide from the reviewer when the "blind" mode is active.
    /// Gitignore syntax (same matcher as `.trvignore`). Typical
    /// contents:
    ///
    /// ```toml
    /// hidden_from_reviewer = ["tests/**", "**/*_test.*", "snapshots/**"]
    /// ```
    ///
    /// Empty (or missing) means there's nothing to hide, so the
    /// "blind" mode becomes a no-op — this is intentional so agents
    /// can't accidentally blind themselves by turning on a feature
    /// that's not configured.
    pub hidden_from_reviewer: Vec<String>,
}

const KNOWN_REVIEW_KEYS: &[&str] = &["hidden_from_reviewer"];

/// Path helper: `<repo_root>/.travelagent/review.toml`. Sibling of
/// `repo_config_path` so both per-repo files live under the same
/// `.travelagent/` directory.
#[must_use]
pub fn review_config_path(repo_root: &Path) -> PathBuf {
    repo_root.join(".travelagent").join("review.toml")
}

/// Outcome of loading a per-repo `review.toml`. Empty on missing file;
/// carries warnings for malformed content or unknown keys so the caller
/// can surface them alongside the main config warnings at startup.
///
/// `sections_present` tracks the set of top-level keys actually written
/// in the TOML, mirroring the convention in
/// [`crate::config::RepoConfigLoadOutcome`]: once a key has been parsed
/// into a typed struct, fields default silently, so any future merge
/// step that needs to distinguish "absent" from "set to the default"
/// should consult `sections_present` via [`Self::has_section`] instead
/// of inspecting the parsed config.
#[derive(Debug, Clone, Default)]
pub struct ReviewConfigOutcome {
    pub config: ReviewConfig,
    pub warnings: Vec<String>,
    pub sections_present: HashSet<String>,
    pub path: PathBuf,
}

impl ReviewConfigOutcome {
    /// True if the raw TOML set `key` at the top level, even when the
    /// parsed value equals its default. Use this when a downstream
    /// layering step needs to tell "user omitted the key" (inherit
    /// upstream value) from "user wrote the key with default content"
    /// (explicit reset). Kept on the outcome so callers don't have to
    /// round-trip through `HashSet::contains` with ad-hoc strings.
    #[must_use]
    pub fn has_section(&self, key: &str) -> bool {
        self.sections_present.contains(key)
    }
}

/// Load `<repo_root>/.travelagent/review.toml`. Missing file is a
/// normal case and returns an empty outcome.
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
    })
}

/// Parse `review.toml` contents directly. Exposed so fuzzers and
/// in-memory tests don't have to write temp files.
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() {
        // `hidden_from_reviewer` expects an array of strings; pass an
        // integer instead to trigger the deserialize fallback.
        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() {
        // Empty file: no sections present — `has_section` answers "no".
        let absent = parse_review_config_str("").unwrap();
        assert!(!absent.has_section("hidden_from_reviewer"));

        // Explicit empty array: user wrote the key. `has_section` must
        // see it even though the parsed value equals the default.
        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());
    }
}