use axum::http::StatusCode;
use axum::response::Response;
use knot::models::RepoScope;
use crate::handlers::models::error_response;
use crate::models::AppState;
pub(crate) const DEFAULT_MAX_RESULTS: usize = 5;
pub(crate) const MIN_MAX_RESULTS: usize = 1;
pub(crate) const MAX_MAX_RESULTS: usize = 100;
#[derive(Debug, PartialEq, Eq)]
pub enum ResolvedScope {
Scope(RepoScope),
NoRepositories,
}
pub fn resolve_scope(raw: Option<&str>, known: &[String]) -> Result<ResolvedScope, Vec<String>> {
let scope = RepoScope::parse_optional(raw);
if matches!(scope, RepoScope::All) {
let mut ids: Vec<String> = known.to_vec();
ids.sort();
ids.dedup();
return Ok(match ids.len() {
0 => ResolvedScope::NoRepositories,
1 => ResolvedScope::Scope(RepoScope::One(ids.remove(0))),
_ => ResolvedScope::Scope(RepoScope::Many(ids)),
});
}
let mut unknown: Vec<String> = scope
.filter_names()
.into_iter()
.filter(|name| !known.contains(name))
.collect();
unknown.sort();
unknown.dedup();
if unknown.is_empty() {
Ok(ResolvedScope::Scope(scope))
} else {
Err(unknown)
}
}
pub fn clamp_max_results(requested: Option<usize>) -> usize {
requested
.unwrap_or(DEFAULT_MAX_RESULTS)
.clamp(MIN_MAX_RESULTS, MAX_MAX_RESULTS)
}
pub(crate) fn scope_or_error(
state: &AppState,
raw: Option<&str>,
) -> Result<ResolvedScope, Vec<String>> {
let known: Vec<String> = {
let mut registry = state.registry.lock().unwrap();
registry.list().iter().map(|r| r.id.clone()).collect()
};
resolve_scope(raw, &known)
}
pub(crate) fn unknown_repos_error(unknown: &[String]) -> Response {
error_response(
StatusCode::BAD_REQUEST,
format!("Unknown repository ids: {}", unknown.join(", ")),
)
}
pub(crate) fn scope_fields(scope: &ResolvedScope) -> (&'static str, usize) {
match scope {
ResolvedScope::NoRepositories => ("none", 0),
ResolvedScope::Scope(RepoScope::All) => ("all", 0),
ResolvedScope::Scope(RepoScope::One(_)) => ("one", 1),
ResolvedScope::Scope(RepoScope::Many(names)) => ("many", names.len()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn known() -> Vec<String> {
vec!["repo-a".to_string(), "repo-b".to_string()]
}
#[test]
fn all_expands_to_registered_repos() {
let unsorted = vec!["repo-b".to_string(), "repo-a".to_string()];
assert_eq!(
resolve_scope(None, &unsorted),
Ok(ResolvedScope::Scope(RepoScope::Many(vec![
"repo-a".to_string(),
"repo-b".to_string()
])))
);
assert_eq!(
resolve_scope(Some(""), &known()),
resolve_scope(None, &known())
);
assert_eq!(
resolve_scope(Some(" "), &known()),
resolve_scope(None, &known())
);
}
#[test]
fn sentinel_expands_to_registered_repos() {
for raw in ["all", "ALL", "All", "*"] {
assert_eq!(
resolve_scope(Some(raw), &known()),
Ok(ResolvedScope::Scope(RepoScope::Many(vec![
"repo-a".to_string(),
"repo-b".to_string()
]))),
"sentinel '{raw}' must expand to the registry"
);
}
}
#[test]
fn all_with_single_registered_repo_is_one() {
let single = vec!["repo-a".to_string()];
assert_eq!(
resolve_scope(None, &single),
Ok(ResolvedScope::Scope(RepoScope::One("repo-a".to_string())))
);
}
#[test]
fn all_with_empty_registry_is_no_repositories() {
assert_eq!(resolve_scope(None, &[]), Ok(ResolvedScope::NoRepositories));
}
#[test]
fn sentinel_with_empty_registry_is_no_repositories() {
for raw in ["all", "ALL", "*"] {
assert_eq!(
resolve_scope(Some(raw), &[]),
Ok(ResolvedScope::NoRepositories),
"sentinel '{raw}' over an empty registry"
);
}
}
#[test]
fn expansion_never_yields_an_empty_filter_list() {
for known in [
vec![],
vec!["repo-a".to_string()],
vec!["repo-b".to_string(), "repo-a".to_string()],
] {
match resolve_scope(None, &known).unwrap() {
ResolvedScope::NoRepositories => assert!(known.is_empty()),
ResolvedScope::Scope(scope) => {
assert!(!scope.filter_names().is_empty());
}
}
}
}
#[test]
fn sentinel_no_longer_skips_membership_check() {
let resolved = resolve_scope(Some("all,ghost"), &known()).unwrap();
let ResolvedScope::Scope(scope) = resolved else {
panic!("a non-empty registry must resolve to a scope");
};
let names = scope.filter_names();
assert_eq!(names, vec!["repo-a".to_string(), "repo-b".to_string()]);
assert!(!names.contains(&"ghost".to_string()));
}
#[test]
fn named_scopes_are_unchanged() {
assert_eq!(
resolve_scope(Some("repo-a"), &known()),
Ok(ResolvedScope::Scope(RepoScope::One("repo-a".to_string())))
);
assert_eq!(
resolve_scope(Some("repo-a,repo-b"), &known()),
Ok(ResolvedScope::Scope(RepoScope::Many(vec![
"repo-a".to_string(),
"repo-b".to_string()
])))
);
assert_eq!(
resolve_scope(Some(" repo-a , repo-a , repo-b "), &known()),
Ok(ResolvedScope::Scope(RepoScope::Many(vec![
"repo-a".to_string(),
"repo-b".to_string()
])))
);
}
#[test]
fn unknown_single_name_is_rejected() {
assert_eq!(
resolve_scope(Some("ghost"), &known()),
Err(vec!["ghost".to_string()])
);
}
#[test]
fn unknown_names_are_sorted_and_deduped() {
assert_eq!(
resolve_scope(Some("z,ghost,ghost"), &known()),
Err(vec!["ghost".to_string(), "z".to_string()])
);
}
#[test]
fn partially_unknown_list_is_rejected_whole() {
assert_eq!(
resolve_scope(Some("repo-a,ghost"), &known()),
Err(vec!["ghost".to_string()])
);
}
#[test]
fn empty_registry_still_rejects_named_unknowns() {
assert_eq!(
resolve_scope(Some("ghost"), &[]),
Err(vec!["ghost".to_string()])
);
}
#[test]
fn repo_names_are_case_sensitive() {
assert_eq!(
resolve_scope(Some("REPO-A"), &known()),
Err(vec!["REPO-A".to_string()])
);
}
#[test]
fn clamp_defaults_to_five() {
assert_eq!(clamp_max_results(None), 5);
assert_eq!(clamp_max_results(Some(5)), 5);
}
#[test]
fn clamp_floor_is_one() {
assert_eq!(clamp_max_results(Some(0)), 1);
}
#[test]
fn clamp_ceiling_is_hundred() {
assert_eq!(clamp_max_results(Some(99999)), 100);
assert_eq!(clamp_max_results(Some(100)), 100);
}
}