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                    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    // PR 19 deleted `crates/soma/contracts` and `crates/soma/service`, the
168    // last two product crates that lived in this bucket (plan section "PR 19:
169    // Delete legacy facades"). `xtask` is the sole remaining member: it is
170    // build tooling, not a shipped product/shared crate, so it does not fit
171    // any layer in the product taxonomy above and keeps its own bucket. Any
172    // future `crates/soma/*` path that is not yet added to the match arms
173    // above also falls back here as a safety net (see the wildcard arm
174    // below) until it is classified.
175    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;