use std::collections::{BTreeMap, BTreeSet, HashSet};
use crate::links::{EXTERNAL_REF_KIND, external_ref_target};
use crate::model::{Node, NodeKind};
use crate::store::{Store, StoreError};
use crate::workspace::{Workspace, WorkspaceError, parse_qualified};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ProjectRole {
Root,
Intermediate,
Leaf,
Isolated,
}
impl ProjectRole {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Root => "root",
Self::Intermediate => "intermediate",
Self::Leaf => "leaf",
Self::Isolated => "isolated",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ProjectGraph {
parents: BTreeMap<String, BTreeSet<String>>,
inbound_edges: BTreeMap<String, usize>,
children: BTreeMap<String, usize>,
has_any_external_refs: bool,
}
impl ProjectGraph {
#[must_use]
pub fn parents_of(&self, name: &str) -> &BTreeSet<String> {
static NONE: std::sync::LazyLock<BTreeSet<String>> =
std::sync::LazyLock::new(BTreeSet::new);
self.parents.get(name).unwrap_or(&NONE)
}
#[must_use]
pub fn inbound_edges_of(&self, name: &str) -> usize {
self.inbound_edges.get(name).copied().unwrap_or(0)
}
#[must_use]
pub fn children_of(&self, name: &str) -> usize {
self.children.get(name).copied().unwrap_or(0)
}
#[must_use]
pub fn role_of(&self, name: &str) -> ProjectRole {
let has_parents = !self.parents_of(name).is_empty();
match (has_parents, self.children_of(name) > 0) {
(false, true) => ProjectRole::Root,
(true, true) => ProjectRole::Intermediate,
(true, false) => ProjectRole::Leaf,
(false, false) => ProjectRole::Isolated,
}
}
#[must_use]
pub fn has_any_external_refs(&self) -> bool {
self.has_any_external_refs
}
#[must_use]
pub fn busiest_hub(&self) -> Option<String> {
self.inbound_edges
.iter()
.max_by_key(|(_, count)| **count)
.map(|(p, _)| p.clone())
}
}
pub fn project_graph(ws: &Workspace, names: &[String]) -> Result<ProjectGraph, WorkspaceError> {
let hosted: HashSet<&str> = names.iter().map(String::as_str).collect();
let mut graph = ProjectGraph::default();
for name in names {
for node in ws.with_store(Some(name), external_ref_nodes)?? {
graph.has_any_external_refs = true;
let Some(qualified) = external_ref_target(&node) else {
continue;
};
let Some((project, _)) = parse_qualified(&qualified) else {
continue;
};
if !hosted.contains(project) {
continue;
}
*graph.inbound_edges.entry(project.to_owned()).or_default() += 1;
if project != name.as_str()
&& graph
.parents
.entry(name.clone())
.or_default()
.insert(project.to_owned())
{
*graph.children.entry(project.to_owned()).or_default() += 1;
}
}
}
Ok(graph)
}
fn external_ref_nodes(store: &Store) -> Result<Vec<Node>, StoreError> {
let mut out = Vec::new();
for node in store.nodes_by_kind(&NodeKind::Other(EXTERNAL_REF_KIND.to_owned()))? {
for edge in store.edges_to(&node.key)? {
if matches!(
edge.provenance,
crate::provenance::Provenance::Inferred | crate::provenance::Provenance::Authored
) {
out.push(node.clone());
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::{ProjectRole, project_graph};
use crate::links::{external_ref_key, external_ref_node};
use crate::model::{Edge, EdgeKind, Node, NodeKind};
use crate::store::Store;
use crate::workspace::Workspace;
fn repo(own: &str, targets: &[&str]) -> Store {
let store = Store::open_in_memory().expect("store");
let src_key = format!("cfgkey:cfg.toml#{own}");
store
.upsert_node(&Node::new(
src_key.clone(),
NodeKind::Other("config_key".to_owned()),
own.to_owned(),
))
.expect("src node");
for target in targets {
let node = external_ref_node(target);
store.upsert_node(&node).expect("node");
let edge = Edge::authored(
src_key.clone(),
external_ref_key(target),
EdgeKind::References,
);
store.insert_edge(&edge).expect("edge");
}
store
}
fn names(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn a_chain_has_a_root_a_sub_hub_and_leaves() {
let ws = Workspace::from_stores([
(
"infra1".to_owned(),
repo("a", &["chart::cfgkey:cfg.toml#c"]),
),
(
"infra2".to_owned(),
repo("b", &["chart::cfgkey:cfg.toml#c"]),
),
("chart".to_owned(), repo("c", &["app::cfgkey:cfg.toml#d"])),
("app".to_owned(), repo("d", &[])),
]);
let g = project_graph(&ws, &names(&["infra1", "infra2", "chart", "app"])).expect("graph");
assert_eq!(g.role_of("app"), ProjectRole::Root, "nothing is downstream");
assert_eq!(
g.role_of("chart"),
ProjectRole::Intermediate,
"a spoke of app AND the hub of both infra repos"
);
assert_eq!(g.role_of("infra1"), ProjectRole::Leaf);
assert_eq!(g.role_of("infra2"), ProjectRole::Leaf);
assert_eq!(
g.parents_of("chart").iter().collect::<Vec<_>>(),
["app"],
"the sub-hub names its own hub"
);
assert!(g.parents_of("app").is_empty());
assert_eq!(g.busiest_hub().as_deref(), Some("chart"));
}
#[test]
fn a_project_with_no_links_is_isolated() {
let ws = Workspace::from_stores([
("solo".to_owned(), repo("a", &[])),
("other".to_owned(), repo("b", &[])),
]);
let g = project_graph(&ws, &names(&["solo", "other"])).expect("graph");
assert_eq!(g.role_of("solo"), ProjectRole::Isolated);
assert_eq!(g.busiest_hub(), None, "nothing references anything hosted");
assert!(!g.has_any_external_refs());
}
#[test]
fn a_dangling_link_is_still_a_link() {
let ws = Workspace::from_stores([(
"spoke".to_owned(),
repo("a", &["ghost::cfgkey:cfg.toml#z"]),
)]);
let g = project_graph(&ws, &names(&["spoke"])).expect("graph");
assert!(
g.has_any_external_refs(),
"the ref exists even though its target is not hosted"
);
assert_eq!(g.busiest_hub(), None, "nothing hosted is referenced");
assert_eq!(g.role_of("spoke"), ProjectRole::Isolated);
}
#[test]
fn many_links_from_one_repo_are_one_dependent_but_many_edges() {
let ws = Workspace::from_stores([
(
"spoke".to_owned(),
repo("a", &["hub::cfgkey:cfg.toml#x", "hub::cfgkey:cfg.toml#y"]),
),
("hub".to_owned(), repo("h", &[])),
]);
let g = project_graph(&ws, &names(&["spoke", "hub"])).expect("graph");
assert_eq!(g.children_of("hub"), 1, "one dependent project");
assert_eq!(g.inbound_edges_of("hub"), 2, "two edges");
}
#[test]
fn a_self_reference_is_not_a_dependency() {
let ws =
Workspace::from_stores([("solo".to_owned(), repo("a", &["solo::cfgkey:cfg.toml#a"]))]);
let g = project_graph(&ws, &names(&["solo"])).expect("graph");
assert!(g.parents_of("solo").is_empty());
assert_eq!(g.children_of("solo"), 0);
assert_eq!(g.role_of("solo"), ProjectRole::Isolated);
assert_eq!(g.inbound_edges_of("solo"), 1);
}
}