prlens 0.1.0

One queue for all your PRs — aggregates GitHub and Bitbucket review requests into a single interactive view
Documentation
use serde::Deserialize;

#[derive(Debug, Deserialize, Default, PartialEq)]
pub struct Config {
    #[serde(default)]
    pub github: GithubConfig,
    #[serde(default)]
    pub bitbucket: BitbucketConfig,
    #[serde(default)]
    pub display: DisplayConfig,
    #[serde(default)]
    pub jira: JiraConfig,
    #[serde(default)]
    pub profiles: Vec<ProfileConfig>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct GithubConfig {
    #[serde(default = "default_true")]
    pub enabled: bool,
    #[serde(default)]
    pub watch_repos: Vec<String>,
    #[serde(default)]
    pub exclude_repos: Vec<String>,
}

impl Default for GithubConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            watch_repos: vec![],
            exclude_repos: vec![],
        }
    }
}

impl PartialEq for GithubConfig {
    fn eq(&self, other: &Self) -> bool {
        self.enabled == other.enabled
            && self.watch_repos == other.watch_repos
            && self.exclude_repos == other.exclude_repos
    }
}

#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct BitbucketConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub workspace: Option<String>,
    #[serde(default)]
    pub watch_repos: Vec<String>,
    // Phase 4 additions (D-04, D-12, D-05):
    #[serde(default)]
    pub token: Option<String>,      // BB_TOKEN fallback (D-04)
    #[serde(default)]
    pub base_url: Option<String>,   // self-hosted Data Center URL; defaults to Cloud when absent (D-12)
    #[serde(default)]
    pub username: Option<String>,   // required for Cloud Basic auth (Atlassian email or Bitbucket username) (D-05)
}

/// Configuration for Jira integration (JIRA-01).
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct JiraConfig {
    #[serde(default)]
    pub base_url: Option<String>,
}

/// One condition within a profile. A PR matches if ALL set fields match (AND within a group).
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct FilterGroup {
    #[serde(default)]
    pub provider: Option<String>,
    #[serde(default)]
    pub org: Option<String>,
    #[serde(default)]
    pub repo: Option<String>,
    #[serde(default)]
    pub status: Option<String>,
}

/// A named profile for filtering PRs (TUI-06).
/// A PR is shown if it matches ANY of the filter groups (OR between groups).
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct ProfileConfig {
    pub name: String,
    #[serde(default)]
    pub filters: Vec<FilterGroup>,
}

#[derive(Debug, Deserialize)]
pub struct DisplayConfig {
    #[serde(default = "default_date_format")]
    pub date_format: String,
}

impl Default for DisplayConfig {
    fn default() -> Self {
        Self {
            date_format: "relative".to_string(),
        }
    }
}

impl PartialEq for DisplayConfig {
    fn eq(&self, other: &Self) -> bool {
        self.date_format == other.date_format
    }
}

#[allow(dead_code)]
fn default_true() -> bool {
    true
}

#[allow(dead_code)]
fn default_date_format() -> String {
    "relative".to_string()
}

/// Load config from a specific path. Returns Config::default() if the file does not exist.
/// Uses std::fs (NOT tokio::fs) — config loading happens before the async runtime handles work.
pub(crate) fn load_config_from(path: std::path::PathBuf) -> anyhow::Result<Config> {
    let content = match std::fs::read_to_string(&path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            tracing::debug!("No config at {:?}, using defaults", path);
            return Ok(Config::default());
        }
        Err(e) => {
            return Err(anyhow::anyhow!("Cannot read config at {:?}: {}", path, e));
        }
    };
    let config: Config = toml::from_str(&content)
        .map_err(|e| anyhow::anyhow!("Config parse error in {:?}: {}", path, e))?;
    tracing::debug!("Loaded config from {:?}", path);
    Ok(config)
}

