Skip to main content

csharp_rs/
lib.rs

1// Rust guideline compliant 2026-03-15
2//! Generate C# type definitions from Rust structs and enums.
3//!
4//! `csharp-rs` provides a derive macro that generates C# class, record,
5//! or enum definitions from Rust types. It respects `serde` attributes
6//! for JSON serialization compatibility, making it ideal for sharing
7//! types between a Rust backend and a C#/.NET or Unity client.
8//!
9//! # Examples
10//!
11//! ```
12//! use csharp_rs::CSharp;
13//!
14//! #[derive(CSharp)]
15//! #[csharp(namespace = "Game.Types")]
16//! pub struct PlayerProfile {
17//!     pub name: String,
18//!     pub level: i32,
19//!     pub score: Option<f64>,
20//! }
21//! ```
22
23use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25
26/// Re-export of the derive macro from `csharp-rs-macros`.
27#[doc(inline)]
28pub use csharp_rs_macros::CSharp;
29
30// ---------------------------------------------------------------------------
31// Configuration enums
32// ---------------------------------------------------------------------------
33
34/// Which JSON serializer library to target in generated C# code.
35#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
36pub enum Serializer {
37    /// `System.Text.Json` attributes (default).
38    #[default]
39    SystemTextJson,
40    /// `Newtonsoft.Json` attributes.
41    Newtonsoft,
42}
43
44/// Target C# language version — controls which syntax features are used.
45#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
46pub enum CSharpVersion {
47    /// Unity C# 9.0 — `sealed class` + `{ get; set; }`, no records or init-only setters.
48    Unity,
49    /// C# 9.0 (default) — positional records, block-scoped namespaces.
50    #[default]
51    CSharp9,
52    /// C# 10.0 — file-scoped namespaces.
53    CSharp10,
54    /// C# 11.0 — `required` modifier, native `[JsonPolymorphic]`.
55    CSharp11,
56    /// C# 12.0 — primary constructors.
57    CSharp12,
58}
59
60impl std::fmt::Display for CSharpVersion {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        let s = match self {
63            Self::Unity => "Unity",
64            Self::CSharp9 => "9.0",
65            Self::CSharp10 => "10.0",
66            Self::CSharp11 => "11.0",
67            Self::CSharp12 => "12.0",
68        };
69        f.write_str(s)
70    }
71}
72
73impl CSharpVersion {
74    /// Whether the target supports file-scoped namespaces (C# 10+, not Unity).
75    #[must_use]
76    pub fn supports_file_scoped_namespace(self) -> bool {
77        self >= Self::CSharp10
78    }
79
80    /// Whether the target supports the `required` modifier (C# 11+, not Unity).
81    #[must_use]
82    pub fn supports_required_modifier(self) -> bool {
83        self >= Self::CSharp11
84    }
85
86    /// Whether the target uses `record` types. Unity uses `class` instead.
87    #[must_use]
88    pub fn uses_records(self) -> bool {
89        self != Self::Unity
90    }
91}
92
93/// A validated C# namespace (e.g. `"Company.Product"`).
94///
95/// Each segment must start with an ASCII letter or underscore and contain
96/// only ASCII alphanumeric characters or underscores.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct CSharpNamespace(String);
99
100impl CSharpNamespace {
101    /// Creates a new validated namespace.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error message if the namespace is empty, contains empty
106    /// segments, or has segments with invalid characters.
107    pub fn new(value: impl Into<String>) -> Result<Self, &'static str> {
108        let s = value.into();
109        validate_namespace(&s)?;
110        Ok(Self(s))
111    }
112}
113
114impl std::fmt::Display for CSharpNamespace {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.write_str(&self.0)
117    }
118}
119
120impl AsRef<str> for CSharpNamespace {
121    fn as_ref(&self) -> &str {
122        &self.0
123    }
124}
125
126impl PartialEq<&str> for CSharpNamespace {
127    fn eq(&self, other: &&str) -> bool {
128        self.0 == *other
129    }
130}
131
132/// Validates a C# namespace string.
133fn validate_namespace(ns: &str) -> Result<(), &'static str> {
134    if ns.is_empty() {
135        return Err("namespace must not be empty");
136    }
137    for segment in ns.split('.') {
138        if segment.is_empty() {
139            return Err("namespace must not contain empty segments");
140        }
141        let mut chars = segment.chars();
142        let first = chars.next().expect("segment is non-empty");
143        if !first.is_ascii_alphabetic() && first != '_' {
144            return Err("each segment must start with a letter or underscore");
145        }
146        if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
147            return Err("segments must contain only letters, digits, or underscores");
148        }
149    }
150    Ok(())
151}
152
153// ---------------------------------------------------------------------------
154// Runtime configuration
155// ---------------------------------------------------------------------------
156
157/// Runtime configuration for C# code generation.
158///
159/// Controls namespace, serializer library, C# language version, and export
160/// directory. Construct with [`Config::default`] and customize with builder
161/// methods.
162///
163/// # Examples
164///
165/// ```
166/// use csharp_rs::{Config, Serializer, CSharpVersion};
167///
168/// let cfg = Config::default()
169///     .with_serializer(Serializer::Newtonsoft)
170///     .with_target(CSharpVersion::CSharp11);
171/// ```
172#[derive(Debug)]
173pub struct Config {
174    namespace: CSharpNamespace,
175    serializer: Serializer,
176    target: CSharpVersion,
177    export_dir: PathBuf,
178}
179
180impl Default for Config {
181    fn default() -> Self {
182        Self {
183            namespace: CSharpNamespace::new("Generated").expect("default namespace is valid"),
184            serializer: Serializer::SystemTextJson,
185            target: CSharpVersion::CSharp9,
186            export_dir: PathBuf::from("./csharp-bindings"),
187        }
188    }
189}
190
191impl Config {
192    /// Creates a configuration from environment variables.
193    ///
194    /// Reads the following environment variables, falling back to defaults
195    /// for missing or invalid values:
196    ///
197    /// - `CSHARP_RS_EXPORT_DIR` — output directory (default: `"./csharp-bindings"`)
198    /// - `CSHARP_RS_SERIALIZER` — `"stj"` or `"newtonsoft"` (default: `"stj"`)
199    /// - `CSHARP_RS_TARGET` — `"unity"`, `"9"`, `"10"`, `"11"`, `"12"` (default: `"9"`)
200    /// - `CSHARP_RS_NAMESPACE` — C# namespace (default: `"Generated"`)
201    #[must_use]
202    pub fn from_env() -> Self {
203        let mut cfg = Self::default();
204
205        if let Ok(dir) = std::env::var("CSHARP_RS_EXPORT_DIR") {
206            cfg.export_dir = PathBuf::from(dir);
207        }
208
209        if let Ok(serializer) = std::env::var("CSHARP_RS_SERIALIZER") {
210            if serializer.as_str() == "newtonsoft" {
211                cfg.serializer = Serializer::Newtonsoft;
212            }
213        }
214
215        if let Ok(target) = std::env::var("CSHARP_RS_TARGET") {
216            match target.as_str() {
217                "unity" => cfg.target = CSharpVersion::Unity,
218                "9" => cfg.target = CSharpVersion::CSharp9,
219                "10" => cfg.target = CSharpVersion::CSharp10,
220                "11" => cfg.target = CSharpVersion::CSharp11,
221                "12" => cfg.target = CSharpVersion::CSharp12,
222                _ => {} // unknown value, keep default
223            }
224        }
225
226        if let Ok(ns) = std::env::var("CSHARP_RS_NAMESPACE") {
227            if let Ok(validated) = CSharpNamespace::new(ns) {
228                cfg.namespace = validated;
229            }
230        }
231
232        cfg
233    }
234
235    /// Sets the root namespace. Panics if the value is not a valid C#
236    /// namespace.
237    ///
238    /// # Panics
239    ///
240    /// Panics if `ns` fails [`CSharpNamespace`] validation.
241    #[must_use]
242    pub fn with_namespace(mut self, ns: &str) -> Self {
243        self.namespace =
244            CSharpNamespace::new(ns).unwrap_or_else(|e| panic!("invalid namespace \"{ns}\": {e}"));
245        self
246    }
247
248    /// Sets the root namespace from a pre-validated [`CSharpNamespace`].
249    #[must_use]
250    pub fn with_validated_namespace(mut self, ns: CSharpNamespace) -> Self {
251        self.namespace = ns;
252        self
253    }
254
255    /// Sets the target serializer library.
256    #[must_use]
257    pub fn with_serializer(mut self, serializer: Serializer) -> Self {
258        self.serializer = serializer;
259        self
260    }
261
262    /// Sets the target C# language version.
263    #[must_use]
264    pub fn with_target(mut self, target: CSharpVersion) -> Self {
265        self.target = target;
266        self
267    }
268
269    /// Sets the export directory for generated `.cs` files.
270    #[must_use]
271    pub fn with_export_dir(mut self, dir: impl Into<PathBuf>) -> Self {
272        self.export_dir = dir.into();
273        self
274    }
275
276    /// Returns the configured namespace as a string slice.
277    #[must_use]
278    pub fn namespace(&self) -> &str {
279        self.namespace.as_ref()
280    }
281
282    /// Returns the configured serializer.
283    #[must_use]
284    pub fn serializer(&self) -> Serializer {
285        self.serializer
286    }
287
288    /// Returns the configured C# target version.
289    #[must_use]
290    pub fn target(&self) -> CSharpVersion {
291        self.target
292    }
293
294    /// Returns the configured export directory.
295    #[must_use]
296    pub fn export_dir(&self) -> &Path {
297        &self.export_dir
298    }
299}
300
301/// Metadata for a C# field, used by `#[serde(flatten)]` to inline properties.
302#[derive(Debug, Clone)]
303pub enum CSharpFieldInfo {
304    /// A regular property to inline into the parent record.
305    Property {
306        /// C# property name (`PascalCase`).
307        property_name: String,
308        /// JSON serialization key.
309        json_name: String,
310        /// Resolved C# type name (e.g. `"string"`, `"int"`).
311        type_name: String,
312        /// Whether the field is nullable.
313        is_optional: bool,
314    },
315    /// An extension data container (from flattened `HashMap`).
316    ExtensionData {
317        /// C# key type name (typically `"string"`).
318        key_type_name: String,
319        /// C# value type name.
320        value_type_name: String,
321    },
322}
323
324/// Generates a C# type definition as a string.
325///
326/// Implementors produce a complete `.cs` file content including
327/// `using` directives, namespace declaration, and type definition.
328pub trait CSharp {
329    /// Returns the C# type name (e.g., `"int"`, `"MyStruct"`).
330    fn csharp_name(cfg: &Config) -> String;
331
332    /// Returns the complete `.cs` file content for this type, or empty for
333    /// primitives / generics.
334    fn csharp_definition(cfg: &Config) -> String;
335
336    /// Returns C# type names this type depends on (for transitive export).
337    fn dependencies(cfg: &Config) -> Vec<String>;
338
339    /// Returns metadata about this type's fields (used by `#[serde(flatten)]`).
340    ///
341    /// Only meaningful for struct types. Primitives, generics, and enums
342    /// return an empty vec (the default implementation).
343    #[must_use]
344    fn csharp_fields(_cfg: &Config) -> Vec<CSharpFieldInfo> {
345        Vec::new()
346    }
347}
348
349/// Writes the C# definition of `T` to `path`.
350///
351/// Creates parent directories if they do not exist.
352///
353/// # Errors
354///
355/// Returns an I/O error if the file cannot be written.
356pub fn export_to<T: CSharp>(cfg: &Config, path: impl AsRef<Path>) -> std::io::Result<()> {
357    let path = path.as_ref();
358    if let Some(parent) = path.parent() {
359        std::fs::create_dir_all(parent)?;
360    }
361    std::fs::write(path, T::csharp_definition(cfg))
362}
363
364// ---------------------------------------------------------------------------
365// Primitive type mappings
366// ---------------------------------------------------------------------------
367
368macro_rules! impl_csharp_primitive {
369    ($rust_ty:ty, $csharp_name:expr) => {
370        impl CSharp for $rust_ty {
371            fn csharp_name(_cfg: &Config) -> String {
372                String::from($csharp_name)
373            }
374
375            fn csharp_definition(_cfg: &Config) -> String {
376                // Primitives have no standalone definition.
377                String::new()
378            }
379
380            fn dependencies(_cfg: &Config) -> Vec<String> {
381                Vec::new()
382            }
383        }
384    };
385}
386
387impl_csharp_primitive!(String, "string");
388impl_csharp_primitive!(bool, "bool");
389
390// Signed integers
391impl_csharp_primitive!(i8, "sbyte");
392impl_csharp_primitive!(i16, "short");
393impl_csharp_primitive!(i32, "int");
394impl_csharp_primitive!(i64, "long");
395// C# `decimal` (128-bit, 96-bit mantissa) cannot represent all `i128` values.
396// `System.Int128` requires .NET 7+ / C# 11+, outside the default C# 9.0 target.
397impl_csharp_primitive!(i128, "decimal");
398
399// Unsigned integers
400impl_csharp_primitive!(u8, "byte");
401impl_csharp_primitive!(u16, "ushort");
402impl_csharp_primitive!(u32, "uint");
403impl_csharp_primitive!(u64, "ulong");
404// C# `decimal` (128-bit, 96-bit mantissa) cannot represent all `u128` values.
405// `System.UInt128` requires .NET 7+ / C# 11+, outside the default C# 9.0 target.
406impl_csharp_primitive!(u128, "decimal");
407
408// Floating point
409impl_csharp_primitive!(f32, "float");
410impl_csharp_primitive!(f64, "double");
411
412// ---------------------------------------------------------------------------
413// Feature-gated external type impls
414// ---------------------------------------------------------------------------
415
416#[cfg(feature = "uuid-impl")]
417impl_csharp_primitive!(uuid::Uuid, "Guid");
418
419#[cfg(feature = "chrono-impl")]
420mod chrono_impl;
421
422#[cfg(feature = "serde-json-impl")]
423mod serde_json_impl;
424
425// ---------------------------------------------------------------------------
426// Generic type mappings
427// ---------------------------------------------------------------------------
428
429/// Returns the inner type name without a nullable suffix.
430///
431/// Nullability (`?`) is handled by the derive macro via the `is_optional`
432/// flag in codegen, not by the trait. Calling `<Option<i32>>::csharp_name()`
433/// returns `"int"`, not `"int?"`.
434impl<T: CSharp> CSharp for Option<T> {
435    fn csharp_name(cfg: &Config) -> String {
436        T::csharp_name(cfg)
437    }
438
439    fn csharp_definition(_cfg: &Config) -> String {
440        String::new()
441    }
442
443    fn dependencies(cfg: &Config) -> Vec<String> {
444        vec![T::csharp_name(cfg)]
445    }
446}
447
448impl<T: CSharp> CSharp for Vec<T> {
449    fn csharp_name(cfg: &Config) -> String {
450        format!("List<{}>", T::csharp_name(cfg))
451    }
452
453    fn csharp_definition(_cfg: &Config) -> String {
454        String::new()
455    }
456
457    fn dependencies(cfg: &Config) -> Vec<String> {
458        vec![T::csharp_name(cfg)]
459    }
460}
461
462impl<K: CSharp, V: CSharp, S: std::hash::BuildHasher> CSharp for HashMap<K, V, S> {
463    fn csharp_name(cfg: &Config) -> String {
464        format!(
465            "Dictionary<{}, {}>",
466            K::csharp_name(cfg),
467            V::csharp_name(cfg)
468        )
469    }
470
471    fn csharp_definition(_cfg: &Config) -> String {
472        String::new()
473    }
474
475    fn dependencies(cfg: &Config) -> Vec<String> {
476        vec![K::csharp_name(cfg), V::csharp_name(cfg)]
477    }
478}
479
480impl<T: CSharp, S: std::hash::BuildHasher> CSharp for HashSet<T, S> {
481    fn csharp_name(cfg: &Config) -> String {
482        format!("HashSet<{}>", T::csharp_name(cfg))
483    }
484
485    fn csharp_definition(_cfg: &Config) -> String {
486        String::new()
487    }
488
489    fn dependencies(cfg: &Config) -> Vec<String> {
490        vec![T::csharp_name(cfg)]
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn serializer_default_is_system_text_json() {
500        assert_eq!(Serializer::default(), Serializer::SystemTextJson);
501    }
502
503    #[test]
504    fn csharp_version_default_is_csharp9() {
505        assert_eq!(CSharpVersion::default(), CSharpVersion::CSharp9);
506    }
507
508    #[test]
509    fn csharp_version_ordering() {
510        assert!(CSharpVersion::Unity < CSharpVersion::CSharp9);
511        assert!(CSharpVersion::CSharp9 < CSharpVersion::CSharp10);
512        assert!(CSharpVersion::CSharp10 < CSharpVersion::CSharp11);
513        assert!(CSharpVersion::CSharp11 < CSharpVersion::CSharp12);
514    }
515
516    #[test]
517    fn csharp_version_display() {
518        assert_eq!(CSharpVersion::Unity.to_string(), "Unity");
519        assert_eq!(CSharpVersion::CSharp9.to_string(), "9.0");
520        assert_eq!(CSharpVersion::CSharp10.to_string(), "10.0");
521        assert_eq!(CSharpVersion::CSharp11.to_string(), "11.0");
522        assert_eq!(CSharpVersion::CSharp12.to_string(), "12.0");
523    }
524
525    #[test]
526    fn unity_does_not_support_file_scoped_namespace() {
527        assert!(!CSharpVersion::Unity.supports_file_scoped_namespace());
528    }
529
530    #[test]
531    fn unity_does_not_support_required_modifier() {
532        assert!(!CSharpVersion::Unity.supports_required_modifier());
533    }
534
535    #[test]
536    fn unity_does_not_use_records() {
537        assert!(!CSharpVersion::Unity.uses_records());
538    }
539
540    #[test]
541    fn csharp9_uses_records() {
542        assert!(CSharpVersion::CSharp9.uses_records());
543    }
544
545    #[test]
546    fn csharp10_supports_file_scoped() {
547        assert!(CSharpVersion::CSharp10.supports_file_scoped_namespace());
548    }
549
550    #[test]
551    fn csharp11_supports_all_features() {
552        assert!(CSharpVersion::CSharp11.supports_file_scoped_namespace());
553        assert!(CSharpVersion::CSharp11.supports_required_modifier());
554        assert!(CSharpVersion::CSharp11.uses_records());
555    }
556
557    #[test]
558    fn namespace_valid_single_segment() {
559        let ns = CSharpNamespace::new("MyGame").unwrap();
560        assert_eq!(ns.as_ref(), "MyGame");
561    }
562
563    #[test]
564    fn namespace_valid_multi_segment() {
565        let ns = CSharpNamespace::new("Company.Product.Module").unwrap();
566        assert_eq!(ns.as_ref(), "Company.Product.Module");
567    }
568
569    #[test]
570    fn namespace_underscore_prefix_valid() {
571        assert!(CSharpNamespace::new("_Internal").is_ok());
572    }
573
574    #[test]
575    fn namespace_invalid_empty() {
576        assert!(CSharpNamespace::new("").is_err());
577    }
578
579    #[test]
580    fn namespace_invalid_starts_with_digit() {
581        assert!(CSharpNamespace::new("1Invalid").is_err());
582    }
583
584    #[test]
585    fn namespace_invalid_special_chars() {
586        assert!(CSharpNamespace::new("My-Namespace").is_err());
587    }
588
589    #[test]
590    fn namespace_invalid_empty_segment() {
591        assert!(CSharpNamespace::new("A..B").is_err());
592    }
593
594    #[test]
595    fn namespace_display() {
596        let ns = CSharpNamespace::new("Test.Ns").unwrap();
597        assert_eq!(ns.to_string(), "Test.Ns");
598    }
599
600    #[test]
601    fn namespace_partial_eq_str() {
602        let ns = CSharpNamespace::new("Generated").unwrap();
603        assert_eq!(ns, "Generated");
604    }
605
606    #[test]
607    fn config_default_values() {
608        let cfg = Config::default();
609        assert_eq!(cfg.namespace(), "Generated");
610        assert_eq!(cfg.serializer(), Serializer::SystemTextJson);
611        assert_eq!(cfg.target(), CSharpVersion::CSharp9);
612        assert_eq!(cfg.export_dir(), Path::new("./csharp-bindings"));
613    }
614
615    #[test]
616    fn config_with_serializer() {
617        let cfg = Config::default().with_serializer(Serializer::Newtonsoft);
618        assert_eq!(cfg.serializer(), Serializer::Newtonsoft);
619    }
620
621    #[test]
622    fn config_with_target() {
623        let cfg = Config::default().with_target(CSharpVersion::CSharp12);
624        assert_eq!(cfg.target(), CSharpVersion::CSharp12);
625    }
626
627    #[test]
628    fn config_with_namespace() {
629        let cfg = Config::default().with_namespace("My.Game");
630        assert_eq!(cfg.namespace(), "My.Game");
631    }
632
633    #[test]
634    #[should_panic(expected = "each segment must start with a letter")]
635    fn config_with_namespace_panics_on_invalid() {
636        let _ = Config::default().with_namespace("1Bad");
637    }
638
639    #[test]
640    fn config_with_validated_namespace() {
641        let ns = CSharpNamespace::new("Pre.Validated").unwrap();
642        let cfg = Config::default().with_validated_namespace(ns);
643        assert_eq!(cfg.namespace(), "Pre.Validated");
644    }
645
646    #[test]
647    fn config_with_export_dir() {
648        let cfg = Config::default().with_export_dir("./output");
649        assert_eq!(cfg.export_dir(), Path::new("./output"));
650    }
651
652    #[test]
653    fn config_builder_chaining() {
654        let cfg = Config::default()
655            .with_namespace("Unity.Types")
656            .with_serializer(Serializer::Newtonsoft)
657            .with_target(CSharpVersion::CSharp11)
658            .with_export_dir("./generated");
659        assert_eq!(cfg.namespace(), "Unity.Types");
660        assert_eq!(cfg.serializer(), Serializer::Newtonsoft);
661        assert_eq!(cfg.target(), CSharpVersion::CSharp11);
662        assert_eq!(cfg.export_dir(), Path::new("./generated"));
663    }
664
665    // NOTE: Tests that call `std::env::set_var` / `remove_var` are NOT
666    // thread-safe.  Run with `--test-threads=1` if they start flaking.
667
668    #[test]
669    fn from_env_defaults_match_default() {
670        let cfg = Config::from_env();
671        let default = Config::default();
672        assert_eq!(cfg.namespace(), default.namespace());
673        assert_eq!(cfg.serializer(), default.serializer());
674        assert_eq!(cfg.target(), default.target());
675        assert_eq!(cfg.export_dir(), default.export_dir());
676    }
677
678    #[test]
679    fn from_env_reads_serializer() {
680        // SAFETY: single-threaded test; no concurrent env access.
681        unsafe { std::env::set_var("CSHARP_RS_SERIALIZER", "newtonsoft") };
682        let cfg = Config::from_env();
683        assert_eq!(cfg.serializer(), Serializer::Newtonsoft);
684        // SAFETY: single-threaded test; restoring env to original state.
685        unsafe { std::env::remove_var("CSHARP_RS_SERIALIZER") };
686    }
687
688    #[test]
689    fn from_env_reads_target_unity() {
690        // SAFETY: single-threaded test; no concurrent env access.
691        unsafe { std::env::set_var("CSHARP_RS_TARGET", "unity") };
692        let cfg = Config::from_env();
693        assert_eq!(cfg.target(), CSharpVersion::Unity);
694        // SAFETY: single-threaded test; restoring env to original state.
695        unsafe { std::env::remove_var("CSHARP_RS_TARGET") };
696    }
697
698    #[test]
699    fn from_env_reads_target_version() {
700        // SAFETY: single-threaded test; no concurrent env access.
701        unsafe { std::env::set_var("CSHARP_RS_TARGET", "11") };
702        let cfg = Config::from_env();
703        assert_eq!(cfg.target(), CSharpVersion::CSharp11);
704        // SAFETY: single-threaded test; restoring env to original state.
705        unsafe { std::env::remove_var("CSHARP_RS_TARGET") };
706    }
707
708    #[test]
709    fn from_env_reads_namespace() {
710        // SAFETY: single-threaded test; no concurrent env access.
711        unsafe { std::env::set_var("CSHARP_RS_NAMESPACE", "Game.Types") };
712        let cfg = Config::from_env();
713        assert_eq!(cfg.namespace(), "Game.Types");
714        // SAFETY: single-threaded test; restoring env to original state.
715        unsafe { std::env::remove_var("CSHARP_RS_NAMESPACE") };
716    }
717
718    #[test]
719    fn from_env_reads_export_dir() {
720        // SAFETY: single-threaded test; no concurrent env access.
721        unsafe { std::env::set_var("CSHARP_RS_EXPORT_DIR", "/tmp/csharp-out") };
722        let cfg = Config::from_env();
723        assert_eq!(cfg.export_dir(), Path::new("/tmp/csharp-out"));
724        // SAFETY: single-threaded test; restoring env to original state.
725        unsafe { std::env::remove_var("CSHARP_RS_EXPORT_DIR") };
726    }
727
728    #[test]
729    fn from_env_invalid_namespace_falls_back() {
730        // SAFETY: single-threaded test; no concurrent env access.
731        unsafe { std::env::set_var("CSHARP_RS_NAMESPACE", "123invalid") };
732        let cfg = Config::from_env();
733        assert_eq!(cfg.namespace(), "Generated");
734        // SAFETY: single-threaded test; restoring env to original state.
735        unsafe { std::env::remove_var("CSHARP_RS_NAMESPACE") };
736    }
737
738    #[test]
739    fn from_env_unknown_serializer_falls_back() {
740        // SAFETY: single-threaded test; no concurrent env access.
741        unsafe { std::env::set_var("CSHARP_RS_SERIALIZER", "protobuf") };
742        let cfg = Config::from_env();
743        assert_eq!(cfg.serializer(), Serializer::SystemTextJson);
744        // SAFETY: single-threaded test; restoring env to original state.
745        unsafe { std::env::remove_var("CSHARP_RS_SERIALIZER") };
746    }
747
748    #[test]
749    fn string_maps_to_csharp_string() {
750        let cfg = Config::default();
751        assert_eq!(String::csharp_name(&cfg), "string");
752    }
753
754    #[test]
755    fn bool_maps_to_csharp_bool() {
756        let cfg = Config::default();
757        assert_eq!(bool::csharp_name(&cfg), "bool");
758    }
759
760    #[test]
761    fn integer_type_mappings() {
762        let cfg = Config::default();
763        assert_eq!(i8::csharp_name(&cfg), "sbyte");
764        assert_eq!(i16::csharp_name(&cfg), "short");
765        assert_eq!(i32::csharp_name(&cfg), "int");
766        assert_eq!(i64::csharp_name(&cfg), "long");
767        assert_eq!(i128::csharp_name(&cfg), "decimal");
768        assert_eq!(u8::csharp_name(&cfg), "byte");
769        assert_eq!(u16::csharp_name(&cfg), "ushort");
770        assert_eq!(u32::csharp_name(&cfg), "uint");
771        assert_eq!(u64::csharp_name(&cfg), "ulong");
772        assert_eq!(u128::csharp_name(&cfg), "decimal");
773    }
774
775    #[test]
776    fn float_type_mappings() {
777        let cfg = Config::default();
778        assert_eq!(f32::csharp_name(&cfg), "float");
779        assert_eq!(f64::csharp_name(&cfg), "double");
780    }
781
782    #[test]
783    fn option_unwraps_inner_type() {
784        let cfg = Config::default();
785        assert_eq!(<Option<i32>>::csharp_name(&cfg), "int");
786    }
787
788    #[test]
789    fn vec_maps_to_list() {
790        let cfg = Config::default();
791        assert_eq!(<Vec<String>>::csharp_name(&cfg), "List<string>");
792    }
793
794    #[test]
795    fn hashmap_maps_to_dictionary() {
796        let cfg = Config::default();
797        assert_eq!(
798            <HashMap<String, i32>>::csharp_name(&cfg),
799            "Dictionary<string, int>"
800        );
801    }
802
803    #[test]
804    fn hashset_maps_to_hashset() {
805        let cfg = Config::default();
806        assert_eq!(<HashSet<String>>::csharp_name(&cfg), "HashSet<string>");
807    }
808
809    #[test]
810    fn nested_generics() {
811        let cfg = Config::default();
812        assert_eq!(<Vec<Option<i32>>>::csharp_name(&cfg), "List<int>");
813        assert_eq!(
814            <HashMap<String, Vec<f64>>>::csharp_name(&cfg),
815            "Dictionary<string, List<double>>"
816        );
817    }
818
819    // --- primitive csharp_definition / dependencies coverage ---
820
821    #[test]
822    fn primitive_definition_is_empty() {
823        let cfg = Config::default();
824        assert!(String::csharp_definition(&cfg).is_empty());
825        assert!(bool::csharp_definition(&cfg).is_empty());
826        assert!(i32::csharp_definition(&cfg).is_empty());
827        assert!(u64::csharp_definition(&cfg).is_empty());
828        assert!(f64::csharp_definition(&cfg).is_empty());
829    }
830
831    #[test]
832    fn primitive_dependencies_is_empty() {
833        let cfg = Config::default();
834        assert!(String::dependencies(&cfg).is_empty());
835        assert!(bool::dependencies(&cfg).is_empty());
836        assert!(i32::dependencies(&cfg).is_empty());
837        assert!(u64::dependencies(&cfg).is_empty());
838        assert!(f64::dependencies(&cfg).is_empty());
839    }
840
841    // --- generic csharp_definition / dependencies coverage ---
842
843    #[test]
844    fn option_definition_is_empty() {
845        let cfg = Config::default();
846        assert!(<Option<i32>>::csharp_definition(&cfg).is_empty());
847    }
848
849    #[test]
850    fn option_dependencies_contains_inner() {
851        let cfg = Config::default();
852        let deps = <Option<i32>>::dependencies(&cfg);
853        assert_eq!(deps, vec!["int"]);
854    }
855
856    #[test]
857    fn vec_definition_is_empty() {
858        let cfg = Config::default();
859        assert!(<Vec<String>>::csharp_definition(&cfg).is_empty());
860    }
861
862    #[test]
863    fn vec_dependencies_contains_inner() {
864        let cfg = Config::default();
865        let deps = <Vec<String>>::dependencies(&cfg);
866        assert_eq!(deps, vec!["string"]);
867    }
868
869    #[test]
870    fn hashmap_definition_is_empty() {
871        let cfg = Config::default();
872        assert!(<HashMap<String, i32>>::csharp_definition(&cfg).is_empty());
873    }
874
875    #[test]
876    fn hashmap_dependencies_contains_key_and_value() {
877        let cfg = Config::default();
878        let deps = <HashMap<String, i32>>::dependencies(&cfg);
879        assert_eq!(deps, vec!["string", "int"]);
880    }
881
882    #[test]
883    fn hashset_definition_is_empty() {
884        let cfg = Config::default();
885        assert!(<HashSet<String>>::csharp_definition(&cfg).is_empty());
886    }
887
888    #[test]
889    fn hashset_dependencies_contains_inner() {
890        let cfg = Config::default();
891        let deps = <HashSet<String>>::dependencies(&cfg);
892        assert_eq!(deps, vec!["string"]);
893    }
894
895    // --- csharp_fields coverage ---
896
897    #[test]
898    fn primitive_csharp_fields_is_empty() {
899        let cfg = Config::default();
900        assert!(String::csharp_fields(&cfg).is_empty());
901        assert!(i32::csharp_fields(&cfg).is_empty());
902        assert!(bool::csharp_fields(&cfg).is_empty());
903    }
904
905    #[test]
906    fn generic_csharp_fields_is_empty() {
907        let cfg = Config::default();
908        assert!(<Vec<String>>::csharp_fields(&cfg).is_empty());
909        assert!(<Option<i32>>::csharp_fields(&cfg).is_empty());
910        assert!(<HashMap<String, i32>>::csharp_fields(&cfg).is_empty());
911        assert!(<HashSet<String>>::csharp_fields(&cfg).is_empty());
912    }
913
914    // --- export_to coverage ---
915
916    #[test]
917    fn export_to_writes_file() {
918        let cfg = Config::default();
919        let dir = std::env::temp_dir().join("csharp_rs_test_export");
920        let _ = std::fs::remove_dir_all(&dir);
921        let path = dir.join("sub").join("Test.cs");
922
923        export_to::<i32>(&cfg, &path).expect("export_to should succeed");
924
925        let content = std::fs::read_to_string(&path).expect("file should exist");
926        // Primitives have empty definitions
927        assert!(content.is_empty());
928
929        // Cleanup
930        let _ = std::fs::remove_dir_all(&dir);
931    }
932
933    // --- uuid feature-gated tests ---
934
935    #[cfg(feature = "uuid-impl")]
936    #[test]
937    fn uuid_maps_to_guid() {
938        let cfg = Config::default();
939        assert_eq!(<uuid::Uuid as CSharp>::csharp_name(&cfg), "Guid");
940        assert!(<uuid::Uuid as CSharp>::csharp_definition(&cfg).is_empty());
941        assert!(<uuid::Uuid as CSharp>::dependencies(&cfg).is_empty());
942    }
943}
944
945// ---------------------------------------------------------------------------
946// Feature-gated external type tests
947// ---------------------------------------------------------------------------
948
949#[cfg(feature = "chrono-impl")]
950#[cfg(test)]
951mod chrono_tests {
952    use super::*;
953
954    #[test]
955    fn datetime_utc_maps_to_datetimeoffset() {
956        let cfg = Config::default();
957        assert_eq!(
958            <chrono::DateTime<chrono::Utc> as CSharp>::csharp_name(&cfg),
959            "DateTimeOffset"
960        );
961    }
962
963    #[test]
964    fn naive_date_maps_to_dateonly() {
965        let cfg = Config::default();
966        assert_eq!(<chrono::NaiveDate as CSharp>::csharp_name(&cfg), "DateOnly");
967    }
968
969    #[test]
970    fn naive_time_maps_to_timeonly() {
971        let cfg = Config::default();
972        assert_eq!(<chrono::NaiveTime as CSharp>::csharp_name(&cfg), "TimeOnly");
973    }
974
975    #[test]
976    fn naive_datetime_maps_to_datetime() {
977        let cfg = Config::default();
978        assert_eq!(
979            <chrono::NaiveDateTime as CSharp>::csharp_name(&cfg),
980            "DateTime"
981        );
982    }
983
984    #[test]
985    fn duration_maps_to_timespan() {
986        let cfg = Config::default();
987        assert_eq!(<chrono::Duration as CSharp>::csharp_name(&cfg), "TimeSpan");
988    }
989}
990
991#[cfg(feature = "serde-json-impl")]
992#[cfg(test)]
993mod serde_json_tests {
994    use super::*;
995
996    #[test]
997    fn serde_json_value_stj_maps_to_json_element() {
998        let cfg = Config::default();
999        assert_eq!(
1000            <serde_json::Value as CSharp>::csharp_name(&cfg),
1001            "JsonElement"
1002        );
1003    }
1004
1005    #[test]
1006    fn serde_json_value_newtonsoft_maps_to_jtoken() {
1007        let cfg = Config::default().with_serializer(Serializer::Newtonsoft);
1008        assert_eq!(<serde_json::Value as CSharp>::csharp_name(&cfg), "JToken");
1009    }
1010
1011    #[test]
1012    fn serde_json_number_maps_to_double() {
1013        let cfg = Config::default();
1014        assert_eq!(<serde_json::Number as CSharp>::csharp_name(&cfg), "double");
1015    }
1016
1017    #[test]
1018    fn serde_json_value_definition_is_empty() {
1019        let cfg = Config::default();
1020        assert!(<serde_json::Value as CSharp>::csharp_definition(&cfg).is_empty());
1021    }
1022}