Repository: 1Password/typeshare (rust)

Issue:
Support specifying swift Generic Type Constraints
Consider the following struct:

```
#[typeshare]
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct AccountPayload<A> {
    pub id: String,
    pub payload: A
}
```

It is generated as: (we have Sendable in the typeshare.toml file)
```
public struct AccountPayload<A: Codable>: Codable, Sendable {
	public let id: String
	public let payload: A
…
	}
}
```

However, generic type `A` (and others) may also have another type constraint (in this case `Sendable` supplied) so that it needs to be generated as:
```
public struct CoreAccountMenuEntry<A: Codable & Sendable>: Codable, Sendable {
…
```

### Suggestion:

An additional decorator parameter in the toml:
`default_generic_decorators = ["Sendable"]`

And/or in the rust definitions itself:
`[typeshare(swift="Whatever", swift_generic="A:Sendable & AnotherProtocol,B:Sendable & SomeOtherProtocol")]` where A and B are generics specified in the struct. (I added B here in case there is more than one generic)


Pull request:
Configurable Generic Constraints for Swift
Resolves #86.

Default type constraints can now be specified for Swift within the config, using `default_generic_constraints = [...]` under `[swift]`. Constraint strings within the list can include multiple constraints, such as `Sendable & Identifiable`.

Patch:
diff --git a/cli/src/config.rs b/cli/src/config.rs
index 9c379ac..88ad9a4 100644
--- a/cli/src/config.rs
+++ b/cli/src/config.rs
@@ -31,6 +31,7 @@ pub struct SwiftParams {
     pub prefix: String,
     pub type_mappings: HashMap<String, String>,
     pub default_decorators: Vec<String>,
+    pub default_generic_constraints: Vec<String>,
 }
 
 #[derive(Default, Serialize, Deserialize, PartialEq, Eq, Debug)]
@@ -159,6 +160,15 @@ mod test {
         assert_eq!(config.swift.default_decorators[0], "Sendable");
     }
 
