using RackPeek.Domain.Resources; using RackPeek.Domain.Resources.Services; using RackPeek.Domain.Resources.SystemResources; namespace Shared.Rcl.Services; public record SearchResult( string Name, string Kind, string Url, string MatchedField, string MatchedValue, int Score ); /// /// Ranks a set of resources against a free-text query and returns the top N matches. /// /// Scoring per resource: each searchable field is scored independently with a /// weight (name > ip > tag > label) and a shape modifier (equality > /// prefix > substring > subsequence). The resource takes the best-scoring /// single field, so a strong name match always beats a weak label match. /// public static class GlobalSearchService { private const int _defaultMax = 8; private const int _weightName = 100; private const int _weightIp = 50; private const int _weightTag = 25; private const int _weightLabel = 10; public static IReadOnlyList Search( IEnumerable resources, string query, int max = _defaultMax) { if (string.IsNullOrWhiteSpace(query)) return []; var q = query.Trim().ToLowerInvariant(); var results = new List(); foreach (Resource r in resources) { (string Field, string Value, int Score)? best = BestMatch(r, q); if (best is null) continue; results.Add(new SearchResult( Name: r.Name, Kind: r.Kind, Url: Resource.GetResourceUrl(r.Kind, r.Name), MatchedField: best.Value.Field, MatchedValue: best.Value.Value, Score: best.Value.Score)); } return results .OrderByDescending(s => s.Score) .ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase) .Take(max) .ToList(); } private static (string Field, string Value, int Score)? BestMatch(Resource r, string q) { (string Field, string Value, int Score)? best = null; void Consider(string field, string? value, int weight) { if (string.IsNullOrEmpty(value)) return; var score = ScoreField(value, q, weight); if (score <= 0) return; if (best is null || score > best.Value.Score) { best = (field, value, score); } } Consider("name", r.Name, _weightName); var ip = GetIp(r); Consider("ip", ip, _weightIp); if (r.Tags is not null) { foreach (var tag in r.Tags) { Consider("tag", tag, _weightTag); } } if (r.Labels is not null) { foreach (KeyValuePair kvp in r.Labels) { // Match against the value (label keys are usually category names, // values hold the meaningful data — IPs, hostnames, etc). Consider("label", $"{kvp.Key}: {kvp.Value}", _weightLabel); } } return best; } private static string? GetIp(Resource r) => r switch { SystemResource s => s.Ip, Service svc => svc.Network?.Ip, _ => null }; private static int ScoreField(string? value, string lowerQuery, int weight) { if (string.IsNullOrEmpty(value)) return 0; var v = value.ToLowerInvariant(); if (v == lowerQuery) return weight + 20; if (v.StartsWith(lowerQuery, StringComparison.Ordinal)) return weight + 10; if (v.Contains(lowerQuery, StringComparison.Ordinal)) return weight; return IsSubsequence(lowerQuery, v) ? weight - 10 : 0; } private static bool IsSubsequence(string needle, string haystack) { var i = 0; foreach (var c in haystack) { if (i < needle.Length && c == needle[i]) i++; if (i == needle.Length) return true; } return i == needle.Length; } }