Implement a personal work dashboard that shows everything relevant to the
configured GitLab user: open issues assigned to them, MRs they authored,
MRs they are reviewing, and a chronological activity feed.
Design decisions:
- Attention state computed from GitLab interaction data (comments, reviews)
with no local state tracking -- purely derived from existing synced data
- Username resolution: --user flag > config.gitlab.username > actionable error
- Project scoping: --project (fuzzy) | --all | default_project | all
- Section filtering: --issues, --mrs, --activity (combinable, default = all)
- Activity feed controlled by --since (default 30d); work item sections
always show all open items regardless of --since
Architecture (src/cli/commands/me/):
- types.rs: MeDashboard, MeSummary, AttentionState data types
- queries.rs: 4 SQL queries (open_issues, authored_mrs, reviewing_mrs,
activity) using existing issue_assignees, mr_reviewers, notes tables
- render_human.rs: colored terminal output with attention state indicators
- render_robot.rs: {ok, data, meta} JSON envelope with field selection
- mod.rs: orchestration (resolve_username, resolve_project_scope, run_me)
- me_tests.rs: comprehensive unit tests covering all query paths
Config additions:
- New optional gitlab.username field in config.json
- Tests for config with/without username
- Existing test configs updated with username: None
CLI wiring:
- MeArgs struct with section filter, since, project, all, user, fields flags
- Autocorrect support for me command flags
- LoreRenderer::try_get() for safe renderer access in me module
- Robot mode field selection presets (me_items, me_activity)
- handle_me() in main.rs command dispatch
Also fixes duplicate assertions in surgical sync tests (removed 6
duplicate assert! lines that were copy-paste artifacts).
Spec: docs/lore-me-spec.md
954 lines
29 KiB
Rust
954 lines
29 KiB
Rust
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<String>,
|
|
|
|
/// Optional GitLab username for `lore me` personal dashboard.
|
|
#[serde(default)]
|
|
pub username: Option<String>,
|
|
}
|
|
|
|
impl GitLabConfig {
|
|
/// Resolve token with priority: env var > config file.
|
|
pub fn resolve_token(&self) -> Result<String> {
|
|
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<String>,
|
|
|
|
#[serde(rename = "backupDir")]
|
|
pub backup_dir: Option<String>,
|
|
|
|
#[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<String>,
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
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<ProjectConfig>,
|
|
|
|
#[serde(rename = "defaultProject")]
|
|
pub default_project: Option<String>,
|
|
|
|
#[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<Self> {
|
|
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<Self> {
|
|
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<ProjectConfig>,
|
|
#[serde(rename = "defaultProject", skip_serializing_if = "Option::is_none")]
|
|
pub default_project: Option<String>,
|
|
}
|
|
|
|
#[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<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
|
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);
|
|
}
|
|
}
|