// Shared middleware helper (T-03) for the vicoproplus/moon_ua_parser
// library: pure functions only — no framework API, no IO.
//
// Contract (spec §3, degradation/forensics):
// - The framework adapter parses once per request via `@ua_parser.parse`
// and hands the resulting `Result[UaInfo, UaError]` to `assemble`.
// - Degradation: the result is `Err`, OR all three domain families equal
// the library's all-miss fallback literal (see `FALLBACK_FAMILY`). In
// both cases the middleware mounts the empty `UaInfo`
// (`empty_ua_info`, empty-string families — deliberately distinct from
// the library's `"Other"` fallback) and emits a forensics record
// (`ForensicsRecord`) instead of surfacing an error to the caller.
// - Otherwise the parsed `UaInfo` is mounted unchanged.
//
// Everything here is a total, side-effect-free function: errors never
// propagate to the caller and no logging/IO happens inside this module —
// the framework thin layer only adapts `ForensicsRecord` to its own log
// channel (PS-2).
///|
/// The family literal the library reports for a domain whose rules all
/// missed (the "empty fallback"). Verified against the current snapshot —
/// do not "fix" from memory:
///
/// - Library source: `moon_ua_parser_lib/src/ua_parser/engine.mbt:33-65`
/// (`other_browser` / `other_os` / `other_device` all emit
/// `family: "Other"` with every other field `None`).
/// - uap-core goldens: browser `uap-core/tests/test_ua.yaml:1020-1024`
/// (`SomethingWeNeverKnewExisted` -> family 'Other'); OS
/// `uap-core/tests/test_os.yaml:710-715` (same UA -> family 'Other');
/// device `uap-core/tests/test_device.yaml:398-401` (`NCSA_Mosaic/2.0
/// (Windows 3.1)` -> family 'Other'; uap-core has no device catch-all
/// rule, see moon_ua_parser_lib/tests/semantics/semantics.mbt Test 12
/// comment).
/// - Runtime-verified for all three domains at once by the
/// `SomethingWeNeverKnewExisted` test in `core_tests.mbt`.
///
/// Note: a bot UA such as Googlebot reports device family `"Spider"`
/// because a real device rule produced it (rule 625,
/// `uap-core/regexes.yaml:6217`) — that is a match, NOT this fallback,
/// and must not trigger degradation by itself.
pub const FALLBACK_FAMILY : String = "Other"
///|
/// Maximum length of `ForensicsRecord.ua_summary` in characters. Longer
/// user-agent strings are truncated to exactly this many leading
/// characters by `truncate_ua_summary`.
pub const UA_SUMMARY_MAX_LENGTH : Int = 64
///|
/// Degradation reason recorded when the parse result is `Err`. With the
/// precompiled rule set `parse` never returns `Err` today; the reason is
/// kept distinct so adapters can attribute future failure modes.
pub const REASON_PARSE_ERROR : String = "parse_error"
///|
/// Degradation reason recorded when the parse succeeded but all three
/// domain families equal the library's all-miss fallback literal
/// (`FALLBACK_FAMILY`) — i.e. nothing about the UA was actually learned.
pub const REASON_EMPTY_FALLBACK : String = "empty_fallback"
///|
/// A forensics record describing one degraded parse. Built only by this
/// module (`make_forensics`); the framework thin layer forwards it to its
/// log channel (PS-2) — this module performs no IO itself.
pub(all) struct ForensicsRecord {
/// The original user-agent string truncated to at most
/// `UA_SUMMARY_MAX_LENGTH` characters (forensics-safe size).
ua_summary : String
/// Machine-readable degradation reason (`REASON_PARSE_ERROR` or
/// `REASON_EMPTY_FALLBACK`); never empty.
reason : String
} derive(Eq, Debug)
///|
/// Outcome of `assemble`: what the middleware mounts for this request,
/// plus the optional forensics record to log.
///
/// No `derive`: the embedded `@ua_parser.UaInfo` implements neither
/// `Debug` nor a non-deprecated `Show` (the lib derives the deprecated
/// `Show`, engine baseline), so this struct is compared/asserted
/// field-by-field (its `ua_info` field supports `==` via `UaInfo`'s `Eq`).
pub(all) struct MiddlewareOutcome {
/// The effective UaInfo to mount: the parsed one when healthy, or
/// `empty_ua_info()` when degraded (empty-string families, `None`
/// versions — deliberately distinct from the library's `"Other"`
/// fallback values).
ua_info : @ua_parser.UaInfo
/// `true` iff the result was degraded (Err or empty fallback).
degraded : Bool
/// Forensics record iff degraded, `None` when healthy.
forensics : ForensicsRecord?
}
///|
/// The empty UaInfo mounted on degradation: every domain family is the
/// empty string (meaning "no parse result") and every version/brand/model
/// field is `None`. This is deliberately distinct from the library's
/// all-miss fallback (`FALLBACK_FAMILY = "Other"` per domain), so callers
/// can tell "middleware had nothing" from "the library matched nothing".
pub fn empty_ua_info() -> @ua_parser.UaInfo {
{
browser: {
family: "",
major: None,
minor: None,
patch: None,
patch_minor: None,
rule_index: None,
},
os: {
family: "",
major: None,
minor: None,
patch: None,
patch_minor: None,
rule_index: None,
},
device: { family: "", brand: None, model: None, rule_index: None },
}
}
///|
/// Degradation judgment for an already-parsed UaInfo: `true` iff ALL
/// THREE domain families equal the library's all-miss fallback literal
/// (`FALLBACK_FAMILY`). A single fallback domain (e.g. desktop UAs whose
/// device rules all miss) is NOT degraded — the other domains still carry
/// real information. Partial or exact family matches on any domain keep
/// the result healthy.
pub fn is_empty_fallback(info : @ua_parser.UaInfo) -> Bool {
info.browser.family == FALLBACK_FAMILY &&
info.os.family == FALLBACK_FAMILY &&
info.device.family == FALLBACK_FAMILY
}
///|
/// Degradation judgment for a parse result: `Some(reason)` when the
/// request must degrade — `REASON_PARSE_ERROR` for `Err(_)`,
/// `REASON_EMPTY_FALLBACK` when the parsed UaInfo satisfies
/// `is_empty_fallback` — and `None` when the parse is healthy. Pure and
/// total: never raises.
pub fn degradation_reason(
result : Result[@ua_parser.UaInfo, @ua_parser.UaError],
) -> String? {
match result {
Err(_) => Some(REASON_PARSE_ERROR)
Ok(info) => if is_empty_fallback(info) { Some(REASON_EMPTY_FALLBACK) } else { None }
}
}
///|
/// Degradation judgment convenience wrapper: `true` iff
/// `degradation_reason(result)` is `Some(_)`.
pub fn is_degraded(result : Result[@ua_parser.UaInfo, @ua_parser.UaError]) -> Bool {
degradation_reason(result) is Some(_)
}
///|
/// Truncate a user-agent string to at most `UA_SUMMARY_MAX_LENGTH`
/// characters (character-based, not bytes/code units). Strings at or
/// below the limit are returned unchanged.
pub fn truncate_ua_summary(ua : String) -> String {
if ua.length() <= UA_SUMMARY_MAX_LENGTH {
return ua
}
let buf = StringBuilder()
let mut count = 0
for ch in ua {
if count >= UA_SUMMARY_MAX_LENGTH {
break
}
buf.write_char(ch)
count = count + 1
}
buf.to_string()
}
///|
/// Construct a forensics record: `ua_summary` is the original UA truncated
/// to `UA_SUMMARY_MAX_LENGTH` characters, `reason` is the degradation
/// reason from `degradation_reason`. Pure data construction only — the
/// caller (framework thin layer) decides where the record goes.
pub fn make_forensics(ua : String, reason : String) -> ForensicsRecord {
{ ua_summary: truncate_ua_summary(ua), reason }
}
///|
/// Assemble the middleware result for one request (T-03 core entry
/// point). Consumes the library's parse result:
///
/// - `Err(_)`: mounts `empty_ua_info()` + forensics with reason
/// `REASON_PARSE_ERROR`. Never re-raises — the caller's response chain
/// continues (spec §3 degradation).
/// - `Ok(info)` where `is_empty_fallback(info)` (all three families are
/// the library's all-miss fallback): mounts `empty_ua_info()` +
/// forensics with reason `REASON_EMPTY_FALLBACK`.
/// - otherwise (`Ok(info)`): mounts `info` unchanged with no forensics.
pub fn assemble(
ua : String,
result : Result[@ua_parser.UaInfo, @ua_parser.UaError],
) -> MiddlewareOutcome {
match result {
Err(_) => assemble_error(ua)
Ok(info) =>
if is_empty_fallback(info) {
degraded_outcome(ua, REASON_EMPTY_FALLBACK)
} else {
{ ua_info: info, degraded: false, forensics: None }
}
}
}
///|
/// Err-branch of `assemble`, also usable directly: mounts `empty_ua_info()`
/// plus a `REASON_PARSE_ERROR` forensics record for `ua`, never raising.
/// Exposed separately because `@ua_parser.UaError` is a read-only error
/// type (external code cannot construct `Err` values to test with): an
/// adapter that handles the parse result itself can call this on its
/// `Err` arm, and unit tests pin the Err behavior through it.
pub fn assemble_error(ua : String) -> MiddlewareOutcome {
degraded_outcome(ua, REASON_PARSE_ERROR)
}
///|
/// Internal: build the degraded outcome (empty UaInfo + forensics record).
fn degraded_outcome(ua : String, reason : String) -> MiddlewareOutcome {
{
ua_info: empty_ua_info(),
degraded: true,
forensics: Some(make_forensics(ua, reason)),
}
}