travelagent-core 1.11.1

Core library for travelagent code review tool
Documentation
//! GitHub-style auto-collapse for large / lockfile-like files.
//!
//! When a review is opened, some files are collapsed by default so the user
//! doesn't have to scroll past a 5000-line `Cargo.lock` to get to the real
//! code. A file is auto-collapsed when either:
//!
//! 1. Its path matches one of the built-in "boring" globs (lockfiles, minified
//!    assets, source maps), or a user-supplied glob from the
//!    `[auto_collapse].patterns` config key.
//! 2. Its `additions + deletions` count exceeds `[auto_collapse].line_threshold`
//!    (default 500).
//!
//! Explicit user overrides stored in [`crate::model::review::FileReview::collapsed`]
//! beat both rules — `Some(true)` stays collapsed even if the auto rule says
//! otherwise, and `Some(false)` stays expanded even on a 10k-line lockfile.
//!
//! The built-in pattern list is intentionally conservative: it only includes
//! paths that are almost always machine-generated. User-written files like
//! SVGs or JSON configs are NOT on the list; users can add them via
//! `patterns = ["**/my.json"]` if they want.

use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::glob::{any_glob_matches, path_str};

/// Built-in patterns that are always collapsed when `enabled = true`. The
/// user's `patterns` config field is *appended* to this list, not replaced.
pub const DEFAULT_PATTERNS: &[&str] = &[
    "**/Cargo.lock",
    "**/package-lock.json",
    "**/yarn.lock",
    "**/pnpm-lock.yaml",
    "**/poetry.lock",
    "**/Gemfile.lock",
    "**/go.sum",
    "**/uv.lock",
    "**/composer.lock",
    "**/*.min.js",
    "**/*.min.css",
    "**/*.map",
];

/// Default `line_threshold` — a file with more than this many additions +
/// deletions is collapsed on open.
pub const DEFAULT_LINE_THRESHOLD: usize = 500;

/// Auto-collapse configuration parsed from the `[auto_collapse]` TOML section.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default)]
pub struct AutoCollapseConfig {
    pub enabled: bool,
    pub line_threshold: usize,
    /// User-supplied patterns. These are *appended* to [`DEFAULT_PATTERNS`]
    /// — they do not replace the built-ins.
    pub patterns: Vec<String>,
}

impl Default for AutoCollapseConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            line_threshold: DEFAULT_LINE_THRESHOLD,
            patterns: Vec::new(),
        }
    }
}

/// Reason a file was auto-collapsed; used to phrase the placeholder text
/// shown in the diff panel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CollapseReason {
    /// Path matched a built-in or user-supplied glob.
    LockfileLike,
    /// `additions + deletions` exceeded `line_threshold`.
    OverThreshold,
}

impl CollapseReason {
    pub fn label(self) -> &'static str {
        match self {
            Self::LockfileLike => "lockfile-like",
            Self::OverThreshold => "over threshold",
        }
    }
}

/// Returns `true` when the file should be auto-collapsed under `cfg`.
///
/// Pattern match wins over the line threshold — a 3-line lockfile change still
/// collapses because the path looks boring.
pub fn should_auto_collapse(
    path: &Path,
    added_plus_deleted: usize,
    cfg: &AutoCollapseConfig,
) -> bool {
    auto_collapse_reason(path, added_plus_deleted, cfg).is_some()
}

/// Like [`should_auto_collapse`] but returns the triggering reason so the UI
/// can phrase the placeholder precisely.
pub fn auto_collapse_reason(
    path: &Path,
    added_plus_deleted: usize,
    cfg: &AutoCollapseConfig,
) -> Option<CollapseReason> {
    if !cfg.enabled {
        return None;
    }
    let p = path_str(path);
    let default_match = DEFAULT_PATTERNS
        .iter()
        .any(|g| crate::glob::glob_match(g, &p));
    if default_match || any_glob_matches(&cfg.patterns, path) {
        return Some(CollapseReason::LockfileLike);
    }
    if added_plus_deleted > cfg.line_threshold {
        return Some(CollapseReason::OverThreshold);
    }
    None
}

