use serde::Deserialize; use std::fs; use std::path::Path; use super::error::{LoreError, Result}; use super::paths::get_config_path; #[derive(Debug, Clone, Deserialize)] pub struct GitLabConfig { #[serde(rename = "baseUrl")] pub base_url: String, #[serde(rename = "tokenEnvVar", default = "default_token_env_var")] pub token_env_var: String, /// Optional stored token (env var takes priority when both are set). #[serde(default)] pub token: Option, /// Optional GitLab username for `lore me` personal dashboard. #[serde(default)] pub username: Option, } impl GitLabConfig { /// Resolve token with priority: env var > config file. pub fn resolve_token(&self) -> Result { if let Ok(val) = std::env::var(&self.token_env_var) && !val.trim().is_empty() { return Ok(val.trim().to_string()); } if let Some(ref t) = self.token && !t.trim().is_empty() { return Ok(t.trim().to_string()); } Err(LoreError::TokenNotSet { env_var: self.token_env_var.clone(), }) } /// Returns a human-readable label for where the token was found, or `None`. pub fn token_source(&self) -> Option<&'static str> { if let Ok(val) = std::env::var(&self.token_env_var) && !val.trim().is_empty() { return Some("environment variable"); } if let Some(ref t) = self.token && !t.trim().is_empty() { return Some("config file"); } None } } fn default_token_env_var() -> String { "GITLAB_TOKEN".to_string() } #[derive(Debug, Clone, Deserialize)] pub struct ProjectConfig { pub path: String, } #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct SyncConfig { #[serde(rename = "backfillDays")] pub backfill_days: u32, #[serde(rename = "staleLockMinutes")] pub stale_lock_minutes: u32, #[serde(rename = "heartbeatIntervalSeconds")] pub heartbeat_interval_seconds: u32, #[serde(rename = "cursorRewindSeconds")] pub cursor_rewind_seconds: u32, #[serde(rename = "primaryConcurrency")] pub primary_concurrency: u32, #[serde(rename = "dependentConcurrency")] pub dependent_concurrency: u32, #[serde(rename = "requestsPerSecond")] pub requests_per_second: f64, #[serde(rename = "fetchResourceEvents", default = "default_true")] pub fetch_resource_events: bool, #[serde(rename = "fetchMrFileChanges", default = "default_true")] pub fetch_mr_file_changes: bool, #[serde(rename = "fetchWorkItemStatus", default = "default_true")] pub fetch_work_item_status: bool, } fn default_true() -> bool { true } impl Default for SyncConfig { fn default() -> Self { Self { backfill_days: 14, stale_lock_minutes: 10, heartbeat_interval_seconds: 30, cursor_rewind_seconds: 2, primary_concurrency: 4, dependent_concurrency: 8, requests_per_second: 30.0, fetch_resource_events: true, fetch_mr_file_changes: true, fetch_work_item_status: true, } } } #[derive(Debug, Clone, Deserialize, Default)] #[serde(default)] pub struct StorageConfig { #[serde(rename = "dbPath")] pub db_path: Option, #[serde(rename = "backupDir")] pub backup_dir: Option, #[serde( rename = "compressRawPayloads", default = "default_compress_raw_payloads" )] pub compress_raw_payloads: bool, } fn default_compress_raw_payloads() -> bool { true } #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct EmbeddingConfig { pub provider: String, pub model: String, #[serde(rename = "baseUrl")] pub base_url: String, pub concurrency: u32, } impl Default for EmbeddingConfig { fn default() -> Self { Self { provider: "ollama".to_string(), model: "nomic-embed-text".to_string(), base_url: "http://localhost:11434".to_string(), concurrency: 4, } } } #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct LoggingConfig { #[serde(rename = "logDir")] pub log_dir: Option, #[serde(rename = "retentionDays", default = "default_retention_days")] pub retention_days: u32, #[serde(rename = "fileLogging", default = "default_file_logging")] pub file_logging: bool, } fn default_retention_days() -> u32 { 30 } fn default_file_logging() -> bool { true } impl Default for LoggingConfig { fn default() -> Self { Self { log_dir: None, retention_days: default_retention_days(), file_logging: default_file_logging(), } } } #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct ScoringConfig { /// Points per MR where the user authored code touching the path. #[serde(rename = "authorWeight")] pub author_weight: i64, /// Points per MR where the user reviewed code touching the path. #[serde(rename = "reviewerWeight")] pub reviewer_weight: i64, /// Bonus points per individual inline review comment (DiffNote). #[serde(rename = "noteBonus")] pub note_bonus: i64, /// Points per MR where the user was assigned as a reviewer. #[serde(rename = "reviewerAssignmentWeight")] pub reviewer_assignment_weight: i64, /// Half-life in days for author contribution decay. #[serde(rename = "authorHalfLifeDays")] pub author_half_life_days: u32, /// Half-life in days for reviewer contribution decay. #[serde(rename = "reviewerHalfLifeDays")] pub reviewer_half_life_days: u32, /// Half-life in days for reviewer assignment decay. #[serde(rename = "reviewerAssignmentHalfLifeDays")] pub reviewer_assignment_half_life_days: u32, /// Half-life in days for note/comment contribution decay. #[serde(rename = "noteHalfLifeDays")] pub note_half_life_days: u32, /// Multiplier applied to scores from closed (not merged) MRs. #[serde(rename = "closedMrMultiplier")] pub closed_mr_multiplier: f64, /// Minimum character count for a review note to earn note_bonus. #[serde(rename = "reviewerMinNoteChars")] pub reviewer_min_note_chars: u32, /// Usernames excluded from expert/scoring results. #[serde(rename = "excludedUsernames")] pub excluded_usernames: Vec, } impl Default for ScoringConfig { fn default() -> Self { Self { author_weight: 25, reviewer_weight: 10, note_bonus: 1, reviewer_assignment_weight: 3, author_half_life_days: 180, reviewer_half_life_days: 90, reviewer_assignment_half_life_days: 45, note_half_life_days: 45, closed_mr_multiplier: 0.5, reviewer_min_note_chars: 20, excluded_usernames: vec![], } } } #[derive(Debug, Clone, Deserialize)] pub struct Config { pub gitlab: GitLabConfig, pub projects: Vec, #[serde(rename = "defaultProject")] pub default_project: Option, #[serde(default)] pub sync: SyncConfig, #[serde(default)] pub storage: StorageConfig, #[serde(default)] pub embedding: EmbeddingConfig, #[serde(default)] pub logging: LoggingConfig, #[serde(default)] pub scoring: ScoringConfig, } impl Config { pub fn load(cli_override: Option<&str>) -> Result { let config_path = get_config_path(cli_override); if !config_path.exists() { return Err(LoreError::ConfigNotFound { path: config_path.display().to_string(), }); } Self::load_from_path(&config_path) } pub fn load_from_path(path: &Path) -> Result { let content = fs::read_to_string(path).map_err(|e| LoreError::ConfigInvalid { details: format!("Failed to read config file: {e}"), })?; let config: Config = serde_json::from_str(&content).map_err(|e| LoreError::ConfigInvalid { details: format!("Invalid JSON: {e}"), })?; if config.projects.is_empty() { return Err(LoreError::ConfigInvalid { details: "At least one project is required".to_string(), }); } for project in &config.projects { if project.path.is_empty() { return Err(LoreError::ConfigInvalid { details: "Project path cannot be empty".to_string(), }); } } if url::Url::parse(&config.gitlab.base_url).is_err() { return Err(LoreError::ConfigInvalid { details: format!("Invalid GitLab URL: {}", config.gitlab.base_url), }); } if let Some(ref dp) = config.default_project { let matched = config.projects.iter().any(|p| { p.path.eq_ignore_ascii_case(dp) || p.path .to_ascii_lowercase() .ends_with(&format!("/{}", dp.to_ascii_lowercase())) }); if !matched { return Err(LoreError::ConfigInvalid { details: format!( "defaultProject '{}' does not match any configured project path", dp ), }); } } validate_scoring(&config.scoring)?; Ok(config) } /// Return the effective project filter: CLI flag wins, then config default. pub fn effective_project<'a>(&'a self, cli_project: Option<&'a str>) -> Option<&'a str> { cli_project.or(self.default_project.as_deref()) } } fn validate_scoring(scoring: &ScoringConfig) -> Result<()> { if scoring.author_weight < 0 { return Err(LoreError::ConfigInvalid { details: "scoring.authorWeight must be >= 0".to_string(), }); } if scoring.reviewer_weight < 0 { return Err(LoreError::ConfigInvalid { details: "scoring.reviewerWeight must be >= 0".to_string(), }); } if scoring.note_bonus < 0 { return Err(LoreError::ConfigInvalid { details: "scoring.noteBonus must be >= 0".to_string(), }); } if scoring.reviewer_assignment_weight < 0 { return Err(LoreError::ConfigInvalid { details: "scoring.reviewerAssignmentWeight must be >= 0".to_string(), }); } if scoring.author_half_life_days == 0 || scoring.author_half_life_days > 3650 { return Err(LoreError::ConfigInvalid { details: "scoring.authorHalfLifeDays must be in 1..=3650".to_string(), }); } if scoring.reviewer_half_life_days == 0 || scoring.reviewer_half_life_days > 3650 { return Err(LoreError::ConfigInvalid { details: "scoring.reviewerHalfLifeDays must be in 1..=3650".to_string(), }); } if scoring.reviewer_assignment_half_life_days == 0 || scoring.reviewer_assignment_half_life_days > 3650 { return Err(LoreError::ConfigInvalid { details: "scoring.reviewerAssignmentHalfLifeDays must be in 1..=3650".to_string(), }); } if scoring.note_half_life_days == 0 || scoring.note_half_life_days > 3650 { return Err(LoreError::ConfigInvalid { details: "scoring.noteHalfLifeDays must be in 1..=3650".to_string(), }); } if !scoring.closed_mr_multiplier.is_finite() || scoring.closed_mr_multiplier <= 0.0 || scoring.closed_mr_multiplier > 1.0 { return Err(LoreError::ConfigInvalid { details: "scoring.closedMrMultiplier must be finite and in (0.0, 1.0]".to_string(), }); } if scoring.reviewer_min_note_chars > 4096 { return Err(LoreError::ConfigInvalid { details: "scoring.reviewerMinNoteChars must be <= 4096".to_string(), }); } if scoring .excluded_usernames .iter() .any(|u| u.trim().is_empty()) { return Err(LoreError::ConfigInvalid { details: "scoring.excludedUsernames entries must be non-empty".to_string(), }); } Ok(()) } #[derive(Debug, serde::Serialize)] pub struct MinimalConfig { pub gitlab: MinimalGitLabConfig, pub projects: Vec, #[serde(rename = "defaultProject", skip_serializing_if = "Option::is_none")] pub default_project: Option, } #[derive(Debug, serde::Serialize)] pub struct MinimalGitLabConfig { #[serde(rename = "baseUrl")] pub base_url: String, #[serde(rename = "tokenEnvVar")] pub token_env_var: String, } impl serde::Serialize for ProjectConfig { fn serialize(&self, serializer: S) -> std::result::Result where S: serde::Serializer, { use serde::ser::SerializeStruct; let mut state = serializer.serialize_struct("ProjectConfig", 1)?; state.serialize_field("path", &self.path)?; state.end() } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn write_config(dir: &TempDir, scoring_json: &str) -> std::path::PathBuf { let path = dir.path().join("config.json"); let config = format!( r#"{{ "gitlab": {{ "baseUrl": "https://gitlab.example.com", "tokenEnvVar": "GITLAB_TOKEN" }}, "projects": [ {{ "path": "group/project" }} ], "scoring": {scoring_json} }}"# ); fs::write(&path, config).unwrap(); path } fn write_config_with_default_project( dir: &TempDir, default_project: Option<&str>, ) -> std::path::PathBuf { let path = dir.path().join("config.json"); let dp_field = match default_project { Some(dp) => format!(r#","defaultProject": "{dp}""#), None => String::new(), }; let config = format!( r#"{{ "gitlab": {{ "baseUrl": "https://gitlab.example.com", "tokenEnvVar": "GITLAB_TOKEN" }}, "projects": [ {{ "path": "group/project" }}, {{ "path": "other/repo" }} ]{dp_field} }}"# ); fs::write(&path, config).unwrap(); path } #[test] fn test_load_rejects_negative_author_weight() { let dir = TempDir::new().unwrap(); let path = write_config( &dir, r#"{ "authorWeight": -1, "reviewerWeight": 10, "noteBonus": 1 }"#, ); let err = Config::load_from_path(&path).unwrap_err(); let msg = err.to_string(); assert!( msg.contains("scoring.authorWeight"), "unexpected error: {msg}" ); } #[test] fn test_load_rejects_negative_reviewer_weight() { let dir = TempDir::new().unwrap(); let path = write_config( &dir, r#"{ "authorWeight": 25, "reviewerWeight": -1, "noteBonus": 1 }"#, ); let err = Config::load_from_path(&path).unwrap_err(); let msg = err.to_string(); assert!( msg.contains("scoring.reviewerWeight"), "unexpected error: {msg}" ); } #[test] fn test_config_fetch_work_item_status_default_true() { let config = SyncConfig::default(); assert!(config.fetch_work_item_status); } #[test] fn test_config_deserialize_without_key() { let json = r#"{}"#; let config: SyncConfig = serde_json::from_str(json).unwrap(); assert!( config.fetch_work_item_status, "Missing key should default to true" ); } #[test] fn test_load_rejects_negative_note_bonus() { let dir = TempDir::new().unwrap(); let path = write_config( &dir, r#"{ "authorWeight": 25, "reviewerWeight": 10, "noteBonus": -1 }"#, ); let err = Config::load_from_path(&path).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("scoring.noteBonus"), "unexpected error: {msg}"); } #[test] fn test_effective_project_cli_overrides_default() { let config = Config { gitlab: GitLabConfig { base_url: "https://gitlab.example.com".to_string(), token_env_var: "GITLAB_TOKEN".to_string(), token: None, username: None, }, projects: vec![ProjectConfig { path: "group/project".to_string(), }], default_project: Some("group/project".to_string()), sync: SyncConfig::default(), storage: StorageConfig::default(), embedding: EmbeddingConfig::default(), logging: LoggingConfig::default(), scoring: ScoringConfig::default(), }; assert_eq!( config.effective_project(Some("other/repo")), Some("other/repo") ); } #[test] fn test_effective_project_falls_back_to_default() { let config = Config { gitlab: GitLabConfig { base_url: "https://gitlab.example.com".to_string(), token_env_var: "GITLAB_TOKEN".to_string(), token: None, username: None, }, projects: vec![ProjectConfig { path: "group/project".to_string(), }], default_project: Some("group/project".to_string()), sync: SyncConfig::default(), storage: StorageConfig::default(), embedding: EmbeddingConfig::default(), logging: LoggingConfig::default(), scoring: ScoringConfig::default(), }; assert_eq!(config.effective_project(None), Some("group/project")); } #[test] fn test_effective_project_none_when_both_absent() { let config = Config { gitlab: GitLabConfig { base_url: "https://gitlab.example.com".to_string(), token_env_var: "GITLAB_TOKEN".to_string(), token: None, username: None, }, projects: vec![ProjectConfig { path: "group/project".to_string(), }], default_project: None, sync: SyncConfig::default(), storage: StorageConfig::default(), embedding: EmbeddingConfig::default(), logging: LoggingConfig::default(), scoring: ScoringConfig::default(), }; assert_eq!(config.effective_project(None), None); } #[test] fn test_load_with_valid_default_project() { let dir = TempDir::new().unwrap(); let path = write_config_with_default_project(&dir, Some("group/project")); let config = Config::load_from_path(&path).unwrap(); assert_eq!(config.default_project.as_deref(), Some("group/project")); } #[test] fn test_load_rejects_invalid_default_project() { let dir = TempDir::new().unwrap(); let path = write_config_with_default_project(&dir, Some("nonexistent/project")); let err = Config::load_from_path(&path).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("defaultProject"), "unexpected error: {msg}"); } #[test] fn test_load_default_project_suffix_match() { let dir = TempDir::new().unwrap(); let path = write_config_with_default_project(&dir, Some("project")); let config = Config::load_from_path(&path).unwrap(); assert_eq!(config.default_project.as_deref(), Some("project")); } #[test] fn test_minimal_config_omits_null_default_project() { let config = MinimalConfig { gitlab: MinimalGitLabConfig { base_url: "https://gitlab.example.com".to_string(), token_env_var: "GITLAB_TOKEN".to_string(), }, projects: vec![ProjectConfig { path: "group/project".to_string(), }], default_project: None, }; let json = serde_json::to_string(&config).unwrap(); assert!( !json.contains("defaultProject"), "null default_project should be omitted: {json}" ); } #[test] fn test_minimal_config_includes_default_project_when_set() { let config = MinimalConfig { gitlab: MinimalGitLabConfig { base_url: "https://gitlab.example.com".to_string(), token_env_var: "GITLAB_TOKEN".to_string(), }, projects: vec![ProjectConfig { path: "group/project".to_string(), }], default_project: Some("group/project".to_string()), }; let json = serde_json::to_string(&config).unwrap(); assert!( json.contains("defaultProject"), "set default_project should be present: {json}" ); } #[test] fn test_config_validation_rejects_zero_half_life() { let scoring = ScoringConfig { author_half_life_days: 0, ..Default::default() }; let err = validate_scoring(&scoring).unwrap_err(); let msg = err.to_string(); assert!( msg.contains("authorHalfLifeDays"), "unexpected error: {msg}" ); } #[test] fn test_config_validation_rejects_absurd_half_life() { let scoring = ScoringConfig { author_half_life_days: 5000, ..Default::default() }; let err = validate_scoring(&scoring).unwrap_err(); let msg = err.to_string(); assert!( msg.contains("authorHalfLifeDays"), "unexpected error: {msg}" ); } #[test] fn test_config_validation_rejects_nan_multiplier() { let scoring = ScoringConfig { closed_mr_multiplier: f64::NAN, ..Default::default() }; let err = validate_scoring(&scoring).unwrap_err(); let msg = err.to_string(); assert!( msg.contains("closedMrMultiplier"), "unexpected error: {msg}" ); } #[test] fn test_config_validation_rejects_zero_multiplier() { let scoring = ScoringConfig { closed_mr_multiplier: 0.0, ..Default::default() }; let err = validate_scoring(&scoring).unwrap_err(); let msg = err.to_string(); assert!( msg.contains("closedMrMultiplier"), "unexpected error: {msg}" ); } #[test] fn test_config_validation_rejects_negative_reviewer_assignment_weight() { let scoring = ScoringConfig { reviewer_assignment_weight: -1, ..Default::default() }; let err = validate_scoring(&scoring).unwrap_err(); let msg = err.to_string(); assert!( msg.contains("reviewerAssignmentWeight"), "unexpected error: {msg}" ); } #[test] fn test_config_validation_rejects_oversized_min_note_chars() { let scoring = ScoringConfig { reviewer_min_note_chars: 5000, ..Default::default() }; let err = validate_scoring(&scoring).unwrap_err(); let msg = err.to_string(); assert!( msg.contains("reviewerMinNoteChars"), "unexpected error: {msg}" ); } #[test] fn test_config_validation_rejects_empty_excluded_username() { let scoring = ScoringConfig { excluded_usernames: vec!["valid".to_string(), " ".to_string()], ..Default::default() }; let err = validate_scoring(&scoring).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("excludedUsernames"), "unexpected error: {msg}"); } #[test] fn test_config_validation_accepts_valid_new_fields() { let scoring = ScoringConfig { author_half_life_days: 365, reviewer_half_life_days: 180, reviewer_assignment_half_life_days: 90, note_half_life_days: 60, closed_mr_multiplier: 0.5, reviewer_min_note_chars: 20, reviewer_assignment_weight: 3, excluded_usernames: vec!["bot-user".to_string()], ..Default::default() }; validate_scoring(&scoring).unwrap(); } #[test] fn test_config_validation_accepts_boundary_half_life() { // 1 and 3650 are both valid boundaries let scoring_min = ScoringConfig { author_half_life_days: 1, ..Default::default() }; validate_scoring(&scoring_min).unwrap(); let scoring_max = ScoringConfig { author_half_life_days: 3650, ..Default::default() }; validate_scoring(&scoring_max).unwrap(); } #[test] fn test_config_validation_accepts_multiplier_at_one() { let scoring = ScoringConfig { closed_mr_multiplier: 1.0, ..Default::default() }; validate_scoring(&scoring).unwrap(); } // ── token_source / resolve_token ──────────────────────────────── /// Build a `GitLabConfig` that reads from the given unique env var name /// so parallel tests never collide. fn gitlab_cfg_with_env(env_var: &str, token: Option<&str>) -> GitLabConfig { GitLabConfig { base_url: "https://gitlab.example.com".to_string(), token_env_var: env_var.to_string(), token: token.map(ToString::to_string), username: None, } } #[test] fn test_token_source_env_wins_over_config() { const VAR: &str = "LORE_TEST_TS_ENV_WINS"; // SAFETY: unique var name, no other code reads it. unsafe { std::env::set_var(VAR, "env-tok") }; let cfg = gitlab_cfg_with_env(VAR, Some("config-tok")); assert_eq!(cfg.token_source(), Some("environment variable")); unsafe { std::env::remove_var(VAR) }; } #[test] fn test_token_source_falls_back_to_config() { const VAR: &str = "LORE_TEST_TS_FALLBACK"; unsafe { std::env::remove_var(VAR) }; let cfg = gitlab_cfg_with_env(VAR, Some("config-tok")); assert_eq!(cfg.token_source(), Some("config file")); } #[test] fn test_token_source_none_when_both_absent() { const VAR: &str = "LORE_TEST_TS_NONE"; unsafe { std::env::remove_var(VAR) }; let cfg = gitlab_cfg_with_env(VAR, None); assert_eq!(cfg.token_source(), None); } #[test] fn test_token_source_ignores_whitespace_only_env() { const VAR: &str = "LORE_TEST_TS_WS_ENV"; unsafe { std::env::set_var(VAR, " ") }; let cfg = gitlab_cfg_with_env(VAR, Some("real")); assert_eq!(cfg.token_source(), Some("config file")); unsafe { std::env::remove_var(VAR) }; } #[test] fn test_token_source_ignores_whitespace_only_config() { const VAR: &str = "LORE_TEST_TS_WS_CFG"; unsafe { std::env::remove_var(VAR) }; let cfg = gitlab_cfg_with_env(VAR, Some(" \t ")); assert_eq!(cfg.token_source(), None); } #[test] fn test_resolve_token_env_wins_over_config() { const VAR: &str = "LORE_TEST_RT_ENV_WINS"; unsafe { std::env::set_var(VAR, " env-tok ") }; let cfg = gitlab_cfg_with_env(VAR, Some("config-tok")); assert_eq!(cfg.resolve_token().unwrap(), "env-tok"); unsafe { std::env::remove_var(VAR) }; } #[test] fn test_resolve_token_config_fallback() { const VAR: &str = "LORE_TEST_RT_FALLBACK"; unsafe { std::env::remove_var(VAR) }; let cfg = gitlab_cfg_with_env(VAR, Some(" config-tok ")); assert_eq!(cfg.resolve_token().unwrap(), "config-tok"); } #[test] fn test_resolve_token_err_when_both_absent() { const VAR: &str = "LORE_TEST_RT_NONE"; unsafe { std::env::remove_var(VAR) }; let cfg = gitlab_cfg_with_env(VAR, None); assert!(cfg.resolve_token().is_err()); } // ── gitlab.username ───────────────────────────────────────────── #[test] fn test_config_loads_with_username() { let dir = TempDir::new().unwrap(); let path = dir.path().join("config.json"); let config = r#"{ "gitlab": { "baseUrl": "https://gitlab.example.com", "tokenEnvVar": "GITLAB_TOKEN", "username": "jdoe" }, "projects": [{ "path": "group/project" }] }"#; fs::write(&path, config).unwrap(); let cfg = Config::load_from_path(&path).unwrap(); assert_eq!(cfg.gitlab.username.as_deref(), Some("jdoe")); } #[test] fn test_config_loads_without_username() { let dir = TempDir::new().unwrap(); let path = dir.path().join("config.json"); let config = r#"{ "gitlab": { "baseUrl": "https://gitlab.example.com", "tokenEnvVar": "GITLAB_TOKEN" }, "projects": [{ "path": "group/project" }] }"#; fs::write(&path, config).unwrap(); let cfg = Config::load_from_path(&path).unwrap(); assert_eq!(cfg.gitlab.username, None); } }