+    #[test]
+    fn constraints_test() {
+        let path = config_file_path("constraints_config.toml");
+        let config = load_config(Some(path)).unwrap();
+
+        assert_eq!(config.swift.default_generic_constraints.len(), 1);
+        assert_eq!(config.swift.default_generic_constraints[0], "Sendable");
+    }
+
     #[test]
     fn swift_prefix_test() {
         let path = config_file_path("swift_prefix_config.toml");
diff --git a/cli/src/main.rs b/cli/src/main.rs
index d9da38b..6a34b98 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -8,6 +8,7 @@ use ignore::types::TypesBuilder;
 use ignore::WalkBuilder;
 use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
 use std::{fs, path::Path};
+use typeshare_core::language::GenericConstraints;
 #[cfg(feature = "go")]
 use typeshare_core::language::Go;
 use typeshare_core::{
@@ -184,6 +185,9 @@ fn main() {
             prefix: config.swift.prefix,
             type_mappings: config.swift.type_mappings,
             default_decorators: config.swift.default_decorators,
+            default_generic_constraints: GenericConstraints::from_config(
+                config.swift.default_generic_constraints,
+            ),
             ..Default::default()
         }),
         Some(SupportedLanguage::Kotlin) => Box::new(Kotlin {
diff --git a/core/src/language/go.rs b/core/src/language/go.rs
index 9cd3a9d..a5936d4 100644
--- a/core/src/language/go.rs
+++ b/core/src/language/go.rs
@@ -81,7 +81,7 @@ impl Language for Go {
         Ok(match special_ty {
             SpecialRustType::Vec(rtype) => format!("[]{}", self.format_type(rtype, generic_types)?),
             SpecialRustType::Array(rtype, len) => {
-                format!("[{}]{}", len, self.format_type(rtype, generic_types)?)
+                format!("[{len}]{}", self.format_type(rtype, generic_types)?)
             }
             SpecialRustType::Option(rtype) => {
                 format!("*{}", self.format_type(rtype, generic_types)?)
diff --git a/core/src/language/mod.rs b/core/src/language/mod.rs
index 198a029..ebf5361 100644
--- a/core/src/language/mod.rs
+++ b/core/src/language/mod.rs
@@ -17,6 +17,7 @@ use crate::rust_types::{RustType, RustTypeFormatError, SpecialRustType};
 pub use go::Go;
 pub use kotlin::Kotlin;
 pub use scala::Scala;
+pub use swift::GenericConstraints;
 pub use swift::Swift;
 pub use typescript::TypeScript;
 
diff --git a/core/src/language/swift.rs b/core/src/language/swift.rs
index 936d3e1..d56e1a3 100644
--- a/core/src/language/swift.rs
+++ b/core/src/language/swift.rs
@@ -8,6 +8,7 @@ use crate::{
 use itertools::Itertools;
 use joinery::JoinableIterator;
 use lazy_format::lazy_format;
+use std::collections::BTreeSet;
 use std::{
     collections::HashMap,
     io::Write,
@@ -83,6 +84,49 @@ struct CodingKeysInfo {
     coding_keys: Vec<String>,
 }
 
+/// A container for generic constraints.
+#[derive(Debug, Clone)]
+pub struct GenericConstraints {
+    constraints: BTreeSet<String>,
+}
+
+impl GenericConstraints {
+    /// Create a container for generic constraints from a list of strings.
+    /// Each string will be broken up by `&`, the syntax that Swift uses to combine constraints,
+    /// and the complete list will be de-duplicated.
+    pub fn from_config(constraints: Vec<String>) -> Self {
+        Self {
+            constraints: std::iter::once(CODABLE.into())
+                .chain(constraints.into_iter().flat_map(Self::split_constraints))
+                .collect(),
+        }
+    }
+    /// Add a new constraint expression to this container.
+    /// This expression will be broken up by `&`, the syntax that Swift uses to combine constraints.
+    pub fn add(&mut self, constraints: String) {
+        for decorator in Self::split_constraints(constraints).into_iter() {
+            self.constraints.insert(decorator);
+        }
+    }
+    /// Get an iterator over all constraints.
+    pub fn get_constraints(&self) -> impl Iterator<Item = &String> {
+        self.constraints.iter()
+    }
+
+    fn split_constraints(constraints: String) -> Vec<String> {
+        constraints
+            .split('&')
+            .map(|s| s.trim().to_owned())
+            .collect()
+    }
+}
+
+impl Default for GenericConstraints {
+    fn default() -> Self {
+        Self::from_config(vec![])
+    }
+}
+
 /// All information needed to generate Swift type-code
 #[derive(Default)]
 pub struct Swift {
@@ -92,6 +136,8 @@ pub struct Swift {
     pub type_mappings: HashMap<String, String>,
     /// Default decorators that will be applied to all typeshared types
     pub default_decorators: Vec<String>,
+    /// Default type constraints that will be applied to all generic parameters of typeshared types
+    pub default_generic_constraints: GenericConstraints,
     /// Will be set to true if one of your typeshared Rust type contains the unit type `()`.
     /// This will add a definition of a `CodableVoid` type to the generated Swift code and
     /// use `CodableVoid` to replace `()`.
@@ -216,6 +262,7 @@ impl Language for Swift {
         // If there are no decorators found for this struct, still write `Codable` and default decorators for structs
         let mut decs = self.get_default_decorators();
 
+        let default_generic_constraints = self.default_generic_constraints.clone();
         // Check if this struct's decorators contains swift in the hashmap
         if let Some(swift_decs) = rs.decorators.get(&SupportedLanguage::Swift) {
             // For reach item in the received decorators in the typeshared struct add it to the original vector
@@ -227,12 +274,26 @@ impl Language for Swift {
                 .for_each(|d| decs.push(d.clone()));
         }
 
+        let generic_constraint_string = default_generic_constraints.get_constraints().join(" & ");
+
         writeln!(
             w,
             "public struct {}{}: {} {{",
             type_name,
             (!rs.generic_types.is_empty())
-                .then(|| format!("<{}: Codable>", rs.generic_types.join(": Codable, ")))
+                .then(|| format!(
+                    "<{}>",
+                    rs.generic_types
+                        .iter()
+                        .map(|t| format!(
+                            "{}{}",
+                            t,
+                            (!generic_constraint_string.is_empty())
+                                .then(|| format!(": {}", generic_constraint_string))
+                                .unwrap_or_default()
+                        ))
+                        .join(", ")
+                ))
                 .unwrap_or_default(),
             decs.join(", ")
         )?;


Test patch:
diff --git a/cli/data/tests/constraints_config.toml b/cli/data/tests/constraints_config.toml
new file mode 100644
index 0000000..58fa54a
--- /dev/null
+++ b/cli/data/tests/constraints_config.toml
@@ -0,0 +1,2 @@
+[swift]
+default_generic_constraints = ["Sendable"]
\ No newline at end of file
diff --git a/core/data/tests/generates_configured_generic_constraints/input.rs b/core/data/tests/generates_configured_generic_constraints/input.rs
new file mode 100644
index 0000000..e69de29
diff --git a/core/data/tests/generates_configured_generic_constraints/output.swift b/core/data/tests/generates_configured_generic_constraints/output.swift
new file mode 100644
index 0000000..fecc4ab
--- /dev/null
+++ b/core/data/tests/generates_configured_generic_constraints/output.swift
@@ -0,0 +1,1 @@
+import Foundation
diff --git a/core/data/tests/test_default_generic_constraints/input.rs b/core/data/tests/test_default_generic_constraints/input.rs
new file mode 100644
index 0000000..429c6ae
--- /dev/null
+++ b/core/data/tests/test_default_generic_constraints/input.rs
@@ -0,0 +1,4 @@
+#[typeshare]
+struct GenericType<T> {
+    field: T
+}
\ No newline at end of file
diff --git a/core/data/tests/test_default_generic_constraints/output.swift b/core/data/tests/test_default_generic_constraints/output.swift
new file mode 100644
index 0000000..628a654
--- /dev/null
+++ b/core/data/tests/test_default_generic_constraints/output.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+public struct GenericType<T: Codable & Identifiable & Sendable>: Codable {
+	public let field: T
+
+	public init(field: T) {
+		self.field = field
+	}
+}
diff --git a/core/tests/snapshot_tests.rs b/core/tests/snapshot_tests.rs
index 117868c..92e91a5 100644
--- a/core/tests/snapshot_tests.rs
+++ b/core/tests/snapshot_tests.rs
@@ -454,6 +454,7 @@ tests! {
     // TODO: kotlin and typescript don't appear to support this yet
     generates_empty_structs_and_initializers: [swift, kotlin, scala, typescript, go];
     test_default_decorators: [swift { default_decorators: vec!["Sendable".into(), "Identifiable".into()]}];
+    test_default_generic_constraints: [swift { default_generic_constraints: typeshare_core::language::GenericConstraints::from_config(vec!["Sendable".into(), "Identifiable".into()]) }];
     test_i54_u53_type: [swift, kotlin, scala,  typescript, go];
     test_serde_default_struct: [swift, kotlin, scala,  typescript, go];
     test_serde_iso8601: [