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)
}
#[must_use]
pub fn match_against_hub(
spoke: &[ConfigKey],
hub: &[ConfigKey],
) -> (Vec<KeyMatch>, Vec<ConfigKey>) {
use rto_graph::normalize_config_key as normalize;
use std::collections::HashMap;
let mut by_full: HashMap<String, &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_leaf
.entry(last_token(&n).to_owned())
.or_default()
.push(h);
}
let mut matches = Vec::new();
let mut orphans = Vec::new();
for s in spoke {
let n = normalize(&s.key);
if let Some(h) = by_full.get(&n) {
let conf = if h.value == s.value { 0.98 } else { 0.9 };
matches.push(KeyMatch {
spoke_key: s.key.clone(),
spoke_file: s.file.clone(),
hub_key: h.key.clone(),
hub_file: h.file.clone(),
confidence: conf,
});
} else if let Some([h]) = by_leaf.get(last_token(&n)).map(Vec::as_slice) {
matches.push(KeyMatch {
spoke_key: s.key.clone(),
spoke_file: s.file.clone(),
hub_key: h.key.clone(),
hub_file: h.file.clone(),
confidence: 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(),
}
}
#[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 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));
}
}