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 = knot::cli_tools::DEFAULT_MAX_RESULTS;
pub(crate) const MIN_MAX_RESULTS: usize = 1;
pub(crate) const MAX_MAX_RESULTS: usize = knot::cli_tools::MAX_RESULTS_CEILING;
pub(crate) const DEFAULT_MAX_TARGETS: usize = knot::db::graph::DEFAULT_MAX_TARGETS;
pub(crate) const MIN_MAX_TARGETS: usize = 1;
pub(crate) const MAX_MAX_TARGETS: usize = knot::db::graph::MAX_TARGETS_CEILING;
fn clamp_in_range(requested: Option<usize>, default: usize, min: usize, max: usize) -> usize {
requested.unwrap_or(default).clamp(min, max)
}
#[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 {
clamp_in_range(
requested,
DEFAULT_MAX_RESULTS,
MIN_MAX_RESULTS,
MAX_MAX_RESULTS,
)
}
pub fn clamp_max_targets(requested: Option<usize>) -> usize {
clamp_in_range(
requested,
DEFAULT_MAX_TARGETS,
MIN_MAX_TARGETS,
MAX_MAX_TARGETS,
)
}
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);
}
#[test]
fn result_bounds_mirror_knot() {
assert_eq!(DEFAULT_MAX_RESULTS, knot::cli_tools::DEFAULT_MAX_RESULTS);
assert_eq!(MAX_MAX_RESULTS, knot::cli_tools::MAX_RESULTS_CEILING);
}
#[test]
fn rest_and_mcp_agree_on_the_clamp() {
for requested in [0usize, 1, 5, 20, 99, 100, 1000, usize::MAX] {
let rest = clamp_max_results(Some(requested));
let mcp = knot::cli_tools::resolve_max_results(requested).value;
assert_eq!(rest, mcp, "REST and /mcp disagree for {requested}");
}
}
#[test]
fn clamp_acceptance_matrix() {
assert_eq!(clamp_max_results(Some(0)), 1);
assert_eq!(clamp_max_results(Some(5)), 5);
assert_eq!(clamp_max_results(Some(1000)), 100);
assert_eq!(clamp_max_results(None), DEFAULT_MAX_RESULTS);
}
#[test]
fn knot_clamp_notice_mentions_ceiling_and_no_pagination() {
let notice = knot::cli_tools::resolve_max_results(1000)
.notice()
.expect("a clamped request must carry a notice");
assert!(
notice.contains("100"),
"notice must state the ceiling: {notice}"
);
assert!(
notice.to_lowercase().contains("no pagination"),
"notice must state the no-pagination rule: {notice}"
);
assert!(
knot::cli_tools::resolve_max_results(100).notice().is_none(),
"an in-range request must not carry a notice"
);
}
#[test]
fn target_bounds_mirror_knot() {
assert_eq!(DEFAULT_MAX_TARGETS, knot::db::graph::DEFAULT_MAX_TARGETS);
assert_eq!(MAX_MAX_TARGETS, knot::db::graph::MAX_TARGETS_CEILING);
}
#[test]
fn clamp_max_targets_defaults_to_knot_default() {
assert_eq!(clamp_max_targets(None), DEFAULT_MAX_TARGETS);
assert_eq!(
clamp_max_targets(Some(DEFAULT_MAX_TARGETS)),
DEFAULT_MAX_TARGETS
);
}
#[test]
fn clamp_max_targets_floor_is_one() {
assert_eq!(clamp_max_targets(Some(0)), MIN_MAX_TARGETS);
assert_eq!(clamp_max_targets(Some(1)), 1);
}
#[test]
fn clamp_max_targets_ceiling_is_knot_ceiling() {
assert_eq!(clamp_max_targets(Some(usize::MAX)), MAX_MAX_TARGETS);
assert_eq!(clamp_max_targets(Some(MAX_MAX_TARGETS)), MAX_MAX_TARGETS);
assert_eq!(
clamp_max_targets(Some(MAX_MAX_TARGETS + 1)),
MAX_MAX_TARGETS
);
}
#[test]
fn clamp_max_targets_honours_in_range_values() {
for requested in [2usize, 25, 100, 499] {
assert_eq!(clamp_max_targets(Some(requested)), requested);
}
}
}