diff --git a/README.md b/README.md index f49c8b3..af14e90 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,8 @@ token = { value = "gitea_pat_..." } [[mirrors]] name = "personal" sync_visibility = "all" -repo_whitelist = ["^important-"] -repo_blacklist = ["-archive$"] +repo_whitelist = "^important-" +repo_blacklist = "-archive$" create_missing = true visibility = "private" conflict_resolution = "auto_rebase_pull_request" @@ -179,7 +179,7 @@ Each mirror group is treated as a set of equivalent namespaces. Repositories are Set `sync_visibility = "all"`, `"private"`, or `"public"` on a mirror group to choose which repository visibility is included in that group. When `refray` creates a missing repository, it mirrors the visibility of the existing repository it is syncing from; `visibility` is only a fallback when no source visibility is available. -Set `repo_whitelist = ["..."]` and/or `repo_blacklist = ["..."]` on a mirror group to filter repository names with regular expressions. An empty whitelist includes all repository names, and blacklist matches are excluded after whitelist matches. These name filters are independent from `sync_visibility`; both must match for a repository to be synced. +Set `repo_whitelist = "..."` and/or `repo_blacklist = "..."` on a mirror group to filter repository names with regular expressions. Omit `repo_whitelist` to include all repository names, and blacklist matches are excluded after whitelist matches. These name filters are independent from `sync_visibility`; both must match for a repository to be synced. For every repository name found in any endpoint, `refray` will: diff --git a/src/config.rs b/src/config.rs index e55d717..a4470ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,10 +56,10 @@ pub struct MirrorConfig { pub endpoints: Vec, #[serde(default)] pub sync_visibility: SyncVisibility, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub repo_whitelist: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub repo_blacklist: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_whitelist: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_blacklist: Option, #[serde(default = "default_true")] pub create_missing: bool, #[serde(default)] @@ -135,8 +135,8 @@ pub enum SyncVisibility { #[derive(Clone, Debug)] pub struct RepoNameFilter { - whitelist: Vec, - blacklist: Vec, + whitelist: Option, + blacklist: Option, } impl SyncVisibility { @@ -152,35 +152,41 @@ impl SyncVisibility { impl MirrorConfig { pub fn repo_filter(&self) -> Result { Ok(RepoNameFilter { - whitelist: compile_repo_patterns(&self.name, "repo_whitelist", &self.repo_whitelist)?, - blacklist: compile_repo_patterns(&self.name, "repo_blacklist", &self.repo_blacklist)?, + whitelist: compile_repo_pattern(&self.name, "repo_whitelist", &self.repo_whitelist)?, + blacklist: compile_repo_pattern(&self.name, "repo_blacklist", &self.repo_blacklist)?, }) } } impl RepoNameFilter { pub fn matches(&self, repo_name: &str) -> bool { - let whitelisted = self.whitelist.is_empty() - || self - .whitelist - .iter() - .any(|pattern| pattern.is_match(repo_name)); + let whitelisted = self + .whitelist + .as_ref() + .is_none_or(|pattern| pattern.is_match(repo_name)); let blacklisted = self .blacklist - .iter() - .any(|pattern| pattern.is_match(repo_name)); + .as_ref() + .is_some_and(|pattern| pattern.is_match(repo_name)); whitelisted && !blacklisted } } -fn compile_repo_patterns(mirror: &str, field: &str, patterns: &[String]) -> Result> { - patterns - .iter() - .map(|pattern| { - Regex::new(pattern) - .with_context(|| format!("mirror '{mirror}' has invalid {field} regex '{pattern}'")) - }) - .collect() +fn compile_repo_pattern( + mirror: &str, + field: &str, + pattern: &Option, +) -> Result> { + let Some(pattern) = pattern + .as_deref() + .map(str::trim) + .filter(|pattern| !pattern.is_empty()) + else { + return Ok(None); + }; + Regex::new(pattern) + .with_context(|| format!("mirror '{mirror}' has invalid {field} regex '{pattern}'")) + .map(Some) } fn default_true() -> bool { diff --git a/src/interactive.rs b/src/interactive.rs index 43e84ff..4ec2588 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -38,8 +38,8 @@ struct ParsedProfileUrl { #[derive(Clone, Debug, Default)] struct RepoFilterInput { - whitelist: Vec, - blacklist: Vec, + whitelist: Option, + blacklist: Option, } pub fn run_config_wizard(path: &Path) -> Result { @@ -780,7 +780,7 @@ fn prompt_repo_filters_styled( existing: Option<&RepoFilterInput>, ) -> Result { let existing = existing.cloned().unwrap_or_default(); - let has_existing = !existing.whitelist.is_empty() || !existing.blacklist.is_empty(); + let has_existing = existing.whitelist.is_some() || existing.blacklist.is_some(); if !Confirm::with_theme(theme) .with_prompt("Configure repository name whitelist/blacklist?") .default(has_existing) @@ -790,51 +790,44 @@ fn prompt_repo_filters_styled( } Ok(RepoFilterInput { - whitelist: prompt_repo_pattern_list_styled( + whitelist: prompt_repo_pattern_styled( theme, - "Whitelist regexes (comma-separated, empty means all repo names)", + "Whitelist regex (empty means all repo names)", &existing.whitelist, )?, - blacklist: prompt_repo_pattern_list_styled( - theme, - "Blacklist regexes (comma-separated)", - &existing.blacklist, - )?, + blacklist: prompt_repo_pattern_styled(theme, "Blacklist regex", &existing.blacklist)?, }) } -fn prompt_repo_pattern_list_styled( +fn prompt_repo_pattern_styled( theme: &ColorfulTheme, prompt: &str, - existing: &[String], -) -> Result> { + existing: &Option, +) -> Result> { let input = Input::::with_theme(theme) .with_prompt(prompt) .allow_empty(true) - .validate_with(|value: &String| validate_repo_pattern_list(value)); - let input = if existing.is_empty() { - input + .validate_with(|value: &String| validate_repo_pattern(value)); + let input = if let Some(existing) = existing { + input.default(existing.clone()) } else { - input.default(existing.join(", ")) + input }; let value = input.interact_text()?; - Ok(parse_repo_pattern_list(&value)) + Ok(parse_repo_pattern(&value)) } -fn validate_repo_pattern_list(value: &str) -> std::result::Result<(), String> { - for pattern in parse_repo_pattern_list(value) { - Regex::new(&pattern).map_err(|error| format!("invalid regex '{pattern}': {error}"))?; - } +fn validate_repo_pattern(value: &str) -> std::result::Result<(), String> { + let Some(pattern) = parse_repo_pattern(value) else { + return Ok(()); + }; + Regex::new(&pattern).map_err(|error| format!("invalid regex '{pattern}': {error}"))?; Ok(()) } -fn parse_repo_pattern_list(value: &str) -> Vec { - value - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .collect() +fn parse_repo_pattern(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) } fn sync_visibility_index(sync_visibility: &SyncVisibility) -> usize { @@ -937,13 +930,11 @@ fn sync_visibility_label(sync_visibility: &SyncVisibility) -> &'static str { } fn repo_filter_label(mirror: &MirrorConfig) -> String { - match (mirror.repo_whitelist.len(), mirror.repo_blacklist.len()) { - (0, 0) => "repos: all names".to_string(), - (whitelist, 0) => format!("repos: whitelist {whitelist}"), - (0, blacklist) => format!("repos: blacklist {blacklist}"), - (whitelist, blacklist) => { - format!("repos: whitelist {whitelist}, blacklist {blacklist}") - } + match (&mirror.repo_whitelist, &mirror.repo_blacklist) { + (None, None) => "repos: all names".to_string(), + (Some(_), None) => "repos: whitelist".to_string(), + (None, Some(_)) => "repos: blacklist".to_string(), + (Some(_), Some(_)) => "repos: whitelist + blacklist".to_string(), } } diff --git a/tests/e2e/sequential.rs b/tests/e2e/sequential.rs index 5499dfd..2537336 100644 --- a/tests/e2e/sequential.rs +++ b/tests/e2e/sequential.rs @@ -339,7 +339,7 @@ secret = {{ value = "{WEBHOOK_SECRET}" }} [[mirrors]] name = "all" sync_visibility = "all" -repo_whitelist = ['{}'] +repo_whitelist = '{}' create_missing = {} visibility = "public" conflict_resolution = "{}" @@ -788,7 +788,7 @@ namespace = "{}" let contents = fs::read_to_string(&self.config_path) .with_context(|| format!("failed to read {}", self.config_path.display()))?; let escaped_pattern = pattern.replace('\'', "''"); - let replacement = format!("repo_whitelist = ['{escaped_pattern}']"); + let replacement = format!("repo_whitelist = '{escaped_pattern}'"); let mut replaced = false; let mut updated = contents .lines() diff --git a/tests/unit/config.rs b/tests/unit/config.rs index 594dd17..f61c84c 100644 --- a/tests/unit/config.rs +++ b/tests/unit/config.rs @@ -22,8 +22,8 @@ fn parses_value_tokens() { [[mirrors]] name = "personal" sync_visibility = "public" - repo_whitelist = ["^important-", "-mirror$"] - repo_blacklist = ["-archive$"] + repo_whitelist = "^important-|-mirror$" + repo_blacklist = "-archive$" create_missing = true visibility = "private" conflict_resolution = "auto_rebase_pull_request" @@ -51,11 +51,11 @@ fn parses_value_tokens() { assert_eq!(config.mirrors[0].sync_visibility, SyncVisibility::Public); assert_eq!( config.mirrors[0].repo_whitelist, - vec!["^important-".to_string(), "-mirror$".to_string()] + Some("^important-|-mirror$".to_string()) ); assert_eq!( config.mirrors[0].repo_blacklist, - vec!["-archive$".to_string()] + Some("-archive$".to_string()) ); let webhook = config.webhook.unwrap(); assert!(webhook.install); @@ -105,8 +105,8 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() { namespace: "alice".to_string(), }], sync_visibility: SyncVisibility::All, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, @@ -134,8 +134,8 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() { }, ], sync_visibility: SyncVisibility::All, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, @@ -199,8 +199,8 @@ fn sync_visibility_matches_repo_privacy() { #[test] fn repo_name_filter_applies_whitelist_then_blacklist() { let mut mirror = mirror_config(); - mirror.repo_whitelist = vec!["^important-".to_string(), "-mirror$".to_string()]; - mirror.repo_blacklist = vec!["-archive$".to_string()]; + mirror.repo_whitelist = Some("^important-|-mirror$".to_string()); + mirror.repo_blacklist = Some("-archive$".to_string()); let filter = mirror.repo_filter().unwrap(); assert!(filter.matches("important-api")); @@ -217,7 +217,7 @@ fn validation_rejects_invalid_repo_filter_regex() { mirrors: vec![mirror_config()], webhook: None, }; - config.mirrors[0].repo_whitelist = vec!["(".to_string()]; + config.mirrors[0].repo_whitelist = Some("(".to_string()); let err = validate_config(&config).unwrap_err().to_string(); @@ -238,8 +238,8 @@ fn validation_rejects_duplicate_mirror_endpoints() { name: "broken".to_string(), endpoints: vec![duplicate.clone(), duplicate], sync_visibility: SyncVisibility::All, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, @@ -284,8 +284,8 @@ fn mirror_config() -> MirrorConfig { }, ], sync_visibility: SyncVisibility::All, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, diff --git a/tests/unit/interactive.rs b/tests/unit/interactive.rs index 1c96171..340d4c2 100644 --- a/tests/unit/interactive.rs +++ b/tests/unit/interactive.rs @@ -211,8 +211,8 @@ fn wizard_starts_existing_config_at_sync_group_menu() { }, ], sync_visibility: SyncVisibility::All, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, @@ -240,8 +240,8 @@ fn wizard_can_ask_to_run_full_sync_after_config() { name: "sync-1".to_string(), endpoints: Vec::new(), sync_visibility: SyncVisibility::All, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, @@ -316,8 +316,8 @@ fn wizard_edits_existing_sync_group_from_menu() { }, ], sync_visibility: SyncVisibility::Private, - repo_whitelist: vec!["^important-".to_string()], - repo_blacklist: vec!["-archive$".to_string()], + repo_whitelist: Some("^important-".to_string()), + repo_blacklist: Some("-archive$".to_string()), create_missing: false, visibility: Visibility::Public, conflict_resolution: ConflictResolutionStrategy::Fail, @@ -357,8 +357,8 @@ fn wizard_edits_existing_sync_group_from_menu() { assert_eq!(mirror.endpoints[1].namespace, "bob"); assert!(!mirror.create_missing); assert_eq!(mirror.sync_visibility, SyncVisibility::Public); - assert_eq!(mirror.repo_whitelist, vec!["^public-".to_string()]); - assert_eq!(mirror.repo_blacklist, vec!["-skip$".to_string()]); + assert_eq!(mirror.repo_whitelist, Some("^public-".to_string())); + assert_eq!(mirror.repo_blacklist, Some("-skip$".to_string())); assert_eq!(mirror.visibility, Visibility::Public); let output = String::from_utf8(output).unwrap(); assert!(output.contains("Edit sync group")); @@ -403,8 +403,8 @@ fn wizard_prefills_existing_sync_group_when_editing() { }, ], sync_visibility: SyncVisibility::All, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, @@ -467,8 +467,8 @@ fn wizard_deletes_existing_sync_group_from_menu() { }, ], sync_visibility: SyncVisibility::All, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, @@ -526,8 +526,8 @@ fn wizard_can_go_back_from_delete_menu() { }, ], sync_visibility: SyncVisibility::All, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, diff --git a/tests/unit/interactive_test_io.rs b/tests/unit/interactive_test_io.rs index 50df58b..cc09d3c 100644 --- a/tests/unit/interactive_test_io.rs +++ b/tests/unit/interactive_test_io.rs @@ -531,7 +531,7 @@ where W: Write, { let existing = existing.cloned().unwrap_or_default(); - let has_existing = !existing.whitelist.is_empty() || !existing.blacklist.is_empty(); + let has_existing = existing.whitelist.is_some() || existing.blacklist.is_some(); if !prompt_bool( reader, writer, @@ -542,40 +542,34 @@ where } Ok(RepoFilterInput { - whitelist: prompt_repo_pattern_list( + whitelist: prompt_repo_pattern( reader, writer, - "Whitelist regexes (comma-separated, empty means all repo names)", + "Whitelist regex (empty means all repo names)", &existing.whitelist, )?, - blacklist: prompt_repo_pattern_list( - reader, - writer, - "Blacklist regexes (comma-separated)", - &existing.blacklist, - )?, + blacklist: prompt_repo_pattern(reader, writer, "Blacklist regex", &existing.blacklist)?, }) } -fn prompt_repo_pattern_list( +fn prompt_repo_pattern( reader: &mut R, writer: &mut W, label: &str, - existing: &[String], -) -> Result> + existing: &Option, +) -> Result> where R: BufRead, W: Write, { - let value = if existing.is_empty() { - prompt_optional(reader, writer, label)? - } else { - prompt_with_default(reader, writer, label, &existing.join(", "))? + let value = match existing { + Some(existing) => prompt_with_default(reader, writer, label, existing)?, + None => prompt_optional(reader, writer, label)?, }; - if let Err(error) = validate_repo_pattern_list(&value) { + if let Err(error) = validate_repo_pattern(&value) { bail!(error); } - Ok(parse_repo_pattern_list(&value)) + Ok(parse_repo_pattern(&value)) } fn sync_visibility_value(sync_visibility: &SyncVisibility) -> &'static str { diff --git a/tests/unit/sync.rs b/tests/unit/sync.rs index e5f7f63..d6edea2 100644 --- a/tests/unit/sync.rs +++ b/tests/unit/sync.rs @@ -344,7 +344,7 @@ fn all_visibility_keeps_state_only_repos_for_deletion_detection() { #[test] fn repo_name_filters_do_not_treat_state_only_repos_as_deleted() { let mut mirror = test_mirror(); - mirror.repo_whitelist = vec!["^public-".to_string()]; + mirror.repo_whitelist = Some("^public-".to_string()); let repo_filter = mirror.repo_filter().unwrap(); let mut ref_state = RefState::default(); ref_state.set_repo( @@ -472,8 +472,8 @@ fn test_mirror() -> MirrorConfig { name: "sync-1".to_string(), endpoints: vec![endpoint("github"), endpoint("gitea")], sync_visibility: crate::config::SyncVisibility::All, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: crate::config::Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, diff --git a/tests/unit/webhook.rs b/tests/unit/webhook.rs index 75df5a4..ef145a8 100644 --- a/tests/unit/webhook.rs +++ b/tests/unit/webhook.rs @@ -112,8 +112,8 @@ fn matches_jobs_by_provider_and_namespace() { endpoint("gitea", NamespaceKind::User, "azalea"), ], sync_visibility: SyncVisibility::All, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, @@ -139,8 +139,8 @@ fn matching_jobs_respects_repo_name_filters() { name: "sync-1".to_string(), endpoints: vec![endpoint("github", NamespaceKind::User, "alice")], sync_visibility: SyncVisibility::All, - repo_whitelist: vec!["^important-".to_string()], - repo_blacklist: vec!["-archive$".to_string()], + repo_whitelist: Some("^important-".to_string()), + repo_blacklist: Some("-archive$".to_string()), create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, @@ -159,7 +159,7 @@ fn matching_jobs_respects_repo_name_filters() { assert!(matching_jobs(&config, &webhook_event("important-archive")).is_empty()); assert!(matching_jobs(&config, &webhook_event("random")).is_empty()); - mirror.repo_whitelist.clear(); + mirror.repo_whitelist = None; let config = Config { jobs: crate::config::DEFAULT_JOBS, sites: vec![site("github", ProviderKind::Github)], @@ -357,8 +357,8 @@ fn uninstall_webhooks_skips_blocked_provider_access() { endpoint("github-peer", NamespaceKind::User, "bob"), ], sync_visibility: SyncVisibility::Public, - repo_whitelist: Vec::new(), - repo_blacklist: Vec::new(), + repo_whitelist: None, + repo_blacklist: None, create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, @@ -710,8 +710,8 @@ fn filtered_mirror() -> MirrorConfig { endpoint("github-peer", NamespaceKind::User, "bob"), ], sync_visibility: SyncVisibility::Public, - repo_whitelist: vec!["^important-".to_string()], - repo_blacklist: vec!["-archive$".to_string()], + repo_whitelist: Some("^important-".to_string()), + repo_blacklist: Some("-archive$".to_string()), create_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail,