diff --git a/src/core/config.rs b/src/core/config.rs index 080dfb0..39182f1 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -181,6 +181,9 @@ pub struct Config { pub gitlab: GitLabConfig, pub projects: Vec, + #[serde(rename = "defaultProject")] + pub default_project: Option, + #[serde(default)] pub sync: SyncConfig, @@ -240,10 +243,32 @@ impl Config { }); } + 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<()> { @@ -269,6 +294,8 @@ fn validate_scoring(scoring: &ScoringConfig) -> Result<()> { 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)] @@ -314,6 +341,31 @@ mod tests { 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(); @@ -383,4 +435,130 @@ mod tests { 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(), + }, + 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(), + }, + 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(), + }, + 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}" + ); + } }