/// Resolve the effective collapse state given the user's explicit override
/// (if any) and the auto rule. `Some(true)`/`Some(false)` always win.
pub fn effective_collapsed(
    explicit: Option<bool>,
    path: &Path,
    added_plus_deleted: usize,
    cfg: &AutoCollapseConfig,
) -> bool {
    explicit.unwrap_or_else(|| should_auto_collapse(path, added_plus_deleted, cfg))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn cfg_default() -> AutoCollapseConfig {
        AutoCollapseConfig::default()
    }

    #[test]
    fn default_enabled_with_threshold_500_and_empty_user_patterns() {
        let cfg = cfg_default();
        assert!(cfg.enabled);
        assert_eq!(cfg.line_threshold, 500);
        assert!(cfg.patterns.is_empty());
    }

    #[test]
    fn cargo_lock_at_root_is_collapsed_by_default_pattern() {
        let cfg = cfg_default();
        assert!(should_auto_collapse(&PathBuf::from("Cargo.lock"), 10, &cfg));
        assert!(should_auto_collapse(
            &PathBuf::from("crates/foo/Cargo.lock"),
            10,
            &cfg
        ));
    }

    #[test]
    fn regular_source_file_is_not_collapsed() {
        let cfg = cfg_default();
        assert!(!should_auto_collapse(
            &PathBuf::from("src/foo.rs"),
            100,
            &cfg
        ));
    }

    #[test]
    fn file_over_threshold_is_collapsed() {
        let cfg = cfg_default();
        // 501 > 500 → collapse.
        assert!(should_auto_collapse(
            &PathBuf::from("src/foo.rs"),
            501,
            &cfg
        ));
    }

    #[test]
    fn file_at_exact_threshold_is_not_collapsed() {
        let cfg = cfg_default();
        // Rule is strictly greater-than, so exactly 500 stays expanded.
        assert!(!should_auto_collapse(
            &PathBuf::from("src/foo.rs"),
            500,
            &cfg
        ));
    }

    #[test]
    fn disabled_config_never_collapses() {
        let cfg = AutoCollapseConfig {
            enabled: false,
            ..Default::default()
        };
        assert!(!should_auto_collapse(
            &PathBuf::from("Cargo.lock"),
            10_000,
            &cfg
        ));
        assert!(!should_auto_collapse(
            &PathBuf::from("src/foo.rs"),
            10_000,
            &cfg
        ));
    }

    #[test]
    fn user_pattern_extends_defaults_without_replacing_them() {
        let cfg = AutoCollapseConfig {
            patterns: vec!["**/*.foo".into()],
            ..Default::default()
        };
        // Built-in default still matches.
        assert!(should_auto_collapse(&PathBuf::from("Cargo.lock"), 5, &cfg));
        // User-supplied glob also matches.
        assert!(should_auto_collapse(&PathBuf::from("a/b.foo"), 5, &cfg));
        // Unrelated file still doesn't match.
        assert!(!should_auto_collapse(&PathBuf::from("src/foo.rs"), 5, &cfg));
    }

    #[test]
    fn effective_respects_explicit_expand_over_auto() {
        let cfg = cfg_default();
        // Cargo.lock would auto-collapse; Some(false) forces expanded.
        assert!(!effective_collapsed(
            Some(false),
            &PathBuf::from("Cargo.lock"),
            10_000,
            &cfg,
        ));
    }

    #[test]
    fn effective_respects_explicit_collapse_on_non_auto_file() {
        let cfg = cfg_default();
        // src/foo.rs wouldn't auto-collapse; Some(true) forces collapsed.
        assert!(effective_collapsed(
            Some(true),
            &PathBuf::from("src/foo.rs"),
            1,
            &cfg,
        ));
    }

    #[test]
    fn effective_falls_back_to_auto_when_explicit_is_none() {
        let cfg = cfg_default();
        assert!(effective_collapsed(
            None,
            &PathBuf::from("Cargo.lock"),
            5,
            &cfg,
        ));
        assert!(!effective_collapsed(
            None,
            &PathBuf::from("src/foo.rs"),
            5,
            &cfg,
        ));
    }

    #[test]
    fn reason_distinguishes_pattern_vs_threshold() {
        let cfg = cfg_default();
        assert_eq!(
            auto_collapse_reason(&PathBuf::from("Cargo.lock"), 5, &cfg),
            Some(CollapseReason::LockfileLike)
        );
        assert_eq!(
            auto_collapse_reason(&PathBuf::from("src/foo.rs"), 501, &cfg),
            Some(CollapseReason::OverThreshold)
        );
        assert_eq!(
            auto_collapse_reason(&PathBuf::from("src/foo.rs"), 10, &cfg),
            None
        );
    }

    #[test]
    fn pattern_match_wins_over_threshold() {
        let cfg = cfg_default();
        // Even a tiny 2-line Cargo.lock change is collapsed as LockfileLike,
        // not OverThreshold.
        assert_eq!(
            auto_collapse_reason(&PathBuf::from("Cargo.lock"), 2, &cfg),
            Some(CollapseReason::LockfileLike)
        );
    }
}