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