Skip to main content

affected_core/resolvers/
elixir.rs

1use anyhow::Result;
2use regex::Regex;
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6use crate::resolvers::{file_to_package, Resolver};
7use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
8
9/// ElixirResolver detects Elixir Mix umbrella projects via `mix.exs` + `apps/` directory.
10///
11/// Scans `apps/*/mix.exs` to discover sub-applications and their umbrella dependencies.
12pub struct ElixirResolver;
13impl super::sealed::Sealed for ElixirResolver {}
14
15impl Resolver for ElixirResolver {
16    fn ecosystem(&self) -> Ecosystem {
17        Ecosystem::Elixir
18    }
19
20    fn detect(&self, root: &Path) -> bool {
21        root.join("mix.exs").exists() && root.join("apps").is_dir()
22    }
23
24    fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
25        let apps_dir = root.join("apps");
26
27        // Discover app directories that contain a mix.exs
28        let mut app_dirs = Vec::new();
29        for entry in std::fs::read_dir(&apps_dir)? {
30            let entry = entry?;
31            let path = entry.path();
32            if path.is_dir() && path.join("mix.exs").exists() {
33                app_dirs.push(path);
34            }
35        }
36
37        let mut packages = HashMap::new();
38        let mut name_to_id: HashMap<String, PackageId> = HashMap::new();
39        let mut app_contents: HashMap<PackageId, String> = HashMap::new();
40
41        for app_dir in &app_dirs {
42            let dir_name = app_dir
43                .file_name()
44                .unwrap_or_default()
45                .to_string_lossy()
46                .to_string();
47
48            let mix_path = app_dir.join("mix.exs");
49            let content = std::fs::read_to_string(&mix_path)?;
50
51            let app_name = parse_app_name(&content, &dir_name);
52            let rel_path = format!("apps/{}", dir_name);
53            let pkg_id = PackageId(rel_path);
54
55            tracing::debug!("Elixir: discovered app '{}' at {:?}", app_name, app_dir);
56
57            name_to_id.insert(app_name.clone(), pkg_id.clone());
58            app_contents.insert(pkg_id.clone(), content);
59            packages.insert(
60                pkg_id.clone(),
61                Package {
62                    id: pkg_id,
63                    name: app_name,
64                    version: None,
65                    path: app_dir.clone(),
66                    manifest_path: mix_path,
67                },
68            );
69        }
70
71        // Build dependency edges
72        let known_apps: HashSet<&str> = name_to_id.keys().map(|s| s.as_str()).collect();
73        let mut edges = Vec::new();
74
75        for (pkg_id, content) in &app_contents {
76            let deps = parse_umbrella_deps(content);
77            for dep_name in &deps {
78                if known_apps.contains(dep_name.as_str()) {
79                    if let Some(to_id) = name_to_id.get(dep_name) {
80                        edges.push((pkg_id.clone(), to_id.clone()));
81                    }
82                }
83            }
84        }
85
86        Ok(ProjectGraph {
87            packages,
88            edges,
89            root: root.to_path_buf(),
90        })
91    }
92
93    fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
94        file_to_package(graph, file)
95    }
96
97    fn test_command(&self, package_id: &PackageId) -> Vec<String> {
98        // package_id is "apps/<dir_name>"; extract the app directory name
99        let app_name = package_id.0.strip_prefix("apps/").unwrap_or(&package_id.0);
100        vec![
101            "mix".into(),
102            "cmd".into(),
103            "--app".into(),
104            app_name.into(),
105            "mix".into(),
106            "test".into(),
107        ]
108    }
109}
110
111/// Extract the app name from mix.exs content.
112///
113/// Looks for `app: :some_name` and returns `"some_name"`.
114/// Falls back to the directory name if no match is found.
115fn parse_app_name(content: &str, dir_name: &str) -> String {
116    let re = Regex::new(r"app:\s*:([\w]+)").expect("invalid regex");
117    re.captures(content)
118        .and_then(|caps| caps.get(1))
119        .map(|m| m.as_str().to_string())
120        .unwrap_or_else(|| dir_name.to_string())
121}
122
123/// Extract umbrella dependency names from mix.exs content.
124///
125/// Matches two patterns:
126/// - `{:dep_name, in_umbrella: true}`
127/// - `{:dep_name, path: "../dep_name"}`
128fn parse_umbrella_deps(content: &str) -> Vec<String> {
129    let mut deps = Vec::new();
130
131    // Pattern 1: {:dep_name, in_umbrella: true}
132    let re_umbrella = Regex::new(r#"\{:([\w]+),\s*in_umbrella:\s*true\}"#).expect("invalid regex");
133    for caps in re_umbrella.captures_iter(content) {
134        if let Some(m) = caps.get(1) {
135            deps.push(m.as_str().to_string());
136        }
137    }
138
139    // Pattern 2: {:dep_name, path: "../dep_name"}
140    let re_path = Regex::new(r#"\{:([\w]+),.*?path:\s*""#).expect("invalid regex");
141    for caps in re_path.captures_iter(content) {
142        if let Some(m) = caps.get(1) {
143            let name = m.as_str().to_string();
144            if !deps.contains(&name) {
145                deps.push(name);
146            }
147        }
148    }
149
150    deps
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn test_detect_umbrella() {
159        let dir = tempfile::tempdir().unwrap();
160        std::fs::write(
161            dir.path().join("mix.exs"),
162            r#"defmodule MyUmbrella.MixProject do
163  use Mix.Project
164
165  def project do
166    [apps_path: "apps"]
167  end
168end"#,
169        )
170        .unwrap();
171        std::fs::create_dir_all(dir.path().join("apps")).unwrap();
172
173        assert!(ElixirResolver.detect(dir.path()));
174    }
175
176    #[test]
177    fn test_detect_no_apps_dir() {
178        let dir = tempfile::tempdir().unwrap();
179        std::fs::write(
180            dir.path().join("mix.exs"),
181            r#"defmodule MyApp.MixProject do
182  use Mix.Project
183end"#,
184        )
185        .unwrap();
186
187        assert!(!ElixirResolver.detect(dir.path()));
188    }
189
190    #[test]
191    fn test_detect_no_elixir() {
192        let dir = tempfile::tempdir().unwrap();
193        assert!(!ElixirResolver.detect(dir.path()));
194    }
195
196    #[test]
197    fn test_parse_app_name() {
198        let content = r#"defmodule Api.MixProject do
199  use Mix.Project
200
201  def project do
202    [
203      app: :api,
204      version: "0.1.0",
205      deps: deps()
206    ]
207  end
208end"#;
209
210        assert_eq!(parse_app_name(content, "fallback"), "api");
211    }
212
213    #[test]
214    fn test_parse_app_name_fallback() {
215        let content = r#"defmodule Weird.MixProject do
216  use Mix.Project
217end"#;
218
219        assert_eq!(parse_app_name(content, "my_dir"), "my_dir");
220    }
221
222    #[test]
223    fn test_parse_umbrella_deps() {
224        let content = r#"defmodule Api.MixProject do
225  use Mix.Project
226
227  def project do
228    [
229      app: :api,
230      version: "0.1.0",
231      deps: deps()
232    ]
233  end
234
235  defp deps do
236    [
237      {:core, in_umbrella: true},
238      {:shared, in_umbrella: true},
239      {:phoenix, "~> 1.7"},
240      {:utils, path: "../utils"}
241    ]
242  end
243end"#;
244
245        let deps = parse_umbrella_deps(content);
246        assert_eq!(deps, vec!["core", "shared", "utils"]);
247    }
248
249    #[test]
250    fn test_resolve_umbrella_project() {
251        let dir = tempfile::tempdir().unwrap();
252
253        // Root mix.exs
254        std::fs::write(
255            dir.path().join("mix.exs"),
256            r#"defmodule MyUmbrella.MixProject do
257  use Mix.Project
258
259  def project do
260    [apps_path: "apps"]
261  end
262end"#,
263        )
264        .unwrap();
265
266        // apps/ directory with three apps: core, shared, api
267        let apps = dir.path().join("apps");
268        std::fs::create_dir_all(apps.join("core")).unwrap();
269        std::fs::write(
270            apps.join("core/mix.exs"),
271            r#"defmodule Core.MixProject do
272  use Mix.Project
273
274  def project do
275    [
276      app: :core,
277      version: "0.1.0",
278      deps: deps()
279    ]
280  end
281
282  defp deps do
283    [
284      {:jason, "~> 1.4"}
285    ]
286  end
287end"#,
288        )
289        .unwrap();
290
291        std::fs::create_dir_all(apps.join("shared")).unwrap();
292        std::fs::write(
293            apps.join("shared/mix.exs"),
294            r#"defmodule Shared.MixProject do
295  use Mix.Project
296
297  def project do
298    [
299      app: :shared,
300      version: "0.1.0",
301      deps: deps()
302    ]
303  end
304
305  defp deps do
306    [
307      {:core, in_umbrella: true}
308    ]
309  end
310end"#,
311        )
312        .unwrap();
313
314        std::fs::create_dir_all(apps.join("api")).unwrap();
315        std::fs::write(
316            apps.join("api/mix.exs"),
317            r#"defmodule Api.MixProject do
318  use Mix.Project
319
320  def project do
321    [
322      app: :api,
323      version: "0.1.0",
324      deps: deps()
325    ]
326  end
327
328  defp deps do
329    [
330      {:core, in_umbrella: true},
331      {:shared, in_umbrella: true},
332      {:phoenix, "~> 1.7"}
333    ]
334  end
335end"#,
336        )
337        .unwrap();
338
339        let graph = ElixirResolver.resolve(dir.path()).unwrap();
340
341        // 3 packages discovered
342        assert_eq!(graph.packages.len(), 3);
343        assert!(graph.packages.contains_key(&PackageId("apps/core".into())));
344        assert!(graph
345            .packages
346            .contains_key(&PackageId("apps/shared".into())));
347        assert!(graph.packages.contains_key(&PackageId("apps/api".into())));
348
349        // Check package names
350        assert_eq!(graph.packages[&PackageId("apps/core".into())].name, "core");
351        assert_eq!(
352            graph.packages[&PackageId("apps/shared".into())].name,
353            "shared"
354        );
355        assert_eq!(graph.packages[&PackageId("apps/api".into())].name, "api");
356
357        // shared depends on core
358        assert!(graph.edges.contains(&(
359            PackageId("apps/shared".into()),
360            PackageId("apps/core".into()),
361        )));
362
363        // api depends on core and shared
364        assert!(graph
365            .edges
366            .contains(&(PackageId("apps/api".into()), PackageId("apps/core".into()),)));
367        assert!(graph.edges.contains(&(
368            PackageId("apps/api".into()),
369            PackageId("apps/shared".into()),
370        )));
371
372        // core has no internal deps
373        let core_edges: Vec<_> = graph
374            .edges
375            .iter()
376            .filter(|(from, _)| from == &PackageId("apps/core".into()))
377            .collect();
378        assert!(core_edges.is_empty());
379    }
380
381    #[test]
382    fn test_test_command() {
383        let cmd = ElixirResolver.test_command(&PackageId("apps/api".into()));
384        assert_eq!(cmd, vec!["mix", "cmd", "--app", "api", "mix", "test"]);
385    }
386}