Skip to main content

a3s_use_core/
lib.rs

1use std::collections::BTreeMap;
2use std::ffi::OsString;
3use std::fmt;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(transparent)]
10pub struct UseSessionId(String);
11
12impl UseSessionId {
13    pub fn parse(value: impl Into<String>) -> Result<Self, UseError> {
14        let value = value.into();
15        if value.is_empty()
16            || !value.chars().all(|character| {
17                character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
18            })
19        {
20            return Err(UseError::new(
21                "use.session.invalid_id",
22                "Session IDs may contain only ASCII letters, digits, '-' and '_'.",
23            ));
24        }
25        Ok(Self(value))
26    }
27
28    pub fn as_str(&self) -> &str {
29        &self.0
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "kebab-case")]
35pub enum RiskClass {
36    Read,
37    Navigate,
38    Mutate,
39    Submit,
40    Download,
41    Execute,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct Artifact {
47    pub path: PathBuf,
48    pub media_type: String,
49    pub size: u64,
50    pub sha256: String,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum Readiness {
56    Ready,
57    Missing,
58    Broken,
59    Unknown,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct DomainDiagnostic {
65    pub domain: String,
66    pub readiness: Readiness,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub provider: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub version: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub path: Option<PathBuf>,
73    pub message: String,
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub suggestions: Vec<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct UseError {
81    pub code: String,
82    pub message: String,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub suggestion: Option<String>,
85    #[serde(default)]
86    pub details: BTreeMap<String, serde_json::Value>,
87}
88
89impl UseError {
90    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
91        Self {
92            code: code.into(),
93            message: message.into(),
94            suggestion: None,
95            details: BTreeMap::new(),
96        }
97    }
98
99    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
100        self.suggestion = Some(suggestion.into());
101        self
102    }
103
104    pub fn with_detail(
105        mut self,
106        name: impl Into<String>,
107        value: impl Into<serde_json::Value>,
108    ) -> Self {
109        self.details.insert(name.into(), value.into());
110        self
111    }
112}
113
114impl fmt::Display for UseError {
115    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116        formatter.write_str(&self.message)
117    }
118}
119
120impl std::error::Error for UseError {}
121
122pub type UseResult<T> = Result<T, UseError>;
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum FirstUseInstallBlock {
126    Offline,
127    Disabled,
128}
129
130impl FirstUseInstallBlock {
131    pub const fn reason(self) -> &'static str {
132        match self {
133            Self::Offline => "offline mode",
134            Self::Disabled => "A3S_NO_AUTO_INSTALL",
135        }
136    }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct FirstUseInstallPolicy {
141    offline: bool,
142    disabled: bool,
143}
144
145impl FirstUseInstallPolicy {
146    pub const fn new(offline: bool, disabled: bool) -> Self {
147        Self { offline, disabled }
148    }
149
150    pub fn from_env() -> UseResult<Self> {
151        Self::from_values(
152            std::env::var_os("A3S_OFFLINE"),
153            std::env::var_os("A3S_NO_AUTO_INSTALL"),
154        )
155    }
156
157    pub const fn blocked_by(self) -> Option<FirstUseInstallBlock> {
158        if self.offline {
159            Some(FirstUseInstallBlock::Offline)
160        } else if self.disabled {
161            Some(FirstUseInstallBlock::Disabled)
162        } else {
163            None
164        }
165    }
166
167    pub const fn allows_install(self) -> bool {
168        self.blocked_by().is_none()
169    }
170
171    fn from_values(offline: Option<OsString>, disabled: Option<OsString>) -> UseResult<Self> {
172        Ok(Self {
173            offline: parse_environment_boolean("A3S_OFFLINE", offline)?,
174            disabled: parse_environment_boolean("A3S_NO_AUTO_INSTALL", disabled)?,
175        })
176    }
177}
178
179fn parse_environment_boolean(name: &'static str, value: Option<OsString>) -> UseResult<bool> {
180    let Some(value) = value else {
181        return Ok(false);
182    };
183    if value.is_empty() {
184        return Ok(true);
185    }
186    let value = value.into_string().map_err(|_| {
187        UseError::new(
188            "use.first_use.policy_invalid",
189            format!("{name} must contain a valid UTF-8 boolean value."),
190        )
191        .with_detail("variable", name)
192    })?;
193    match value.trim().to_ascii_lowercase().as_str() {
194        "1" | "true" | "yes" | "on" => Ok(true),
195        "0" | "false" | "no" | "off" => Ok(false),
196        _ => Err(UseError::new(
197            "use.first_use.policy_invalid",
198            format!("{name} must be a boolean value."),
199        )
200        .with_detail("variable", name)),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn session_ids_are_bounded_and_serializable() {
210        let id = UseSessionId::parse("browser_01").unwrap();
211        assert_eq!(id.as_str(), "browser_01");
212        assert!(UseSessionId::parse("../escape").is_err());
213        assert_eq!(serde_json::to_string(&id).unwrap(), "\"browser_01\"");
214    }
215
216    #[test]
217    fn stable_error_has_machine_code_and_suggestion() {
218        let error = UseError::new("use.runtime.missing", "Runtime is missing.")
219            .with_suggestion("Run a3s install use/browser.");
220        let value = serde_json::to_value(error).unwrap();
221        assert_eq!(value["code"], "use.runtime.missing");
222        assert!(value["suggestion"]
223            .as_str()
224            .unwrap()
225            .contains("a3s install"));
226    }
227
228    #[test]
229    fn first_use_policy_uses_a3s_boolean_conventions() {
230        for value in [None, Some("0"), Some("false"), Some("no"), Some("off")] {
231            let policy = FirstUseInstallPolicy::from_values(value.map(Into::into), None).unwrap();
232            assert!(policy.allows_install());
233        }
234        for value in [Some(""), Some("1"), Some("true"), Some("yes"), Some("on")] {
235            let policy = FirstUseInstallPolicy::from_values(value.map(Into::into), None).unwrap();
236            assert_eq!(policy.blocked_by(), Some(FirstUseInstallBlock::Offline));
237        }
238    }
239
240    #[test]
241    fn offline_policy_takes_precedence_over_no_auto_install() {
242        let policy =
243            FirstUseInstallPolicy::from_values(Some("1".into()), Some("1".into())).unwrap();
244        assert_eq!(policy.blocked_by(), Some(FirstUseInstallBlock::Offline));
245    }
246
247    #[test]
248    fn invalid_first_use_policy_is_typed() {
249        let error = FirstUseInstallPolicy::from_values(Some("sometimes".into()), None).unwrap_err();
250        assert_eq!(error.code, "use.first_use.policy_invalid");
251        assert_eq!(error.details["variable"], "A3S_OFFLINE");
252    }
253}