Skip to main content

openlogi_core/binding/
application_target.rs

1//! Validated operating-system launch targets.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// Why an application launch target could not be constructed.
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
8pub enum ApplicationTargetError {
9    /// The launch path or URL was blank.
10    #[error("application path must not be empty")]
11    EmptyPath,
12}
13
14/// Validated application, folder, filesystem, or URL target.
15#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(try_from = "ApplicationTargetWire", into = "ApplicationTargetWire")]
17pub struct ApplicationTarget {
18    path: String,
19    display_name: String,
20}
21
22impl ApplicationTarget {
23    /// Validate a platform path or URL and its user-facing name.
24    pub fn new(
25        path: impl Into<String>,
26        display_name: impl Into<String>,
27    ) -> Result<Self, ApplicationTargetError> {
28        let path = path.into().trim().to_string();
29        if path.is_empty() {
30            return Err(ApplicationTargetError::EmptyPath);
31        }
32        let requested_name = display_name.into();
33        let display_name = if requested_name.trim().is_empty() {
34            std::path::Path::new(&path)
35                .file_stem()
36                .and_then(std::ffi::OsStr::to_str)
37                .filter(|name| !name.is_empty())
38                .unwrap_or(path.as_str())
39                .to_string()
40        } else {
41            requested_name.trim().to_string()
42        };
43        Ok(Self { path, display_name })
44    }
45
46    /// Platform path or URL passed to the operating system. A leading `~` is
47    /// expanded by the injector immediately before opening.
48    #[must_use]
49    pub fn path(&self) -> &str {
50        &self.path
51    }
52
53    /// Name displayed in configuration and overlay UI.
54    #[must_use]
55    pub fn display_name(&self) -> &str {
56        &self.display_name
57    }
58}
59
60#[derive(Clone, Debug, Serialize, Deserialize)]
61struct ApplicationTargetWire {
62    path: String,
63    #[serde(default)]
64    display_name: String,
65}
66
67impl TryFrom<ApplicationTargetWire> for ApplicationTarget {
68    type Error = ApplicationTargetError;
69
70    fn try_from(target: ApplicationTargetWire) -> Result<Self, Self::Error> {
71        Self::new(target.path, target.display_name)
72    }
73}
74
75impl From<ApplicationTarget> for ApplicationTargetWire {
76    fn from(target: ApplicationTarget) -> Self {
77        Self {
78            path: target.path,
79            display_name: target.display_name,
80        }
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn validates_and_derives_display_name() {
90        let target = ApplicationTarget::new("/Applications/Safari.app", "")
91            .unwrap_or_else(|error| panic!("valid target failed: {error}"));
92        assert_eq!(target.path(), "/Applications/Safari.app");
93        assert_eq!(target.display_name(), "Safari");
94        assert_eq!(
95            ApplicationTarget::new("  ", "Safari"),
96            Err(ApplicationTargetError::EmptyPath)
97        );
98    }
99
100    #[test]
101    fn roundtrips_as_a_human_readable_target_table() {
102        let target = ApplicationTarget::new("https://example.test", "Example")
103            .unwrap_or_else(|error| panic!("valid target failed: {error}"));
104        let encoded = toml::to_string(&target)
105            .unwrap_or_else(|error| panic!("target serialization failed: {error}"));
106        assert!(encoded.contains("path = \"https://example.test\""));
107        assert_eq!(toml::from_str::<ApplicationTarget>(&encoded), Ok(target));
108    }
109}