use std::collections::{BTreeMap, BTreeSet};
use super::model::{CrateGraph, CrateNode, DepKind, UnitGraph, UnitNode};
use crate::release::discovered::{CargoMetadata, Dependency, DiscoveredCrate};
use crate::release::metadata::CrateMetadata;
pub(crate) fn build_crate_graph(
document: &CargoMetadata,
crates: &[DiscoveredCrate],
) -> CrateGraph {
let publishable: BTreeMap<String, String> = crates
.iter()
.filter_map(|c| match &c.metadata {
CrateMetadata::Valid(md) if md.publish => {
md.release_unit.clone().map(|unit| (c.name.clone(), unit))
}
_ => None,
})
.collect();
let deps_by_pkg: BTreeMap<String, &Vec<Dependency>> = document
.packages
.iter()
.map(|p| (p.name.clone(), &p.dependencies))
.collect();
let mut nodes = BTreeMap::new();
for c in crates {
let unit = match &c.metadata {
CrateMetadata::Valid(md) if md.publish => match &md.release_unit {
Some(u) => u.clone(),
None => continue,
},
_ => continue,
};
let mut dependencies = BTreeMap::new();
if let Some(deps) = deps_by_pkg.get(&c.name) {
for dep in deps.iter() {
let dep_name = dep.rename.as_ref().unwrap_or(&dep.name);
if !dep_name.starts_with("arcature") {
continue;
}
if !publishable.contains_key(dep_name) {
continue;
}
if dep_name == &c.name {
continue;
}
let kind = match dep.kind.as_deref() {
Some("dev") => DepKind::Dev,
_ => DepKind::Production,
};
let req = dep.req.as_deref().unwrap_or("*");
let is_path_only = req == "*" && dep.path.is_some() && kind == DepKind::Dev;
if is_path_only {
continue;
}
dependencies.insert(dep_name.clone(), kind);
}
}
nodes.insert(
c.name.clone(),
CrateNode {
name: c.name.clone(),
release_unit: unit,
dependencies,
},
);
}
CrateGraph { nodes }
}
pub(crate) fn build_unit_graph(crate_graph: &CrateGraph) -> UnitGraph {
let mut unit_members: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (name, node) in &crate_graph.nodes {
unit_members
.entry(node.release_unit.clone())
.or_default()
.insert(name.clone());
}
let mut nodes = BTreeMap::new();
for (unit, members) in &unit_members {
let mut dependencies: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for member in members {
let crate_node = &crate_graph.nodes[member];
for (dep_crate, dep_kind) in &crate_node.dependencies {
if *dep_kind != DepKind::Production {
continue;
}
let dep_unit = &crate_graph.nodes[dep_crate].release_unit;
if dep_unit == unit {
continue;
}
dependencies
.entry(dep_unit.clone())
.or_default()
.insert(dep_crate.clone());
}
}
nodes.insert(
unit.clone(),
UnitNode {
name: unit.clone(),
members: members.clone(),
dependencies,
},
);
}
UnitGraph { nodes }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::release::discovered::Target;
use crate::release::metadata::ReleaseMetadata;
use std::path::PathBuf;
fn package(
name: &str,
metadata: Option<&str>,
deps: &[(&str, Option<&str>, &str, Option<&str>)],
) -> crate::release::discovered::Package {
crate::release::discovered::Package {
name: name.to_string(),
version: None,
manifest_path: format!("/repo/crates/{name}/Cargo.toml"),
publish: None,
metadata: metadata.map(|m| serde_json::from_str(m).unwrap()),
targets: vec![Target {
kind: vec!["lib".to_string()],
}],
dependencies: deps
.iter()
.map(|(n, kind, req, path)| Dependency {
name: (*n).to_string(),
kind: kind.map(|s| s.to_string()),
optional: Some(false),
req: Some((*req).to_string()),
path: path.map(|s| s.to_string()),
rename: None,
})
.collect(),
}
}
fn discovered(crate_name: &str, md: ReleaseMetadata) -> DiscoveredCrate {
DiscoveredCrate {
name: crate_name.to_string(),
manifest_path: PathBuf::from(format!("/repo/crates/{crate_name}/Cargo.toml")),
publishable: true,
proc_macro: false,
metadata: CrateMetadata::Valid(md),
}
}
fn md(publish: bool, unit: &str) -> ReleaseMetadata {
ReleaseMetadata {
publish,
role: Some(crate::release::metadata::Role::Subsystem),
release_unit: Some(unit.to_string()),
}
}
#[test]
fn build_crate_graph_from_metadata() {
let doc = CargoMetadata {
packages: vec![
package(
"arcature-auth",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
),
&[],
),
package(
"arcature",
Some(r#"{"arcature":{"publish":true,"role":"facade","release-unit":"core"}}"#),
&[
("arcature-auth", None, "~2026.1.0", Some("../arcature-auth")),
("arcature-dx", None, "=2026.1.0", Some("../arcature-dx")),
],
),
],
};
let crates = vec![
discovered("arcature-auth", md(true, "arcature-auth")),
discovered("arcature", md(true, "core")),
];
let graph = build_crate_graph(&doc, &crates);
let arcature = &graph.nodes["arcature"];
assert!(arcature.dependencies.contains_key("arcature-auth"));
assert_eq!(
arcature.dependencies.get("arcature-auth"),
Some(&DepKind::Production)
);
assert!(!arcature.dependencies.contains_key("arcature-dx"));
}
#[test]
fn build_crate_graph_excludes_external_deps() {
let doc = CargoMetadata {
packages: vec![package(
"arcature-auth",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
),
&[("axum", None, "0.8", None), ("serde", None, "1.0", None)],
)],
};
let crates = vec![discovered("arcature-auth", md(true, "arcature-auth"))];
let graph = build_crate_graph(&doc, &crates);
let node = &graph.nodes["arcature-auth"];
assert!(node.dependencies.is_empty());
}
#[test]
fn build_crate_graph_excludes_path_only_dev_deps() {
let doc = CargoMetadata {
packages: vec![
package(
"arcature-auth",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
),
&[],
),
package(
"arcature-test",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-test"}}"#,
),
&[("arcature-auth", Some("dev"), "*", Some("../arcature-auth"))],
),
],
};
let crates = vec![
discovered("arcature-auth", md(true, "arcature-auth")),
discovered("arcature-test", md(true, "arcature-test")),
];
let graph = build_crate_graph(&doc, &crates);
let test = &graph.nodes["arcature-test"];
assert!(!test.dependencies.contains_key("arcature-auth"));
}
#[test]
fn build_crate_graph_keeps_versioned_dev_deps() {
let doc = CargoMetadata {
packages: vec![
package(
"arcature-auth",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
),
&[],
),
package(
"arcature-test",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-test"}}"#,
),
&[(
"arcature-auth",
Some("dev"),
"~2026.1.0",
Some("../arcature-auth"),
)],
),
],
};
let crates = vec![
discovered("arcature-auth", md(true, "arcature-auth")),
discovered("arcature-test", md(true, "arcature-test")),
];
let graph = build_crate_graph(&doc, &crates);
let test = &graph.nodes["arcature-test"];
assert!(test.dependencies.contains_key("arcature-auth"));
assert_eq!(test.dependencies.get("arcature-auth"), Some(&DepKind::Dev));
}
#[test]
fn unit_graph_collapses_core() {
let mut nodes = BTreeMap::new();
nodes.insert(
"arcature".to_string(),
CrateNode {
name: "arcature".to_string(),
release_unit: "core".to_string(),
dependencies: BTreeMap::from([
("arcature-dx".to_string(), DepKind::Production),
("arcature-auth".to_string(), DepKind::Production),
("arcature-db".to_string(), DepKind::Production),
]),
},
);
nodes.insert(
"arcature-dx".to_string(),
CrateNode {
name: "arcature-dx".to_string(),
release_unit: "core".to_string(),
dependencies: BTreeMap::new(),
},
);
nodes.insert(
"arcature-auth".to_string(),
CrateNode {
name: "arcature-auth".to_string(),
release_unit: "arcature-auth".to_string(),
dependencies: BTreeMap::new(),
},
);
nodes.insert(
"arcature-db".to_string(),
CrateNode {
name: "arcature-db".to_string(),
release_unit: "arcature-db".to_string(),
dependencies: BTreeMap::new(),
},
);
let graph = CrateGraph { nodes };
let unit_graph = build_unit_graph(&graph);
let core = &unit_graph.nodes["core"];
assert!(core.dependencies.contains_key("arcature-auth"));
assert!(core.dependencies.contains_key("arcature-db"));
assert!(!core.dependencies.contains_key("core"));
}
#[test]
fn unit_graph_excludes_dev_edges() {
let mut nodes = BTreeMap::new();
nodes.insert(
"arcature-api".to_string(),
CrateNode {
name: "arcature-api".to_string(),
release_unit: "arcature-api".to_string(),
dependencies: BTreeMap::from([("arcature".to_string(), DepKind::Dev)]),
},
);
nodes.insert(
"arcature".to_string(),
CrateNode {
name: "arcature".to_string(),
release_unit: "core".to_string(),
dependencies: BTreeMap::from([("arcature-api".to_string(), DepKind::Production)]),
},
);
let graph = CrateGraph { nodes };
let unit_graph = build_unit_graph(&graph);
assert!(
unit_graph.nodes["core"]
.dependencies
.contains_key("arcature-api")
);
assert!(
!unit_graph.nodes["arcature-api"]
.dependencies
.contains_key("core")
);
}
#[test]
fn crate_graph_topological_order_works() {
let mut nodes = BTreeMap::new();
nodes.insert(
"a".to_string(),
CrateNode {
name: "a".to_string(),
release_unit: "a".to_string(),
dependencies: BTreeMap::new(),
},
);
nodes.insert(
"b".to_string(),
CrateNode {
name: "b".to_string(),
release_unit: "b".to_string(),
dependencies: BTreeMap::from([("a".to_string(), DepKind::Production)]),
},
);
let graph = CrateGraph { nodes };
let order = graph.topological_order().unwrap();
assert_eq!(order, vec!["a", "b"]);
}
#[test]
fn unit_graph_topological_order_works() {
let mut nodes = BTreeMap::new();
nodes.insert(
"core".to_string(),
UnitNode {
name: "core".to_string(),
members: ["arcature", "arcature-dx"]
.into_iter()
.map(String::from)
.collect(),
dependencies: BTreeMap::from([(
"arcature-auth".to_string(),
["arcature".to_string()].into_iter().collect(),
)]),
},
);
nodes.insert(
"arcature-auth".to_string(),
UnitNode {
name: "arcature-auth".to_string(),
members: ["arcature-auth".to_string()].into_iter().collect(),
dependencies: BTreeMap::new(),
},
);
let graph = UnitGraph { nodes };
let order = graph.topological_order().unwrap();
assert_eq!(order, vec!["arcature-auth", "core"]);
}
}