Skip to main content

what_stack/
types.rs

1//! Public input and output types shared by the detection APIs.
2
3use std::borrow::Cow;
4use std::ffi::OsString;
5use std::fmt;
6use std::path::Path;
7
8/// Broad category of a detected [`StackLabel`].
9///
10/// The kind is assigned where each detection rule is defined, so every label
11/// produced by this crate carries one. [`StackDetector`](crate::StackDetector)
12/// uses it to decide whether project config may refine a process label:
13///
14/// - [`Runtime`](Self::Runtime) and [`Tool`](Self::Tool) labels are generic
15///   hosts for project code, so a project config label (for example `Next.js`
16///   for a `node` process) may replace them.
17/// - [`Framework`](Self::Framework), [`Database`](Self::Database), and
18///   [`Service`](Self::Service) labels are final: a `postgres` or `rails`
19///   process keeps its own label even when started from a project directory.
20///
21/// The enum is `#[non_exhaustive]`; match it with a wildcard arm.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23#[non_exhaustive]
24pub enum StackKind {
25    /// Language runtime or process that executes project code, such as
26    /// `Node.js`, `Python`, `.NET`, `Go`, or the `Gunicorn` and `Uvicorn`
27    /// application servers.
28    Runtime,
29    /// Application or site framework, such as `Next.js`, `Django`, `Rails`,
30    /// or `Hugo`.
31    Framework,
32    /// Build tool, bundler, or dev server, such as `Vite`, `Webpack`, or
33    /// `Java (Maven)`.
34    Tool,
35    /// Database, cache, or search engine, such as `PostgreSQL`, `Redis`, or
36    /// `Elasticsearch`.
37    Database,
38    /// Network service that is not a data store, such as a web server, reverse
39    /// proxy, message broker, or cloud emulator (`Nginx`, `Traefik`,
40    /// `RabbitMQ`, `LocalStack`).
41    Service,
42}
43
44/// Human-readable application, framework, runtime, or service label.
45///
46/// A label pairs display text with a [`StackKind`]. Built-in labels borrow
47/// static strings, so producing them never allocates. Use
48/// [`as_str`](Self::as_str), [`AsRef<str>`], or [`Display`](fmt::Display) to
49/// read the text, and [`kind`](Self::kind) for the category.
50///
51/// Equality and hashing consider both text and kind. Comparisons with `str`
52/// and `&str` consider only the text, which keeps assertions short.
53///
54/// # Examples
55///
56/// ```
57/// use what_stack::{StackKind, StackLabel, detect_from_process};
58///
59/// let label: StackLabel = detect_from_process("postgres").expect("known process");
60/// assert_eq!(label, "PostgreSQL");
61/// assert_eq!(label.as_str(), "PostgreSQL");
62/// assert_eq!(label.kind(), StackKind::Database);
63/// assert_eq!(label.to_string(), "PostgreSQL");
64/// ```
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66pub struct StackLabel {
67    text: Cow<'static, str>,
68    kind: StackKind,
69}
70
71impl StackLabel {
72    /// Create a label from owned or static text.
73    ///
74    /// Prefer [`from_static`](Self::from_static) for string literals; it is a
75    /// `const fn` and never allocates.
76    #[must_use]
77    pub fn new(text: impl Into<Cow<'static, str>>, kind: StackKind) -> Self {
78        Self {
79            text: text.into(),
80            kind,
81        }
82    }
83
84    /// Create a label from static text without allocating.
85    #[must_use]
86    pub const fn from_static(text: &'static str, kind: StackKind) -> Self {
87        Self {
88            text: Cow::Borrowed(text),
89            kind,
90        }
91    }
92
93    /// Return the label text.
94    #[must_use]
95    pub fn as_str(&self) -> &str {
96        &self.text
97    }
98
99    /// Return the label category.
100    #[must_use]
101    pub const fn kind(&self) -> StackKind {
102        self.kind
103    }
104
105    /// Convert the label into its text, dropping the kind.
106    ///
107    /// Static labels stay borrowed, so this does not allocate for built-in
108    /// detections.
109    #[must_use]
110    pub fn into_cow(self) -> Cow<'static, str> {
111        self.text
112    }
113}
114
115impl fmt::Display for StackLabel {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.write_str(&self.text)
118    }
119}
120
121impl AsRef<str> for StackLabel {
122    fn as_ref(&self) -> &str {
123        &self.text
124    }
125}
126
127impl PartialEq<str> for StackLabel {
128    fn eq(&self, other: &str) -> bool {
129        self.text == other
130    }
131}
132
133impl PartialEq<&str> for StackLabel {
134    fn eq(&self, other: &&str) -> bool {
135        self.text == *other
136    }
137}
138
139impl PartialEq<StackLabel> for str {
140    fn eq(&self, other: &StackLabel) -> bool {
141        self == other.text
142    }
143}
144
145impl PartialEq<StackLabel> for &str {
146    fn eq(&self, other: &StackLabel) -> bool {
147        *self == other.text
148    }
149}
150
151impl From<StackLabel> for Cow<'static, str> {
152    fn from(label: StackLabel) -> Self {
153        label.text
154    }
155}
156
157impl From<StackLabel> for String {
158    fn from(label: StackLabel) -> Self {
159        label.text.into_owned()
160    }
161}
162
163/// Process-like path inputs used to resolve a project root.
164///
165/// Build one with [`ProjectInput::new`] and the chainable setters. Project root
166/// resolution is best-effort and uses a stable fallback order:
167///
168/// 1. [`cwd`](Self::cwd)
169/// 2. parent of [`exe`](Self::exe), for executables that are not known
170///    runtimes or tools
171/// 3. parents of absolute paths in [`cmd`](Self::cmd)
172///
173/// Relative command-line arguments are ignored because they cannot be resolved
174/// safely without knowing the process working directory at the time the command
175/// started.
176///
177/// The home-directory ceiling is not part of the input. It is configured on
178/// [`StackDetector`](crate::StackDetector) or passed to
179/// [`resolve_project_root`](crate::resolve_project_root) directly.
180///
181/// # Examples
182///
183/// ```
184/// use std::path::Path;
185/// use what_stack::ProjectInput;
186///
187/// let input = ProjectInput::new()
188///     .cwd(Path::new("/workspace/api/src"))
189///     .exe(None);
190/// # let _ = input;
191/// ```
192#[derive(Debug, Clone, Copy, Default)]
193#[non_exhaustive]
194pub struct ProjectInput<'a> {
195    pub(crate) cwd: Option<&'a Path>,
196    pub(crate) exe: Option<&'a Path>,
197    pub(crate) cmd: &'a [OsString],
198}
199
200impl<'a> ProjectInput<'a> {
201    /// Create an empty input with no path hints.
202    #[must_use]
203    pub const fn new() -> Self {
204        Self {
205            cwd: None,
206            exe: None,
207            cmd: &[],
208        }
209    }
210
211    /// Set the process working directory.
212    ///
213    /// This is the most trustworthy project-root hint and is checked first.
214    /// Accepts a `&Path` or an `Option<&Path>`.
215    #[must_use]
216    pub fn cwd(mut self, cwd: impl Into<Option<&'a Path>>) -> Self {
217        self.cwd = cwd.into();
218        self
219    }
220
221    /// Set the process executable path.
222    ///
223    /// The parent directory is searched when the working directory is missing
224    /// or does not resolve to a project, unless the executable is a known
225    /// runtime or tool such as `node` or `python`. A root found this way is
226    /// ignored when it lies in a dot directory directly under the home
227    /// ceiling, such as `~/.nvm` or `~/.cargo`. Accepts a `&Path` or an
228    /// `Option<&Path>`.
229    #[must_use]
230    pub fn exe(mut self, exe: impl Into<Option<&'a Path>>) -> Self {
231        self.exe = exe.into();
232        self
233    }
234
235    /// Set the process command-line arguments.
236    ///
237    /// Only absolute path arguments are inspected, in their original order.
238    #[must_use]
239    pub const fn cmd(mut self, cmd: &'a [OsString]) -> Self {
240        self.cmd = cmd;
241        self
242    }
243}
244
245/// Inputs used by [`StackDetector`](crate::StackDetector) to resolve a stack.
246///
247/// Build one with [`StackInput::new`] and the chainable setters. The detector
248/// uses image, process, and project config metadata. Config detection is
249/// guarded to avoid false positives: a config label can win only when the
250/// process label is a [`StackKind::Runtime`] or [`StackKind::Tool`], or when the
251/// process is unknown but its executable belongs to the project (see
252/// [`StackDetector`](crate::StackDetector)). A known runtime or tool accepts
253/// only config labels from its own ecosystem.
254///
255/// # Examples
256///
257/// ```
258/// use std::path::Path;
259/// use what_stack::StackInput;
260///
261/// let input = StackInput::new("node")
262///     .exe_name("node.exe")
263///     .project_root(Path::new("/workspace/web"))
264///     .image(None);
265/// # let _ = input;
266/// ```
267#[derive(Debug, Clone, Copy, Default)]
268#[non_exhaustive]
269pub struct StackInput<'a> {
270    pub(crate) image: Option<&'a str>,
271    pub(crate) project_root: Option<&'a Path>,
272    pub(crate) process_name: &'a str,
273    pub(crate) exe_name: Option<&'a str>,
274    pub(crate) exe_path: Option<&'a Path>,
275}
276
277impl<'a> StackInput<'a> {
278    /// Create an input from a process name taken from the process table.
279    ///
280    /// Pass an empty string when no process name is available.
281    #[must_use]
282    pub const fn new(process_name: &'a str) -> Self {
283        Self {
284            image: None,
285            project_root: None,
286            process_name,
287            exe_name: None,
288            exe_path: None,
289        }
290    }
291
292    /// Set the container or artifact image name.
293    ///
294    /// Images are checked first because they usually identify services such as
295    /// databases more precisely than process names. Accepts a `&str` or an
296    /// `Option<&str>`.
297    #[must_use]
298    pub fn image(mut self, image: impl Into<Option<&'a str>>) -> Self {
299        self.image = image.into();
300        self
301    }
302
303    /// Set the project root directory.
304    ///
305    /// The directory itself is scanned; parent and child directories are not
306    /// searched by stack detection. Use
307    /// [`StackDetector::detect_project_root`](crate::StackDetector::detect_project_root)
308    /// first when starting from process paths. Accepts a `&Path` or an
309    /// `Option<&Path>`.
310    #[must_use]
311    pub fn project_root(mut self, project_root: impl Into<Option<&'a Path>>) -> Self {
312        self.project_root = project_root.into();
313        self
314    }
315
316    /// Set the executable file name taken from the executable path.
317    ///
318    /// This is used as a fallback when the process name is truncated or less
319    /// specific. Accepts a `&str` or an `Option<&str>`.
320    #[must_use]
321    pub fn exe_name(mut self, exe_name: impl Into<Option<&'a str>>) -> Self {
322        self.exe_name = exe_name.into();
323        self
324    }
325
326    /// Set the full executable path.
327    ///
328    /// The executable is not read. Its path is used only to decide whether
329    /// config detection is allowed for an unknown process name: it is when the
330    /// executable lies inside the project root, in a `go-build*` directory
331    /// from `go run`, or in the `target` directory of an enclosing Cargo
332    /// workspace. Accepts a `&Path` or an `Option<&Path>`.
333    #[must_use]
334    pub fn exe_path(mut self, exe_path: impl Into<Option<&'a Path>>) -> Self {
335        self.exe_path = exe_path.into();
336        self
337    }
338}