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}