Skip to main content

backhopper_xref_reader/
application.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Application-membership resolution.
6
7use std::ffi::OsStr;
8use std::path::{Path, PathBuf};
9
10use backhopper_core::ApplicationName;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
15pub enum ApplicationAssignment {
16    Assigned(ApplicationName),
17    NoLayoutMatch { path: PathBuf },
18    Ambiguous { candidates: Vec<ApplicationName> },
19}
20
21impl ApplicationAssignment {
22    pub fn assigned(&self) -> Option<&ApplicationName> {
23        if let ApplicationAssignment::Assigned(a) = self {
24            Some(a)
25        } else {
26            None
27        }
28    }
29}
30
31/// Maps file paths under a repository root to application names.
32///
33/// Common RabbitMQ patterns:
34/// * `deps/<app>/src/<module>.erl`
35/// * `apps/<app>/src/<module>.erl`
36///
37/// `ProjectLayout::rabbitmq_main()` configures both prefixes by default.
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct ProjectLayout {
40    prefixes: Vec<PathBuf>,
41}
42
43impl ProjectLayout {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    pub fn rabbitmq_main() -> Self {
49        let mut l = Self::new();
50        l.add_prefix("deps");
51        l.add_prefix("apps");
52        l
53    }
54
55    pub fn add_prefix(&mut self, prefix: impl Into<PathBuf>) -> &mut Self {
56        self.prefixes.push(prefix.into());
57        self
58    }
59
60    /// Resolve a source-file path to its application. Returns `NoLayoutMatch`
61    /// when the path is not under any known prefix.
62    pub fn resolve(&self, path: &Path) -> ApplicationAssignment {
63        let mut candidates: Vec<ApplicationName> = Vec::new();
64        for prefix in &self.prefixes {
65            if let Some(app) = match_under_prefix(prefix, path) {
66                candidates.push(app);
67            }
68        }
69        candidates.sort();
70        candidates.dedup();
71        match candidates.len() {
72            0 => ApplicationAssignment::NoLayoutMatch {
73                path: path.to_path_buf(),
74            },
75            1 => ApplicationAssignment::Assigned(candidates.remove(0)),
76            _ => ApplicationAssignment::Ambiguous { candidates },
77        }
78    }
79}
80
81fn match_under_prefix(prefix: &Path, path: &Path) -> Option<ApplicationName> {
82    // Match <prefix...>/<app>/... via OsStr equality so non-UTF-8 paths are safe.
83    let components: Vec<&OsStr> = path.components().map(|c| c.as_os_str()).collect();
84    let prefix_os: Vec<&OsStr> = prefix.components().map(|c| c.as_os_str()).collect();
85    if prefix_os.is_empty() {
86        return None;
87    }
88    let plen = prefix_os.len();
89    let mut i = 0;
90    while i + plen < components.len() {
91        if components[i..i + plen] == prefix_os[..] {
92            let app = components[i + plen].to_string_lossy();
93            return ApplicationName::new(app.into_owned()).ok();
94        }
95        i += 1;
96    }
97    None
98}