Skip to main content

xtask/
architecture_graph.rs

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    // PR 19 deleted `crates/soma/contracts` and `crates/soma/service`, the
214    // last two product crates that lived in this bucket (plan section "PR 19:
215    // Delete legacy facades"). `xtask` is the sole remaining member: it is
216    // build tooling, not a shipped product/shared crate, so it does not fit
217    // any layer in the product taxonomy above and keeps its own bucket. Any
218    // future `crates/soma/*` path that is not yet added to the match arms
219    // above also falls back here as a safety net (see the wildcard arm
220    // below) until it is classified.
221    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            // Language-binding packages (PyO3 extension shipped to PyPI as
251            // soma_provider). They wrap the product-agnostic provider contract
252            // in crates/shared/provider-core, so they sit in the shared layer
253            // rather than being a product surface.
254            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;