1use anyhow::{Context, Result};
2use serde_json::Value;
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5
6pub(crate) struct Graph {
7 pub(crate) packages: BTreeMap<String, Package>,
8 pub(crate) edges: Vec<Edge>,
9 pub(crate) edges_by_from: BTreeMap<String, Vec<usize>>,
10}
11
12impl Graph {
13 pub(crate) fn from_metadata(root: &Path, metadata: &Value) -> Result<Self> {
14 let mut packages = BTreeMap::new();
15 let mut path_to_id = BTreeMap::new();
16 let package_values = metadata
17 .get("packages")
18 .and_then(Value::as_array)
19 .context("metadata has packages")?;
20 let workspace_members = workspace_members(metadata)?;
21
22 for value in package_values.iter().filter(|p| {
23 p.get("id")
24 .and_then(Value::as_str)
25 .is_some_and(|id| workspace_members.contains(id))
26 }) {
27 let id = text(value, "id")?.to_owned();
28 let name = text(value, "name")?.to_owned();
29 let rel_path = package_rel_path(root, Path::new(text(value, "manifest_path")?))?;
30 let layer = Layer::from_path(&rel_path)
31 .with_context(|| format!("{name} has unsupported architecture path {rel_path}"))?;
32 let metadata_layer = metadata_layer(value)?;
33 path_to_id.insert(rel_path.clone(), id.clone());
34 packages.insert(
35 id.clone(),
36 Package {
37 id,
38 name,
39 rel_path,
40 layer,
41 metadata_layer,
42 },
43 );
44 }
45
46 let mut graph = Self {
47 packages,
48 edges: Vec::new(),
49 edges_by_from: BTreeMap::new(),
50 };
51 graph.collect_edges(root, package_values, &workspace_members, &path_to_id)?;
52 Ok(graph)
53 }
54
55 pub(crate) fn package(&self, id: &str) -> &Package {
56 self.packages.get(id).expect("edge references package")
57 }
58
59 pub(crate) fn direct_dependencies_except(
60 &self,
61 id: &str,
62 exceptions: &[crate::architecture::ArchitectureException],
63 ) -> Vec<&Package> {
64 self.edges_by_from
65 .get(id)
66 .into_iter()
67 .flat_map(|edges| edges.iter())
68 .map(|edge| &self.edges[*edge])
69 .filter(|edge| {
70 !exceptions
71 .iter()
72 .any(|exception| exception.matches(self, edge))
73 })
74 .filter_map(|edge| self.packages.get(&edge.to))
75 .collect()
76 }
77
78 pub(crate) fn edge_label(&self, edge: &Edge) -> String {
79 let from = self.package(&edge.from);
80 let to = self.package(&edge.to);
81 let optional = if edge.optional { " optional" } else { "" };
82 format!("{} --{} {}--> {}", from.name, optional, edge.kind, to.name)
83 }
84
85 fn collect_edges(
86 &mut self,
87 root: &Path,
88 package_values: &[Value],
89 workspace_members: &BTreeSet<String>,
90 path_to_id: &BTreeMap<String, String>,
91 ) -> Result<()> {
92 for value in package_values.iter().filter(|p| {
93 p.get("id")
94 .and_then(Value::as_str)
95 .is_some_and(|id| workspace_members.contains(id))
96 }) {
97 let from = text(value, "id")?;
98 for dependency in value
99 .get("dependencies")
100 .and_then(Value::as_array)
101 .into_iter()
102 .flatten()
103 {
104 let kind = dependency_kind(dependency);
105 if kind != "normal" {
106 continue;
107 }
108 let Some(path) = dependency.get("path").and_then(Value::as_str) else {
109 continue;
110 };
111 let Ok(rel_path) = rel_slash(root, Path::new(path)) else {
112 continue;
113 };
114 let Some(to) = path_to_id.get(&rel_path).cloned() else {
115 continue;
116 };
117 self.push_edge(Edge {
118 from: from.to_owned(),
119 to,
120 kind: kind.to_owned(),
121 optional: dependency
122 .get("optional")
123 .and_then(Value::as_bool)
124 .unwrap_or(false),
125 });
126 }
127 }
128 Ok(())
129 }
130
131 fn push_edge(&mut self, edge: Edge) {
132 let index = self.edges.len();
133 self.edges_by_from
134 .entry(edge.from.clone())
135 .or_default()
136 .push(index);
137 self.edges.push(edge);
138 }
139}
140
141pub(crate) struct Package {
142 pub(crate) id: String,
143 pub(crate) name: String,
144 pub(crate) rel_path: String,
145 pub(crate) layer: Layer,
146 pub(crate) metadata_layer: Option<Layer>,
147}
148
149pub(crate) struct Edge {
150 pub(crate) from: String,
151 pub(crate) to: String,
152 pub(crate) kind: String,
153 pub(crate) optional: bool,
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq)]
157pub(crate) enum Layer {
158 App,
159 Shared,
160 Vendor,
161 ProductDomain,
162 ProductApplication,
163 ProductIntegration,
164 ProductRuntime,
165 ProductSurface,
166 ProductSupport,
167 Legacy,
176}
177
178impl Layer {
179 fn from_path(path: &str) -> Option<Self> {
180 match path {
181 "apps/soma" => Some(Self::App),
182 "crates/soma/domain" => Some(Self::ProductDomain),
183 "crates/soma/application" => Some(Self::ProductApplication),
184 "crates/soma/integrations" => Some(Self::ProductIntegration),
185 "crates/soma/runtime" => Some(Self::ProductRuntime),
186 "crates/soma/api"
187 | "crates/soma/cli"
188 | "crates/soma/mcp"
189 | "crates/soma/web"
190 | "crates/soma/palette" => Some(Self::ProductSurface),
191 "crates/soma/test-support" | "crates/soma/client" | "crates/soma/config" => {
192 Some(Self::ProductSupport)
193 }
194 "xtask" => Some(Self::Legacy),
195 path if path.starts_with("crates/shared/") => Some(Self::Shared),
196 path if path.starts_with("crates/integrations/") => Some(Self::Vendor),
197 path if path.starts_with("crates/soma/") => Some(Self::Legacy),
198 _ => None,
199 }
200 }
201
202 fn parse(value: &str) -> Option<Self> {
203 match value {
204 "app" => Some(Self::App),
205 "shared" => Some(Self::Shared),
206 "vendor" => Some(Self::Vendor),
207 "product-domain" => Some(Self::ProductDomain),
208 "product-application" => Some(Self::ProductApplication),
209 "product-integration" => Some(Self::ProductIntegration),
210 "product-runtime" => Some(Self::ProductRuntime),
211 "product-surface" => Some(Self::ProductSurface),
212 "product-support" => Some(Self::ProductSupport),
213 "legacy" => Some(Self::Legacy),
214 _ => None,
215 }
216 }
217
218 pub(crate) fn as_str(self) -> &'static str {
219 match self {
220 Self::App => "app",
221 Self::Shared => "shared",
222 Self::Vendor => "vendor",
223 Self::ProductDomain => "product-domain",
224 Self::ProductApplication => "product-application",
225 Self::ProductIntegration => "product-integration",
226 Self::ProductRuntime => "product-runtime",
227 Self::ProductSurface => "product-surface",
228 Self::ProductSupport => "product-support",
229 Self::Legacy => "legacy",
230 }
231 }
232}
233
234fn metadata_layer(package: &Value) -> Result<Option<Layer>> {
235 let Some(value) = package
236 .pointer("/metadata/soma-architecture/layer")
237 .and_then(Value::as_str)
238 else {
239 return Ok(None);
240 };
241 Layer::parse(value)
242 .with_context(|| format!("unknown architecture layer {value:?}"))
243 .map(Some)
244}
245
246fn text<'a>(value: &'a Value, key: &str) -> Result<&'a str> {
247 value
248 .get(key)
249 .and_then(Value::as_str)
250 .with_context(|| format!("package is missing string field {key:?}"))
251}
252
253fn workspace_members(metadata: &Value) -> Result<BTreeSet<String>> {
254 metadata
255 .get("workspace_members")
256 .and_then(Value::as_array)
257 .context("metadata has workspace_members")?
258 .iter()
259 .map(|value| {
260 value
261 .as_str()
262 .map(str::to_owned)
263 .context("workspace member id is a string")
264 })
265 .collect()
266}
267
268fn dependency_kind(dependency: &Value) -> &str {
269 dependency
270 .get("kind")
271 .and_then(Value::as_str)
272 .unwrap_or("normal")
273}
274
275fn package_rel_path(root: &Path, manifest_path: &Path) -> Result<String> {
276 let package_root = manifest_path
277 .parent()
278 .context("manifest path has no parent directory")?;
279 rel_slash(root, package_root)
280}
281
282fn rel_slash(root: &Path, path: &Path) -> Result<String> {
283 let rel = path
284 .strip_prefix(root)
285 .with_context(|| format!("{} is outside {}", path.display(), root.display()))?;
286 Ok(rel
287 .components()
288 .map(|component| component.as_os_str().to_string_lossy())
289 .collect::<Vec<_>>()
290 .join("/"))
291}
292
293#[cfg(test)]
294#[path = "architecture_graph_tests.rs"]
295mod tests;