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:
teernisse
2026-02-11 11:36:42 -05:00
parent 81647545e7
commit 6ea3108a20

View File

@@ -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}"
);
}
} }