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 product: Product::from_path(&rel_path),
40 rel_path,
41 layer,
42 metadata_layer,
43 },
44 );
45 }
46
47 let mut graph = Self {
48 packages,
49 edges: Vec::new(),
50 edges_by_from: BTreeMap::new(),
51 };
52 graph.collect_edges(root, package_values, &workspace_members, &path_to_id)?;
53 Ok(graph)
54 }
55
56 pub(crate) fn package(&self, id: &str) -> &Package {
57 self.packages.get(id).expect("edge references package")
58 }
59
60 pub(crate) fn direct_dependencies_except(
61 &self,
62 id: &str,
63 exceptions: &[crate::architecture::ArchitectureException],
64 ) -> Vec<&Package> {
65 self.edges_by_from
66 .get(id)
67 .into_iter()
68 .flat_map(|edges| edges.iter())
69 .map(|edge| &self.edges[*edge])
70 .filter(|edge| {
71 !exceptions
72 .iter()
73 .any(|exception| exception.matches(self, edge))
74 })
75 .filter_map(|edge| self.packages.get(&edge.to))
76 .collect()
77 }
78
79 pub(crate) fn edge_label(&self, edge: &Edge) -> String {
80 let from = self.package(&edge.from);
81 let to = self.package(&edge.to);
82 let optional = if edge.optional { " optional" } else { "" };
83 format!("{} --{} {}--> {}", from.name, optional, edge.kind, to.name)
84 }
85
86 fn collect_edges(
87 &mut self,
88 root: &Path,
89 package_values: &[Value],
90 workspace_members: &BTreeSet<String>,
91 path_to_id: &BTreeMap<String, String>,
92 ) -> Result<()> {
93 for value in package_values.iter().filter(|p| {
94 p.get("id")
95 .and_then(Value::as_str)
96 .is_some_and(|id| workspace_members.contains(id))
97 }) {
98 let from = text(value, "id")?;
99 for dependency in value
100 .get("dependencies")
101 .and_then(Value::as_array)
102 .into_iter()
103 .flatten()
104 {
105 let kind = dependency_kind(dependency);
106 if kind != "normal" {
107 continue;
108 }
109 let Some(path) = dependency.get("path").and_then(Value::as_str) else {
110 continue;
111 };
112 let Ok(rel_path) = rel_slash(root, Path::new(path)) else {
113 continue;
114 };
115 let Some(to) = path_to_id.get(&rel_path).cloned() else {
116 continue;
117 };
118 self.push_edge(Edge {
119 from: from.to_owned(),
120 to,
121 kind: kind.to_owned(),
122 optional: dependency
123 .get("optional")
124 .and_then(Value::as_bool)
125 .unwrap_or(false),
126 });
127 }
128 }
129 Ok(())
130 }
131
132 fn push_edge(&mut self, edge: Edge) {
133 let index = self.edges.len();
134 self.edges_by_from
135 .entry(edge.from.clone())
136 .or_default()
137 .push(index);
138 self.edges.push(edge);
139 }
140}
141
142pub(crate) struct Package {
143 pub(crate) id: String,
144 pub(crate) name: String,
145 pub(crate) product: Option<Product>,
146 pub(crate) rel_path: String,
147 pub(crate) layer: Layer,
148 pub(crate) metadata_layer: Option<Layer>,
149}
150
151pub(crate) struct Edge {
152 pub(crate) from: String,
153 pub(crate) to: String,
154 pub(crate) kind: String,
155 pub(crate) optional: bool,
156}
157
158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
159pub(crate) enum Product {
160 Soma,
161 Labby,
162 Axon,
163 Cortex,
164 Synapse,
165}
166
167impl Product {
168 fn from_name(name: &str) -> Option<Self> {
169 match name {
170 "soma" => Some(Self::Soma),
171 "labby" => Some(Self::Labby),
172 "axon" => Some(Self::Axon),
173 "cortex" => Some(Self::Cortex),
174 "synapse" => Some(Self::Synapse),
175 _ => None,
176 }
177 }
178
179 fn from_path(path: &str) -> Option<Self> {
180 if let Some(name) = path.strip_prefix("apps/")
181 && !name.contains('/')
182 {
183 return Self::from_name(name);
184 }
185
186 let rest = path.strip_prefix("crates/")?;
187 let (name, _) = rest.split_once('/')?;
188 Self::from_name(name)
189 }
190
191 pub(crate) fn as_str(self) -> &'static str {
192 match self {
193 Self::Soma => "soma",
194 Self::Labby => "labby",
195 Self::Axon => "axon",
196 Self::Cortex => "cortex",
197 Self::Synapse => "synapse",
198 }
199 }
200}
201
202#[derive(Clone, Copy, Debug, Eq, PartialEq)]
203pub(crate) enum Layer {
204 App,
205 Shared,
206 Vendor,
207 ProductDomain,
208 ProductApplication,
209 ProductIntegration,
210 ProductRuntime,
211 ProductSurface,
212 ProductSupport,
213 Legacy,
222}
223
224impl Layer {
225 fn from_path(path: &str) -> Option<Self> {
226 if let Some(name) = path.strip_prefix("apps/")
227 && !name.contains('/')
228 && Product::from_name(name).is_some()
229 {
230 return Some(Self::App);
231 }
232
233 if let Some(rest) = path.strip_prefix("crates/")
234 && let Some((product, leaf)) = rest.split_once('/')
235 && Product::from_name(product).is_some()
236 {
237 return Some(match leaf {
238 "domain" => Self::ProductDomain,
239 "application" => Self::ProductApplication,
240 "integrations" => Self::ProductIntegration,
241 "runtime" => Self::ProductRuntime,
242 "api" | "cli" | "mcp" | "web" | "palette" => Self::ProductSurface,
243 "test-support" | "client" | "config" => Self::ProductSupport,
244 _ => Self::Legacy,
245 });
246 }
247
248 match path {
249 "xtask" => Some(Self::Legacy),
250 path if path.starts_with("packages/") => Some(Self::Shared),
255 path if path.starts_with("crates/shared/") => Some(Self::Shared),
256 path if path.starts_with("crates/integrations/") => Some(Self::Vendor),
257 _ => None,
258 }
259 }
260
261 fn parse(value: &str) -> Option<Self> {
262 match value {
263 "app" => Some(Self::App),
264 "shared" => Some(Self::Shared),
265 "vendor" => Some(Self::Vendor),
266 "product-domain" => Some(Self::ProductDomain),
267 "product-application" => Some(Self::ProductApplication),
268 "product-integration" => Some(Self::ProductIntegration),
269 "product-runtime" => Some(Self::ProductRuntime),
270 "product-surface" => Some(Self::ProductSurface),
271 "product-support" => Some(Self::ProductSupport),
272 "legacy" => Some(Self::Legacy),
273 _ => None,
274 }
275 }
276
277 pub(crate) fn as_str(self) -> &'static str {
278 match self {
279 Self::App => "app",
280 Self::Shared => "shared",
281 Self::Vendor => "vendor",
282 Self::ProductDomain => "product-domain",
283 Self::ProductApplication => "product-application",
284 Self::ProductIntegration => "product-integration",
285 Self::ProductRuntime => "product-runtime",
286 Self::ProductSurface => "product-surface",
287 Self::ProductSupport => "product-support",
288 Self::Legacy => "legacy",
289 }
290 }
291}
292
293fn metadata_layer(package: &Value) -> Result<Option<Layer>> {
294 let Some(value) = package
295 .pointer("/metadata/soma-architecture/layer")
296 .and_then(Value::as_str)
297 else {
298 return Ok(None);
299 };
300 Layer::parse(value)
301 .with_context(|| format!("unknown architecture layer {value:?}"))
302 .map(Some)
303}
304
305fn text<'a>(value: &'a Value, key: &str) -> Result<&'a str> {
306 value
307 .get(key)
308 .and_then(Value::as_str)
309 .with_context(|| format!("package is missing string field {key:?}"))
310}
311
312fn workspace_members(metadata: &Value) -> Result<BTreeSet<String>> {
313 metadata
314 .get("workspace_members")
315 .and_then(Value::as_array)
316 .context("metadata has workspace_members")?
317 .iter()
318 .map(|value| {
319 value
320 .as_str()
321 .map(str::to_owned)
322 .context("workspace member id is a string")
323 })
324 .collect()
325}
326
327fn dependency_kind(dependency: &Value) -> &str {
328 dependency
329 .get("kind")
330 .and_then(Value::as_str)
331 .unwrap_or("normal")
332}
333
334fn package_rel_path(root: &Path, manifest_path: &Path) -> Result<String> {
335 let package_root = manifest_path
336 .parent()
337 .context("manifest path has no parent directory")?;
338 rel_slash(root, package_root)
339}
340
341fn rel_slash(root: &Path, path: &Path) -> Result<String> {
342 let rel = path
343 .strip_prefix(root)
344 .with_context(|| format!("{} is outside {}", path.display(), root.display()))?;
345 Ok(rel
346 .components()
347 .map(|component| component.as_os_str().to_string_lossy())
348 .collect::<Vec<_>>()
349 .join("/"))
350}
351
352#[cfg(test)]
353#[path = "architecture_graph_tests.rs"]
354mod tests;