feat(config): add defaultProject with validation and cascading resolver
Introduces a new optional `defaultProject` field on Config (and MinimalConfig for init output) that acts as a fallback when the `-p`/`--project` CLI flag is omitted. Domain-layer changes: - Config.default_project: Option<String> with camelCase serde rename - Config::load validates that defaultProject matches a configured project path (exact or case-insensitive suffix match), returning ConfigInvalid on mismatch - Config::effective_project(cli_flag) -> Option<&str>: cascading resolver that prefers the CLI flag, then the config default, then None - MinimalConfig.default_project with skip_serializing_if for clean JSON output when unset Tests added: - effective_project: CLI overrides default, falls back to default, returns None when both absent - Config::load: accepts valid defaultProject, rejects nonexistent, accepts suffix match - MinimalConfig: omits null defaultProject, includes when set - Helper write_config_with_default_project for parameterized tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -181,6 +181,9 @@ pub struct Config {
|
|||||||
pub gitlab: GitLabConfig,
|
pub gitlab: GitLabConfig,
|
||||||
pub projects: Vec<ProjectConfig>,
|
pub projects: Vec<ProjectConfig>,
|
||||||
|
|
||||||
|
#[serde(rename = "defaultProject")]
|
||||||
|
pub default_project: Option<String>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sync: SyncConfig,
|
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)?;
|
validate_scoring(&config.scoring)?;
|
||||||
|
|
||||||
Ok(config)
|
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<()> {
|
fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
|
||||||
@@ -269,6 +294,8 @@ fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
|
|||||||
pub struct MinimalConfig {
|
pub struct MinimalConfig {
|
||||||
pub gitlab: MinimalGitLabConfig,
|
pub gitlab: MinimalGitLabConfig,
|
||||||
pub projects: Vec<ProjectConfig>,
|
pub projects: Vec<ProjectConfig>,
|
||||||
|
#[serde(rename = "defaultProject", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub default_project: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -314,6 +341,31 @@ mod tests {
|
|||||||
path
|
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]
|
#[test]
|
||||||
fn test_load_rejects_negative_author_weight() {
|
fn test_load_rejects_negative_author_weight() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -383,4 +435,130 @@ mod tests {
|
|||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
assert!(msg.contains("scoring.noteBonus"), "unexpected error: {msg}");
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user