Repository: facet-rs/facet (Rust)

Issue:
facet-yaml: skip_serializing_if not honored
When using `#[facet(skip_serializing_if = Option::is_none)]` on optional fields, facet-yaml still serializes them as `null`.

Example:
```rust
#[derive(Facet)]
struct Step {
    #[facet(default, skip_serializing_if = Option::is_none)]
    pub run: Option<String>,
}
```

Expected YAML (when `run` is `None`):
```yaml
name: Checkout
uses: actions/checkout@v4
```

Actual YAML:
```yaml
name: Checkout
uses: actions/checkout@v4
run: null
```

This is problematic for generating GitHub Actions workflows where explicit `null` values can cause issues.


Discussion:

Patch:
diff --git a/facet-yaml/src/serialize.rs b/facet-yaml/src/serialize.rs
index da5cadeab..c9e2ad7dd 100644
--- a/facet-yaml/src/serialize.rs
+++ b/facet-yaml/src/serialize.rs
@@ -326,19 +326,11 @@ impl<W: Write> YamlSerializer<W> {
     ) -> Result<()> {
         let mut first = true;
 
-        for (field, field_peek) in struct_peek.fields() {
-            // Skip fields with skip_serializing attribute (no value check here)
-            if field.has_builtin_attr("skip") || field.has_builtin_attr("skip_serializing") {
-                continue;
-            }
-
-            // Check skip_serializing_if
-            if let Some(_skip_fn) = field.skip_serializing_if_fn() {
-                // Get the raw pointer to the field value
-                // This is tricky - for now, skip this optimization
-                // TODO: implement skip_serializing_if properly
-            }
-
+        // Use fields_for_serialize() which properly handles:
+        // - skip_serializing attribute
+        // - skip_serializing_if predicate
+        // - flatten attribute
+        for (field_item, field_peek) in struct_peek.fields_for_serialize() {
             if first && !is_root {
                 writeln!(self.writer)
                     .map_err(|e| YamlError::without_span(YamlErrorKind::Io(e.to_string())))?;
@@ -351,7 +343,7 @@ impl<W: Write> YamlSerializer<W> {
 
             self.write_indent(indent)?;
             // Use serialized name (respecting rename attribute)
-            let serialized_name = get_serialized_field_name(&field);
+            let serialized_name = get_serialized_field_name(&field_item.field);
             self.write_key(serialized_name)?;
             write!(self.writer, ": ")
                 .map_err(|e| YamlError::without_span(YamlErrorKind::Io(e.to_string())))?;


Test patch:
diff --git a/facet-yaml/tests/serialize/struct_.rs b/facet-yaml/tests/serialize/struct_.rs
index 8928d19cb..286ad6c0c 100644
--- a/facet-yaml/tests/serialize/struct_.rs
+++ b/facet-yaml/tests/serialize/struct_.rs
@@ -190,3 +190,84 @@ fn test_optional_default_struct_fields() -> Result<()> {
 
     Ok(())
 }
+
+#[test]
+fn test_skip_serializing_if_none() -> Result<()> {
+    facet_testhelpers::setup();
+
+    #[derive(Debug, Facet, PartialEq)]
+    struct Step {
+        name: String,
+        #[facet(default, skip_serializing_if = Option::is_none)]
+        run: Option<String>,
+    }
+
+    // When run is None, it should be omitted from the output
+    let step = Step {
+        name: "Checkout".to_string(),
+        run: None,
+    };
+    let yaml = facet_yaml::to_string(&step)?;
+    assert!(
+        !yaml.contains("run"),
+        "run field should be omitted when None"
+    );
+    assert!(
+        yaml.contains("name: Checkout"),
+        "name field should be present"
+    );
+
+    // When run is Some, it should be included
+    let step_with_run = Step {
+        name: "Build".to_string(),
+        run: Some("cargo build".to_string()),
+    };
+    let yaml_with_run = facet_yaml::to_string(&step_with_run)?;
+    assert!(
+        yaml_with_run.contains("run: cargo build"),
+        "run field should be present when Some"
+    );
+
+    Ok(())
+}
+
+#[test]
+fn test_skip_serializing_if_custom_predicate() -> Result<()> {
+    facet_testhelpers::setup();
+
+    #[allow(clippy::ptr_arg)] // see https://github.com/facet-rs/facet/issues/1036
+    fn is_empty(s: &String) -> bool {
+        s.is_empty()
+    }
+
+    #[derive(Debug, Facet, PartialEq)]
+    struct Config {
+        name: String,
+        #[facet(default, skip_serializing_if = is_empty)]
+        description: String,
+    }
+
+    // When description is empty, it should be omitted
+    let config = Config {
+        name: "test".to_string(),
+        description: String::new(),
+    };
+    let yaml = facet_yaml::to_string(&config)?;
+    assert!(
+        !yaml.contains("description"),
+        "description should be omitted when empty"
+    );
+
+    // When description is not empty, it should be included
+    let config_with_desc = Config {
+        name: "test".to_string(),
+        description: "A test config".to_string(),
+    };
+    let yaml_with_desc = facet_yaml::to_string(&config_with_desc)?;
+    assert!(
+        yaml_with_desc.contains("description: A test config"),
+        "description should be present when not empty"
+    );
+
+    Ok(())
+}