Skip to main content

act_types/
types.rs

1use std::collections::{BTreeMap, HashMap};
2
3use crate::cbor;
4
5// ── LocalizedString ──
6
7/// A localizable text value, matching the WIT `localized-string` variant.
8///
9/// - `Plain` — a single string in the component's `default-language`.
10/// - `Localized` — a map of BCP 47 language tags to text.
11#[derive(Debug, Clone)]
12pub enum LocalizedString {
13    /// A single string assumed to be in the component's `default-language`.
14    Plain(String),
15    /// Language tag → text map. MUST include the component's `default-language`.
16    Localized(HashMap<String, String>),
17}
18
19impl Default for LocalizedString {
20    fn default() -> Self {
21        Self::Plain(String::new())
22    }
23}
24
25impl LocalizedString {
26    /// Create a plain (non-localized) string.
27    pub fn plain(text: impl Into<String>) -> Self {
28        Self::Plain(text.into())
29    }
30
31    /// Create a localized string with a single language entry.
32    pub fn new(lang: impl Into<String>, text: impl Into<String>) -> Self {
33        let mut map = HashMap::new();
34        map.insert(lang.into(), text.into());
35        Self::Localized(map)
36    }
37
38    /// Look up text for a specific language tag.
39    ///
40    /// For `Plain`, always returns the text (it is assumed to match any language).
41    /// For `Localized`, performs exact key lookup.
42    pub fn get(&self, lang: &str) -> Option<&str> {
43        match self {
44            Self::Plain(text) => Some(text.as_str()),
45            Self::Localized(map) => map.get(lang).map(|s| s.as_str()),
46        }
47    }
48
49    /// Resolve to text for the given language, with fallback chain.
50    ///
51    /// - `Plain` → returns the plain string (assumed to be in `default_language`).
52    /// - `Localized` → exact match → prefix match → any entry.
53    pub fn resolve(&self, lang: &str) -> &str {
54        match self {
55            Self::Plain(text) => text.as_str(),
56            Self::Localized(map) => {
57                // 1. Exact match
58                if let Some(text) = map.get(lang) {
59                    return text.as_str();
60                }
61                // 2. Prefix match (e.g. "zh" matches "zh-Hans")
62                if let Some(text) = map
63                    .iter()
64                    .find(|(tag, _)| tag.starts_with(lang) || lang.starts_with(tag.as_str()))
65                    .map(|(_, text)| text.as_str())
66                {
67                    return text;
68                }
69                // 3. Any entry
70                map.values().next().map(|s| s.as_str()).unwrap_or("")
71            }
72        }
73    }
74
75    /// Get some text, regardless of language.
76    /// Useful when you don't have the default language available.
77    pub fn any_text(&self) -> &str {
78        match self {
79            Self::Plain(text) => text.as_str(),
80            Self::Localized(map) => map.values().next().map(|s| s.as_str()).unwrap_or(""),
81        }
82    }
83}
84
85impl From<String> for LocalizedString {
86    fn from(s: String) -> Self {
87        Self::Plain(s)
88    }
89}
90
91impl From<&str> for LocalizedString {
92    fn from(s: &str) -> Self {
93        Self::Plain(s.to_string())
94    }
95}
96
97impl From<Vec<(String, String)>> for LocalizedString {
98    fn from(v: Vec<(String, String)>) -> Self {
99        Self::Localized(v.into_iter().collect())
100    }
101}
102
103impl From<HashMap<String, String>> for LocalizedString {
104    fn from(map: HashMap<String, String>) -> Self {
105        Self::Localized(map)
106    }
107}
108
109// ── Metadata ──
110
111/// Key → value metadata, stored as JSON values internally.
112///
113/// Converts to/from WIT `list<tuple<string, list<u8>>>` (CBOR) at the boundary.
114#[derive(Debug, Clone, Default)]
115pub struct Metadata(HashMap<String, serde_json::Value>);
116
117impl Metadata {
118    pub fn new() -> Self {
119        Self(HashMap::new())
120    }
121
122    /// Insert a value. Overwrites any existing entry for the key.
123    pub fn insert(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
124        self.0.insert(key.into(), value.into());
125    }
126
127    /// Get a value by key.
128    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
129        self.0.get(key)
130    }
131
132    /// Get a value by key, deserializing into a typed value.
133    pub fn get_as<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
134        self.0
135            .get(key)
136            .and_then(|v| serde_json::from_value(v.clone()).ok())
137    }
138
139    /// Check if a key exists.
140    pub fn contains_key(&self, key: &str) -> bool {
141        self.0.contains_key(key)
142    }
143
144    /// Returns true if there are no entries.
145    pub fn is_empty(&self) -> bool {
146        self.0.is_empty()
147    }
148
149    /// Iterate over key-value pairs.
150    pub fn iter(&self) -> impl Iterator<Item = (&String, &serde_json::Value)> {
151        self.0.iter()
152    }
153
154    /// Number of entries.
155    pub fn len(&self) -> usize {
156        self.0.len()
157    }
158
159    /// Merge all entries from `other` into `self`. Entries in `other` overwrite existing keys.
160    pub fn extend(&mut self, other: Metadata) {
161        self.0.extend(other.0);
162    }
163}
164
165/// Convert from a JSON object value. Non-object values produce empty metadata.
166impl From<serde_json::Value> for Metadata {
167    fn from(value: serde_json::Value) -> Self {
168        match value {
169            serde_json::Value::Object(map) => Self(map.into_iter().collect()),
170            _ => Self::new(),
171        }
172    }
173}
174
175/// Convert to a JSON object value (consuming).
176impl From<Metadata> for serde_json::Value {
177    fn from(m: Metadata) -> Self {
178        serde_json::Value::Object(m.0.into_iter().collect())
179    }
180}
181
182/// Convert from WIT metadata (CBOR-encoded values).
183impl From<Vec<(String, Vec<u8>)>> for Metadata {
184    fn from(v: Vec<(String, Vec<u8>)>) -> Self {
185        Self(
186            v.into_iter()
187                .filter_map(|(k, cbor_bytes)| {
188                    let val = cbor::cbor_to_json(&cbor_bytes).ok()?;
189                    Some((k, val))
190                })
191                .collect(),
192        )
193    }
194}
195
196/// Convert to WIT metadata (CBOR-encoded values).
197impl From<Metadata> for Vec<(String, Vec<u8>)> {
198    fn from(m: Metadata) -> Self {
199        m.0.into_iter()
200            .map(|(k, v)| (k, cbor::to_cbor(&v)))
201            .collect()
202    }
203}
204
205use crate::constants::*;
206
207// ── Component info (act:component custom section) ──
208
209/// Parameters for the `wasi:filesystem` capability.
210#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
211pub struct FilesystemCap {
212    /// Internal WASM root path for all host mounts (default: `/`).
213    #[serde(
214        rename = "mount-root",
215        default,
216        skip_serializing_if = "Option::is_none"
217    )]
218    pub mount_root: Option<String>,
219}
220
221/// Parameters for the `wasi:http` capability.
222#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
223pub struct HttpCap {}
224
225/// Parameters for the `wasi:sockets` capability.
226#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
227pub struct SocketsCap {}
228
229/// Capability declarations from the `std:capabilities` map in `act:component`.
230///
231/// Well-known capabilities have typed fields. Unknown third-party capabilities
232/// are collected in `other`. Serializes as a CBOR/JSON map keyed by capability ID.
233#[serde_with::skip_serializing_none]
234#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
235#[serde(default)]
236pub struct Capabilities {
237    /// `wasi:filesystem` — filesystem access.
238    #[serde(rename = "wasi:filesystem")]
239    pub filesystem: Option<FilesystemCap>,
240    /// `wasi:http` — outbound HTTP requests.
241    #[serde(rename = "wasi:http")]
242    pub http: Option<HttpCap>,
243    /// `wasi:sockets` — outbound TCP/UDP connections.
244    #[serde(rename = "wasi:sockets")]
245    pub sockets: Option<SocketsCap>,
246    /// Third-party capabilities keyed by identifier.
247    #[serde(flatten)]
248    pub other: BTreeMap<String, serde_json::Value>,
249}
250
251impl Capabilities {
252    /// True if no capabilities are declared.
253    pub fn is_empty(&self) -> bool {
254        self.http.is_none()
255            && self.filesystem.is_none()
256            && self.sockets.is_none()
257            && self.other.is_empty()
258    }
259
260    /// Check if a capability is declared by its string identifier.
261    pub fn has(&self, id: &str) -> bool {
262        match id {
263            CAP_HTTP => self.http.is_some(),
264            CAP_FILESYSTEM => self.filesystem.is_some(),
265            CAP_SOCKETS => self.sockets.is_some(),
266            other => self.other.contains_key(other),
267        }
268    }
269
270    /// Get the `mount-root` parameter from the `wasi:filesystem` capability.
271    pub fn fs_mount_root(&self) -> Option<&str> {
272        self.filesystem.as_ref()?.mount_root.as_deref()
273    }
274}
275
276/// Component metadata stored in the `act:component` WASM custom section (CBOR-encoded).
277///
278/// Used by SDK macros (serialization) and host (deserialization).
279/// Also deserializable from `act.toml` manifest via `alias` attributes.
280///
281/// Extra namespaces (not `std`) are collected into `extra`.
282#[non_exhaustive]
283#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
284pub struct ComponentInfo {
285    /// Well-known component metadata.
286    #[serde(default)]
287    pub std: StdComponentInfo,
288    /// Extra namespaces (third-party extensions).
289    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
290    pub extra: HashMap<String, serde_json::Value>,
291}
292
293/// Well-known component metadata under the `std` namespace.
294#[non_exhaustive]
295#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
296pub struct StdComponentInfo {
297    #[serde(default)]
298    pub name: String,
299    #[serde(default)]
300    pub version: String,
301    #[serde(default)]
302    pub description: String,
303    #[serde(
304        rename = "default-language",
305        default,
306        skip_serializing_if = "Option::is_none"
307    )]
308    pub default_language: Option<String>,
309    #[serde(default, skip_serializing_if = "Capabilities::is_empty")]
310    pub capabilities: Capabilities,
311}
312
313impl ComponentInfo {
314    pub fn new(
315        name: impl Into<String>,
316        version: impl Into<String>,
317        description: impl Into<String>,
318    ) -> Self {
319        Self {
320            std: StdComponentInfo {
321                name: name.into(),
322                version: version.into(),
323                description: description.into(),
324                ..Default::default()
325            },
326            ..Default::default()
327        }
328    }
329
330    // Convenience accessors for backward compatibility.
331    pub fn name(&self) -> &str {
332        &self.std.name
333    }
334    pub fn version(&self) -> &str {
335        &self.std.version
336    }
337    pub fn description(&self) -> &str {
338        &self.std.description
339    }
340}
341
342// ── Error type ──
343
344/// Error type mapping to ACT `tool-error`.
345#[derive(Debug, Clone)]
346pub struct ActError {
347    pub kind: String,
348    pub message: String,
349}
350
351impl ActError {
352    pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
353        Self {
354            kind: kind.into(),
355            message: message.into(),
356        }
357    }
358
359    pub fn not_found(message: impl Into<String>) -> Self {
360        Self::new(ERR_NOT_FOUND, message)
361    }
362
363    pub fn invalid_args(message: impl Into<String>) -> Self {
364        Self::new(ERR_INVALID_ARGS, message)
365    }
366
367    pub fn internal(message: impl Into<String>) -> Self {
368        Self::new(ERR_INTERNAL, message)
369    }
370
371    pub fn timeout(message: impl Into<String>) -> Self {
372        Self::new(ERR_TIMEOUT, message)
373    }
374
375    pub fn capability_denied(message: impl Into<String>) -> Self {
376        Self::new(ERR_CAPABILITY_DENIED, message)
377    }
378}
379
380impl std::fmt::Display for ActError {
381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        write!(f, "{}: {}", self.kind, self.message)
383    }
384}
385
386impl std::error::Error for ActError {}
387
388/// Result type for ACT operations.
389pub type ActResult<T> = Result<T, ActError>;
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use serde_json::json;
395
396    #[test]
397    fn localized_string_plain() {
398        let ls = LocalizedString::plain("hello");
399        assert_eq!(ls.resolve("en"), "hello");
400        assert_eq!(ls.any_text(), "hello");
401    }
402
403    #[test]
404    fn localized_string_from_str() {
405        let ls = LocalizedString::from("hello");
406        assert_eq!(ls.any_text(), "hello");
407    }
408
409    #[test]
410    fn localized_string_default() {
411        let ls = LocalizedString::default();
412        assert_eq!(ls.any_text(), "");
413    }
414
415    #[test]
416    fn localized_string_resolve_by_lang() {
417        let mut map = std::collections::HashMap::new();
418        map.insert("en".to_string(), "hello".to_string());
419        map.insert("ru".to_string(), "привет".to_string());
420        let ls = LocalizedString::Localized(map);
421        assert_eq!(ls.resolve("ru"), "привет");
422        assert_eq!(ls.resolve("en"), "hello");
423        // Unknown lang falls back to some entry
424        assert!(!ls.resolve("fr").is_empty());
425    }
426
427    #[test]
428    fn localized_string_resolve_prefix() {
429        let mut map = HashMap::new();
430        map.insert("zh-Hans".to_string(), "你好".to_string());
431        map.insert("en".to_string(), "hello".to_string());
432        let ls = LocalizedString::Localized(map);
433        assert_eq!(ls.resolve("zh"), "你好");
434    }
435
436    #[test]
437    fn localized_string_get() {
438        let ls = LocalizedString::new("en", "hello");
439        assert_eq!(ls.get("en"), Some("hello"));
440        assert_eq!(ls.get("ru"), None);
441    }
442
443    #[test]
444    fn localized_string_from_vec() {
445        let v = vec![("en".to_string(), "hi".to_string())];
446        let ls = LocalizedString::from(v);
447        assert_eq!(ls.resolve("en"), "hi");
448    }
449
450    #[test]
451    fn metadata_insert_and_get() {
452        let mut m = Metadata::new();
453        m.insert("std:read-only", true);
454        assert_eq!(m.get("std:read-only"), Some(&json!(true)));
455        assert_eq!(m.get_as::<bool>("std:read-only"), Some(true));
456    }
457
458    #[test]
459    fn metadata_to_json_empty() {
460        let json: serde_json::Value = Metadata::new().into();
461        assert_eq!(json, json!({}));
462    }
463
464    #[test]
465    fn metadata_to_json_with_values() {
466        let mut m = Metadata::new();
467        m.insert("std:read-only", true);
468        let json: serde_json::Value = m.into();
469        assert_eq!(json["std:read-only"], json!(true));
470    }
471
472    #[test]
473    fn metadata_from_vec() {
474        let v = vec![("key".to_string(), cbor::to_cbor(&42u32))];
475        let m = Metadata::from(v);
476        assert_eq!(m.get("key"), Some(&json!(42)));
477        assert_eq!(m.get_as::<u32>("key"), Some(42));
478    }
479
480    #[test]
481    fn capabilities_cbor_roundtrip() {
482        let mut info = ComponentInfo::new("test", "0.1.0", "test component");
483        info.std.capabilities.http = Some(HttpCap {});
484        info.std.capabilities.filesystem = Some(FilesystemCap {
485            mount_root: Some("/data".to_string()),
486        });
487
488        let mut buf = Vec::new();
489        ciborium::into_writer(&info, &mut buf).unwrap();
490
491        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
492        assert!(decoded.std.capabilities.http.is_some());
493        assert!(decoded.std.capabilities.filesystem.is_some());
494        assert!(decoded.std.capabilities.sockets.is_none());
495        assert_eq!(decoded.std.capabilities.fs_mount_root(), Some("/data"));
496    }
497
498    #[test]
499    fn capabilities_empty_roundtrip() {
500        let info = ComponentInfo::new("test", "0.1.0", "test");
501
502        let mut buf = Vec::new();
503        ciborium::into_writer(&info, &mut buf).unwrap();
504
505        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
506        assert!(decoded.std.capabilities.is_empty());
507    }
508
509    #[test]
510    fn capabilities_fs_no_params_roundtrip() {
511        let mut info = ComponentInfo::new("test", "0.1.0", "test");
512        info.std.capabilities.filesystem = Some(FilesystemCap::default());
513
514        let mut buf = Vec::new();
515        ciborium::into_writer(&info, &mut buf).unwrap();
516
517        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
518        assert!(decoded.std.capabilities.filesystem.is_some());
519        assert_eq!(decoded.std.capabilities.fs_mount_root(), None);
520    }
521
522    #[test]
523    fn capabilities_unknown_preserved() {
524        let mut info = ComponentInfo::new("test", "0.1.0", "test");
525        info.std
526            .capabilities
527            .other
528            .insert("acme:gpu".to_string(), json!({"cores": 8}));
529
530        let mut buf = Vec::new();
531        ciborium::into_writer(&info, &mut buf).unwrap();
532
533        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
534        assert!(decoded.std.capabilities.has("acme:gpu"));
535        assert_eq!(decoded.std.capabilities.other["acme:gpu"]["cores"], 8);
536    }
537}