use crate::{DialectHint, ios_like_literal_region};
pub const STRONG_SIGNAL: i32 = 3;
pub const MODERATE_SIGNAL: i32 = 2;
pub const WEAK_SIGNAL: i32 = 1;
const MIN_CONFIDENCE_SCORE: i32 = 3;
const MARGIN_FACTOR: i32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Test {
StartsWithAny(&'static [&'static str]),
IsAny(&'static [&'static str]),
EndsWithAny(&'static [&'static str]),
ContainsAny(&'static [&'static str]),
MinWords(usize),
WordIsNumber(usize),
WordIsDottedMask(usize),
WordContainsSlash(usize),
AnyWordIsDottedMask,
WordIsJunosStanza(usize),
InterfaceName(NameShape),
Not(&'static Test),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NameShape {
StartsWithAny(&'static [&'static str]),
ContainsSlash,
IosxeEthernet,
Iosxr,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Signal {
pub weight: i32,
pub tests: &'static [Test],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SignalTable {
pub name: &'static str,
pub signals: &'static [Signal],
}
impl Test {
fn holds(&self, line: &str, words: &[&str]) -> bool {
match self {
Test::StartsWithAny(prefixes) => prefixes.iter().any(|p| line.starts_with(p)),
Test::IsAny(literals) => literals.contains(&line),
Test::EndsWithAny(suffixes) => suffixes.iter().any(|s| line.ends_with(s)),
Test::ContainsAny(needles) => needles.iter().any(|n| line.contains(n)),
Test::MinWords(count) => words.len() >= *count,
Test::WordIsNumber(index) => {
words.get(*index).is_some_and(|w| w.parse::<u32>().is_ok())
}
Test::WordIsDottedMask(index) => {
words.get(*index).is_some_and(|w| looks_like_dotted_mask(w))
}
Test::WordContainsSlash(index) => words.get(*index).is_some_and(|w| w.contains('/')),
Test::AnyWordIsDottedMask => words.iter().any(|w| looks_like_dotted_mask(w)),
Test::WordIsJunosStanza(index) => {
words.get(*index).is_some_and(|w| is_junos_stanza_name(w))
}
Test::InterfaceName(shape) => match interface_name(line) {
Some(name) => shape.holds(name),
None => false,
},
Test::Not(test) => !test.holds(line, words),
}
}
}
impl NameShape {
fn holds(&self, name: &str) -> bool {
match self {
NameShape::StartsWithAny(prefixes) => prefixes.iter().any(|p| name.starts_with(p)),
NameShape::ContainsSlash => name.contains('/'),
NameShape::IosxeEthernet => is_iosxe_ethernet_name(name),
NameShape::Iosxr => is_iosxr_interface_name(name),
}
}
}
impl Signal {
fn matches(&self, line: &str, words: &[&str]) -> bool {
self.tests.iter().all(|test| test.holds(line, words))
}
}
pub fn detect_dialect(input: &str, dialects: &[SignalTable]) -> DialectHint {
let lines: Vec<&str> = input.lines().map(str::trim).collect();
let mut scores = vec![0i32; dialects.len()];
for (&line, scorable) in lines.iter().zip(scorable_lines(&lines)) {
if !scorable {
continue;
}
let words: Vec<&str> = line.split_whitespace().collect();
for (score, table) in scores.iter_mut().zip(dialects) {
for signal in table.signals {
if signal.matches(line, &words) {
*score += signal.weight;
}
}
}
}
let mut ranked: Vec<(&str, i32)> = dialects
.iter()
.map(|table| table.name)
.zip(scores)
.collect();
ranked.sort_by_key(|candidate| std::cmp::Reverse(candidate.1));
let Some(&(best_name, best_score)) = ranked.first() else {
return DialectHint::Generic;
};
let second_score = ranked.get(1).map_or(0, |candidate| candidate.1);
if best_score < MIN_CONFIDENCE_SCORE {
return DialectHint::Generic;
}
if best_score < second_score * MARGIN_FACTOR {
return DialectHint::Generic;
}
DialectHint::Named(best_name.to_string())
}
fn interface_name(line: &str) -> Option<&str> {
line.starts_with("interface ")
.then(|| line.trim_start_matches("interface "))
}
fn scorable_lines(lines: &[&str]) -> Vec<bool> {
let mut scorable: Vec<bool> = lines
.iter()
.map(|line| !line.is_empty() && !is_comment(line))
.collect();
let mut idx = 0usize;
while idx < lines.len() {
match banner_body_end(lines, idx) {
Some(end) => {
scorable[idx + 1..=end].fill(false);
idx = end + 1;
}
None => idx += 1,
}
}
scorable
}
fn is_comment(line: &str) -> bool {
line.starts_with('!') || line.starts_with('#')
}
fn banner_body_end(lines: &[&str], idx: usize) -> Option<usize> {
let terminator = ios_like_literal_region(lines[idx])?;
lines[idx + 1..]
.iter()
.position(|line| terminator.terminates(line))
.map(|offset| idx + 1 + offset)
}
fn is_junos_stanza_name(name: &str) -> bool {
matches!(
name,
"interfaces"
| "protocols"
| "policy-options"
| "routing-options"
| "forwarding-options"
| "class-of-service"
| "system"
| "security"
| "firewall"
| "vlans"
| "chassis"
| "snmp"
| "applications"
| "groups"
| "routing-instances"
)
}
fn is_iosxe_ethernet_name(name: &str) -> bool {
const PREFIXES: [&str; 9] = [
"AppGigabitEthernet",
"FastEthernet",
"FiveGigabitEthernet",
"FortyGigabitEthernet",
"GigabitEthernet",
"HundredGigE",
"TenGigabitEthernet",
"TwentyFiveGigE",
"TwoGigabitEthernet",
];
PREFIXES.iter().any(|prefix| name.starts_with(prefix))
}
fn is_iosxr_interface_name(name: &str) -> bool {
const XR_ONLY_PREFIXES: [&str; 5] = ["bundle-ether", "bvi", "mgmteth", "pw-ether", "tunnel-ip"];
let lower = name.to_ascii_lowercase();
if XR_ONLY_PREFIXES
.iter()
.any(|prefix| lower.starts_with(prefix))
{
return true;
}
has_four_part_slot(name) && !is_other_dialect_interface_name(&lower)
}
const OTHER_DIALECT_INTERFACE_PREFIXES: &[&str] = &[
"appgigabitethernet",
"bdi",
"ethernet",
"fabric",
"fastethernet",
"fivegigabitethernet",
"fortygigabitethernet",
"fourhundredgig",
"gigabitethernet",
"hundredgigabitethernet",
"hundredgige",
"loopback",
"management",
"mgmt",
"nve",
"port-channel",
"serial",
"tengigabitethernet",
"tunnel",
"twentyfivegige",
"twogigabitethernet",
"twohundredgig",
"vlan",
"vxlan",
];
fn is_other_dialect_interface_name(lower: &str) -> bool {
OTHER_DIALECT_INTERFACE_PREFIXES
.iter()
.any(|prefix| lower.starts_with(prefix))
}
fn has_four_part_slot(name: &str) -> bool {
let base = name.split('.').next().unwrap_or(name);
let slots: Vec<&str> = base.split('/').collect();
slots.len() == 4
&& slots[1..].iter().all(|slot| slot.parse::<u32>().is_ok())
&& slots[0]
.trim_start_matches(|c: char| !c.is_ascii_digit())
.parse::<u32>()
.is_ok()
}
fn looks_like_dotted_mask(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
parts.len() == 4 && parts.iter().all(|p| p.parse::<u8>().is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scorable_lines_does_not_reopen_a_banner_inside_a_banner_body() {
let lines = [
"banner motd ^C",
"banner set by netops on 2026-01-01",
"^C",
"interface GigabitEthernet1/0/1",
" standby 1 ip 192.0.2.254",
];
assert_eq!(scorable_lines(&lines), [true, false, false, true, true]);
}
#[test]
fn an_empty_table_set_detects_nothing() {
assert_eq!(detect_dialect("feature bgp\n", &[]), DialectHint::Generic);
}
#[test]
fn a_lone_table_needs_no_runner_up() {
const SIGNALS: &[Signal] = &[Signal {
weight: STRONG_SIGNAL,
tests: &[Test::StartsWithAny(&["feature "])],
}];
let tables = [SignalTable {
name: "nxos",
signals: SIGNALS,
}];
assert_eq!(
detect_dialect("feature bgp\n", &tables),
DialectHint::Named("nxos".into()),
);
}
#[test]
fn interface_name_strips_every_leading_keyword() {
assert_eq!(interface_name("interface Ethernet1"), Some("Ethernet1"));
assert_eq!(
interface_name("interface interface Ethernet1"),
Some("Ethernet1"),
);
assert_eq!(interface_name("ip address 10.0.0.1/24"), None);
}
}