use rto_graph::{Edge, EdgeKind, FactSet, LINKS_REF, external_ref_node};
pub use rto_graph::ConfigKey;
#[derive(Debug, Clone, serde::Serialize)]
pub struct KeyMatch {
pub spoke_key: String,
pub spoke_file: String,
pub hub_key: String,
pub hub_file: String,
pub confidence: f64,
}
fn last_token(norm: &str) -> &str {
norm.rsplit('.').next().unwrap_or(norm)
}
fn unambiguous<'a>(cands: &[&'a ConfigKey]) -> Option<&'a ConfigKey> {
let first = *cands.first()?;
cands.iter().all(|c| c.key == first.key).then_some(first)
}
#[must_use]
pub fn match_against_hub(
spoke: &[ConfigKey],
hub: &[ConfigKey],
) -> (Vec<KeyMatch>, Vec<ConfigKey>) {
use rto_graph::{canonicalize_config_key as canonicalize, normalize_config_key as normalize};
use std::collections::HashMap;
let mut by_full: HashMap<String, &ConfigKey> = HashMap::new();
let mut by_canon: HashMap<String, Vec<&ConfigKey>> = HashMap::new();
let mut by_leaf: HashMap<String, Vec<&ConfigKey>> = HashMap::new();
for h in hub {
let n = normalize(&h.key);
by_full.entry(n.clone()).or_insert(h);
by_canon.entry(canonicalize(&h.key)).or_default().push(h);
by_leaf
.entry(last_token(&n).to_owned())
.or_default()
.push(h);
}
let mut matches = Vec::new();
let mut orphans = Vec::new();
let hit = |s: &ConfigKey, h: &ConfigKey, confidence: f64| KeyMatch {
spoke_key: s.key.clone(),
spoke_file: s.file.clone(),
hub_key: h.key.clone(),
hub_file: h.file.clone(),
confidence,
};
let values_agree =
|h: &ConfigKey, s: &ConfigKey| h.value_known && s.value_known && h.value == s.value;
for s in spoke {
let n = normalize(&s.key);
if let Some(h) = by_full.get(&n) {
matches.push(hit(s, h, if values_agree(h, s) { 0.98 } else { 0.9 }));
} else if let Some(h) = by_canon
.get(&canonicalize(&s.key))
.and_then(|c| unambiguous(c))
{
matches.push(hit(s, h, if values_agree(h, s) { 0.95 } else { 0.85 }));
} else if let Some([h]) = by_leaf.get(last_token(&n)).map(Vec::as_slice) {
matches.push(hit(s, h, 0.55));
} else {
orphans.push(s.clone());
}
}
(matches, orphans)
}
#[must_use]
pub fn link_facts(hub_project: &str, matches: &[KeyMatch]) -> FactSet {
let mut facts = FactSet::new();
for m in matches {
let spoke_node = format!("cfgkey:{}#{}", m.spoke_file, m.spoke_key);
let qualified = format!("{hub_project}::cfgkey:{}#{}", m.hub_file, m.hub_key);
let target = external_ref_node(&qualified);
let mut edge = Edge::inferred(
spoke_node,
target.key.clone(),
EdgeKind::References,
m.confidence,
);
edge.src_ref = Some(LINKS_REF.to_owned());
facts = facts.with_node(target).with_edge(edge);
}
facts
}
#[cfg(test)]
mod tests {
use super::*;
fn ck(key: &str, value: &str) -> ConfigKey {
ConfigKey {
file: "f".into(),
key: key.into(),
value: value.into(),
value_known: true,
}
}
fn ck_struct(key: &str) -> ConfigKey {
ConfigKey {
file: "app/config.rs".into(),
key: key.into(),
value: String::new(),
value_known: false,
}
}
#[test]
fn value_agreement_lifts_confidence_and_ambiguous_leaf_is_skipped() {
let hub = vec![ck("serve.addr", "0.0.0.0:8443"), ck("db.addr", "x")];
let spoke = vec![
ck("SERVE_ADDR", "0.0.0.0:8443"), ck("addr", "y"), ];
let (m, orphans) = match_against_hub(&spoke, &hub);
assert_eq!(m.len(), 1);
assert_eq!(m[0].hub_key, "serve.addr");
assert_eq!(m[0].hub_file, "f");
assert!(m[0].confidence >= 0.95);
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].key, "addr");
}
#[test]
fn camel_snake_kebab_bridge_the_naming_convention_gap() {
let hub = vec![
ck("zerobus.server_endpoint", "grpc://z:443"),
ck("zerobus.workspace_url", "https://w"),
];
let spoke = vec![
ck("zerobus.serverEndpoint", "grpc://z:443"), ck("zerobus.workspace-url", "https://w"), ];
let (m, orphans) = match_against_hub(&spoke, &hub);
assert!(orphans.is_empty(), "both bridge the gap: {orphans:?}");
assert_eq!(m.len(), 2);
let camel = m
.iter()
.find(|k| k.spoke_key == "zerobus.serverEndpoint")
.unwrap();
assert_eq!(camel.hub_key, "zerobus.server_endpoint");
assert!(
camel.confidence >= 0.9,
"value agreement lifts it: {camel:?}"
);
let kebab = m
.iter()
.find(|k| k.spoke_key == "zerobus.workspace-url")
.unwrap();
assert_eq!(kebab.hub_key, "zerobus.workspace_url");
}
#[test]
fn exact_match_takes_precedence_over_a_canonical_one() {
let hub = vec![
ck("zerobus.server_endpoint", "grpc://z:443"),
ck("zerobus.serverEndpoint", "grpc://other:443"),
];
let spoke = vec![ck("zerobus.server_endpoint", "grpc://z:443")];
let (m, orphans) = match_against_hub(&spoke, &hub);
assert!(orphans.is_empty());
assert_eq!(m.len(), 1);
assert_eq!(
m[0].hub_key, "zerobus.server_endpoint",
"exact spelling wins"
);
assert!(
m[0].confidence >= 0.98,
"exact + value agreement: {:?}",
m[0]
);
}
#[test]
fn a_hub_absent_key_is_still_reported_as_drift() {
let hub = vec![ck("zerobus.server_endpoint", "grpc://z:443")];
let spoke = vec![ck(
"zerobus.delta_table_properties.delta.enableChangeDataFeed",
"true",
)];
let (m, orphans) = match_against_hub(&spoke, &hub);
assert!(m.is_empty(), "no fabricated match: {m:?}");
assert_eq!(orphans.len(), 1);
assert_eq!(
orphans[0].key,
"zerobus.delta_table_properties.delta.enableChangeDataFeed"
);
}
#[test]
fn an_ambiguous_canonical_collision_produces_no_wrong_link() {
let hub = vec![
ck("zerobus.server_endpoint", "grpc://snake"),
ck("zerobus.server-endpoint", "grpc://kebab"),
];
let spoke = vec![ck("zerobus.serverEndpoint", "grpc://camel")];
let (m, orphans) = match_against_hub(&spoke, &hub);
assert!(m.is_empty(), "ambiguous collision must not link: {m:?}");
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].key, "zerobus.serverEndpoint");
}
#[test]
fn struct_derived_hub_keys_match_spoke_infra_keys() {
let hub = vec![
ck_struct("zerobus.server_endpoint"),
ck_struct("zerobus.workspace_url"),
];
let spoke = vec![
ck("zerobus.serverEndpoint", "grpc://z:443"), ck("zerobus.workspace_url", "https://w"), ];
let (m, orphans) = match_against_hub(&spoke, &hub);
assert!(orphans.is_empty(), "both link to struct keys: {orphans:?}");
assert_eq!(m.len(), 2);
assert_eq!(
m.iter()
.find(|k| k.spoke_key == "zerobus.serverEndpoint")
.unwrap()
.hub_key,
"zerobus.server_endpoint"
);
}
#[test]
fn struct_derived_key_gets_no_false_value_agreement_bump() {
let hub = vec![ck_struct("serve.addr")];
let spoke_empty = vec![ck("serve.addr", "")]; let (m, orphans) = match_against_hub(&spoke_empty, &hub);
assert!(orphans.is_empty());
assert_eq!(m.len(), 1);
assert!(
(m[0].confidence - 0.9).abs() < f64::EPSILON,
"name-only tier, no value-agreement bump: {:?}",
m[0]
);
let hub_file = vec![ck("serve.addr", "")];
let (m2, _) = match_against_hub(&spoke_empty, &hub_file);
assert!(
(m2[0].confidence - 0.98).abs() < f64::EPSILON,
"real empty values still agree: {:?}",
m2[0]
);
}
#[test]
fn link_facts_builds_an_external_ref_and_inferred_edge_per_match() {
let m = KeyMatch {
spoke_key: "SERVE_ADDR".into(),
spoke_file: "prod.env".into(),
hub_key: "serve.addr".into(),
hub_file: "config.toml".into(),
confidence: 0.9,
};
let facts = link_facts("app", std::slice::from_ref(&m));
assert_eq!(facts.nodes.len(), 1);
assert_eq!(
facts.nodes[0].key,
"extref:app::cfgkey:config.toml#serve.addr"
);
assert_eq!(facts.edges.len(), 1);
let e = &facts.edges[0];
assert_eq!(e.src, "cfgkey:prod.env#SERVE_ADDR");
assert_eq!(e.dst, "extref:app::cfgkey:config.toml#serve.addr");
assert_eq!(e.confidence, Some(0.9));
assert_eq!(e.src_ref.as_deref(), Some(LINKS_REF));
}
}