/// Load config from the XDG-aware platform config path (~/.config/prlens/config.toml on Linux).
/// Returns Config::default() when no file exists — guaranteed zero-panic first-run.
pub fn load_config() -> anyhow::Result<Config> {
    let path = dirs::config_dir()
        .ok_or_else(|| anyhow::anyhow!(
            "Cannot determine config directory — set XDG_CONFIG_HOME or HOME"
        ))?
        .join("prlens")
        .join("config.toml");
    load_config_from(path)
}

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

    #[test]
    fn jira_config_parse() {
        // Parse Config with [jira] section — base_url should deserialize into JiraConfig
        let cfg: Config = toml::from_str("[jira]\nbase_url = \"https://acme.atlassian.net\"\n")
            .unwrap();
        assert_eq!(cfg.jira.base_url, Some("https://acme.atlassian.net".to_string()));
    }

    #[test]
    fn jira_config_default() {
        assert_eq!(JiraConfig::default().base_url, None);
    }

    #[test]
    fn profile_config_parse() {
        let toml_str = "[[profiles]]\nname = \"work\"\n[[profiles.filters]]\norg = \"acme\"\n[[profiles]]\nname = \"oss\"\n";
        let cfg: Config = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.profiles.len(), 2);
        assert_eq!(cfg.profiles[0].name, "work");
        assert_eq!(cfg.profiles[0].filters[0].org, Some("acme".to_string()));
        assert_eq!(cfg.profiles[1].name, "oss");
        assert_eq!(cfg.profiles[1].filters.len(), 0);
    }

    #[test]
    fn config_default_when_missing() {
        // Use a path that is guaranteed not to exist
        let result = load_config_from(
            std::env::temp_dir().join("prlens-test-nonexistent-config.toml"),
        );
        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
        assert_eq!(result.unwrap(), Config::default());
    }

    #[test]
    fn config_parse_partial() {
        // Minimal config with only one section — all other fields should use defaults
        let cfg: Config = toml::from_str("[github]\nenabled = false\n").unwrap();
        assert!(!cfg.github.enabled);
        // Bitbucket and display fields should still be defaults
        assert!(!cfg.bitbucket.enabled);
        assert_eq!(cfg.display.date_format, "relative");
    }

    #[test]
    fn config_parse_bitbucket_new_fields() {
        let cfg: Config = toml::from_str(
            "[bitbucket]\nenabled = true\ntoken = \"mytoken\"\nbase_url = \"https://bb.example.com\"\nusername = \"user@example.com\"\n"
        ).unwrap();
        assert!(cfg.bitbucket.enabled);
        assert_eq!(cfg.bitbucket.token.as_deref(), Some("mytoken"));
        assert_eq!(cfg.bitbucket.base_url.as_deref(), Some("https://bb.example.com"));
        assert_eq!(cfg.bitbucket.username.as_deref(), Some("user@example.com"));
    }

    #[test]
    fn bitbucket_config_default_new_fields_are_none() {
        let cfg = BitbucketConfig::default();
        assert_eq!(cfg.token, None);
        assert_eq!(cfg.base_url, None);
        assert_eq!(cfg.username, None);
    }

    #[test]
    fn bitbucket_config_clone() {
        let cfg = BitbucketConfig {
            enabled: true,
            token: Some("tok".to_string()),
            base_url: Some("https://example.com".to_string()),
            username: Some("user@example.com".to_string()),
            workspace: None,
            watch_repos: vec![],
        };
        let cloned = cfg.clone();
        assert_eq!(cfg, cloned);
    }

    #[test]
    fn config_unknown_keys_ignored() {
        // toml 1.x behavior: unknown fields cause a parse error by default.
        // Document actual behavior here without asserting Ok or Err — binary must not panic.
        let result = toml::from_str::<Config>("[github]\nunknown_field = \"hi\"\n");
        // toml 1.x rejects unknown fields unless #[serde(deny_unknown_fields)] is NOT set.
        // With default serde behavior, this may succeed or fail depending on toml version.
        // The key invariant is: this call returns a Result and never panics.
        match result {
            Ok(_) => {
                // toml version allows unknown keys — pass
            }
            Err(_) => {
                // toml version rejects unknown keys — also acceptable, documented here.
                // The binary still won't panic because load_config_from() propagates Err via ?
            }
        }
    }
}