Scaffold project tooling
Add justfile (fmt, clippy, coverage with 100% enforcement), CI workflow,
and release workflow (publish commented out). Update to edition 2024,
remove Cargo.lock (library convention), fix clippy warnings from the
edition upgrade.

Assisted-by: Claude Opus 4.6 via pi
change xzttnxlyyupuqqyuoqltozyztvrllrnx
commit 4924f3e06e358c279f07182d4ffdc1cd3404f3fc
author Alpha Chen <alpha@kejadlen.dev>
date
parent zttlwrpo
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2cef060
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,17 @@
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+
+jobs:
+  ci:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - run: rustup component add clippy rustfmt llvm-tools
+      - run: cargo install grcov
+      - run: cargo install just
+      - run: cargo fmt --check
+      - run: just clippy coverage
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..ddd3676
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,45 @@
+name: Release
+
+on:
+  workflow_dispatch:
+  workflow_run:
+    workflows: [CI]
+    types: [completed]
+    branches: [main]
+
+permissions:
+  contents: write
+
+jobs:
+  release:
+    if: >-
+      github.event_name == 'workflow_dispatch'
+      || github.event.workflow_run.conclusion == 'success'
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Calculate version
+        id: version
+        run: |
+          CALVER=$(date -u +"%Y-%m-%d")
+          SHORT_SHA=$(git rev-parse --short HEAD)
+          echo "version=${CALVER}+${SHORT_SHA}" >> $GITHUB_OUTPUT
+
+      # - name: Publish to crates.io
+      #   run: cargo publish
+      #   env:
+      #     CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+
+      - name: Tag release
+        run: |
+          VERSION="${{ steps.version.outputs.version }}"
+          git tag "v${VERSION}"
+          git push origin "v${VERSION}"
+          gh release create "v${VERSION}" \
+            --title "v${VERSION}" \
+            --generate-notes
+        env:
+          GH_TOKEN: ${{ github.token }}
diff --git a/.gitignore b/.gitignore
index eb5a316..96ef6c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
-target
+/target
+Cargo.lock
diff --git a/Cargo.lock b/Cargo.lock
deleted file mode 100644
index 2ad3f03..0000000
--- a/Cargo.lock
+++ /dev/null
@@ -1,224 +0,0 @@
-# This file is automatically @generated by Cargo.
-# It is not intended for manual editing.
-version = 4
-
-[[package]]
-name = "autocfg"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
-
-[[package]]
-name = "cfg-if"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
-
-[[package]]
-name = "kdl"
-version = "6.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e"
-dependencies = [
- "miette",
- "num",
- "winnow",
-]
-
-[[package]]
-name = "memchr"
-version = "2.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
-
-[[package]]
-name = "miette"
-version = "7.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
-dependencies = [
- "cfg-if",
- "unicode-width",
-]
-
-[[package]]
-name = "num"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
-dependencies = [
- "num-bigint",
- "num-complex",
- "num-integer",
- "num-iter",
- "num-rational",
- "num-traits",
-]
-
-[[package]]
-name = "num-bigint"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
-dependencies = [
- "num-integer",
- "num-traits",
-]
-
-[[package]]
-name = "num-complex"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "num-integer"
-version = "0.1.46"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "num-iter"
-version = "0.1.45"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
-dependencies = [
- "autocfg",
- "num-integer",
- "num-traits",
-]
-
-[[package]]
-name = "num-rational"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
-dependencies = [
- "num-bigint",
- "num-integer",
- "num-traits",
-]
-
-[[package]]
-name = "num-traits"
-version = "0.2.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
-name = "proc-macro2"
-version = "1.0.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "quote"
-version = "1.0.45"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
-dependencies = [
- "proc-macro2",
-]
-
-[[package]]
-name = "serde"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
-dependencies = [
- "serde_core",
- "serde_derive",
-]
-
-[[package]]
-name = "serde-kdl"
-version = "0.1.0"
-dependencies = [
- "kdl",
- "serde",
- "thiserror",
-]
-
-[[package]]
-name = "serde_core"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
-dependencies = [
- "serde_derive",
-]
-
-[[package]]
-name = "serde_derive"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "syn"
-version = "2.0.117"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "thiserror"
-version = "2.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
-dependencies = [
- "thiserror-impl",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "2.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "unicode-ident"
-version = "1.0.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
-
-[[package]]
-name = "unicode-width"
-version = "0.1.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
-
-[[package]]
-name = "winnow"
-version = "0.6.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
-dependencies = [
- "memchr",
-]
diff --git a/Cargo.toml b/Cargo.toml
index 28b8fac..18ab933 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
 [package]
 name = "serde-kdl"
 version = "0.1.0"
-edition = "2021"
+edition = "2024"
 description = "Serde integration for KDL (KDL Document Language)"
 license = "MIT OR Apache-2.0"
 repository = "https://github.com/kdl-org/kdl-rs"
@@ -15,3 +15,4 @@ thiserror = "2"
 
 [dev-dependencies]
 serde = { version = "1", features = ["derive"] }
+tempfile = "3"
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..cfcff33
--- /dev/null
+++ b/justfile
@@ -0,0 +1,45 @@
+default: all
+
+fmt:
+    cargo fmt --all
+
+check:
+    cargo check --workspace
+
+clippy:
+    cargo clippy --workspace -- -D warnings
+
+coverage:
+    #!/usr/bin/env bash
+    set -euo pipefail
+    export RUSTFLAGS="-Cinstrument-coverage"
+    export CARGO_TARGET_DIR="target/coverage"
+    export LLVM_PROFILE_FILE="target/coverage/profraw/%p-%m.profraw"
+    rm -rf target/coverage
+    cargo test --workspace -q
+    REPORT=$(grcov target/coverage/profraw \
+        --binary-path ./target/coverage/debug/ \
+        -s . \
+        -t covdir \
+        --ignore-not-existing \
+        --keep-only 'src/**' \
+        --excl-line 'cov-excl-line' \
+        --excl-start 'cov-excl-start' \
+        --excl-stop 'cov-excl-stop')
+    echo "$REPORT" | jq -r '
+        def files:
+            to_entries[] | .value |
+            if .children then .children | files
+            else "\(.name): \(.coveragePercent)% (\(.linesCovered)/\(.linesTotal))"
+            end;
+        .children | files
+    '
+    COVERAGE=$(echo "$REPORT" | jq '.coveragePercent')
+    echo ""
+    echo "Total: ${COVERAGE}%"
+    if [ "$(echo "$COVERAGE < 100" | bc -l)" -eq 1 ]; then
+        echo "ERROR: Coverage is below 100%"
+        exit 1
+    fi
+
+all: fmt clippy coverage
diff --git a/src/de.rs b/src/de.rs
index d38eae3..5231792 100644
--- a/src/de.rs
+++ b/src/de.rs
@@ -13,8 +13,8 @@
 //!   `shape { Circle { radius 5.0 } }`.
 
 use kdl::{KdlDocument, KdlNode, KdlValue};
-use serde::de::{self, DeserializeSeed, IntoDeserializer, MapAccess, SeqAccess, Visitor};
 use serde::Deserialize;
+use serde::de::{self, DeserializeSeed, IntoDeserializer, MapAccess, SeqAccess, Visitor};
 
 use crate::error::{Error, Result};
 
@@ -177,12 +177,10 @@ impl<'a> FieldDeserializer<'a> {
     }
 }
 
-fn first_arg_of<'a>(node: &'a KdlNode) -> Result<&'a KdlValue> {
-    node.get(0).ok_or_else(|| {
-        Error::TypeMismatch {
-            expected: "a value argument",
-            got: format!("node '{}' with no arguments", node.name().value()),
-        }
+fn first_arg_of(node: &KdlNode) -> Result<&KdlValue> {
+    node.get(0).ok_or_else(|| Error::TypeMismatch {
+        expected: "a value argument",
+        got: format!("node '{}' with no arguments", node.name().value()),
     })
 }
 
@@ -377,10 +375,7 @@ impl<'de, 'a> de::Deserializer<'de> for FieldDeserializer<'a> {
 
             // Arguments as elements
             let args = node_args(node);
-            visitor.visit_seq(ArgsSeqAccess {
-                args,
-                index: 0,
-            })
+            visitor.visit_seq(ArgsSeqAccess { args, index: 0 })
         }
     }
 
@@ -406,10 +401,7 @@ impl<'de, 'a> de::Deserializer<'de> for FieldDeserializer<'a> {
         // If node has properties, deserialize from properties
         let props = node_props(node);
         if !props.is_empty() {
-            return visitor.visit_map(PropsMapAccess {
-                props,
-                index: 0,
-            });
+            return visitor.visit_map(PropsMapAccess { props, index: 0 });
         }
         // Empty map
         visitor.visit_map(PropsMapAccess {
@@ -434,10 +426,7 @@ impl<'de, 'a> de::Deserializer<'de> for FieldDeserializer<'a> {
         // If node has properties, use them as struct fields
         let props = node_props(node);
         if !props.is_empty() {
-            return visitor.visit_map(PropsMapAccess {
-                props,
-                index: 0,
-            });
+            return visitor.visit_map(PropsMapAccess { props, index: 0 });
         }
 
         // If struct has a single field and node has a single argument,
@@ -469,14 +458,15 @@ impl<'de, 'a> de::Deserializer<'de> for FieldDeserializer<'a> {
 
         // Unit variant: first argument is variant name string
         // e.g., `color "Red"`
-        if !args.is_empty() && node.children().is_none() {
-            if let Some(s) = args[0].as_string() {
-                return visitor.visit_enum(EnumUnitAccess {
-                    variant: s,
-                    node,
-                    arg_offset: 1,
-                });
-            }
+        if !args.is_empty()
+            && node.children().is_none()
+            && let Some(s) = args[0].as_string()
+        {
+            return visitor.visit_enum(EnumUnitAccess {
+                variant: s,
+                node,
+                arg_offset: 1,
+            });
         }
 
         // Complex variant: child node named after variant
@@ -648,9 +638,7 @@ impl<'de, 'a> de::Deserializer<'de> for NodeContentDeserializer<'a> {
         let args = node_args(self.node);
         let props = node_props(self.node);
 
-        if self.node.children().is_some() {
-            self.deserialize_map(visitor)
-        } else if !props.is_empty() {
+        if self.node.children().is_some() || !props.is_empty() {
             self.deserialize_map(visitor)
         } else if args.len() == 1 {
             ValueDeserializer::new(args[0]).deserialize_any(visitor)
@@ -808,10 +796,7 @@ impl<'de, 'a> de::Deserializer<'de> for NodeContentDeserializer<'a> {
             return visitor.visit_map(DocumentMapAccess::new(children));
         }
         let props = node_props(self.node);
-        visitor.visit_map(PropsMapAccess {
-            props,
-            index: 0,
-        })
+        visitor.visit_map(PropsMapAccess { props, index: 0 })
     }
 
     fn deserialize_struct<V: Visitor<'de>>(
@@ -825,10 +810,7 @@ impl<'de, 'a> de::Deserializer<'de> for NodeContentDeserializer<'a> {
         }
         let props = node_props(self.node);
         if !props.is_empty() {
-            return visitor.visit_map(PropsMapAccess {
-                props,
-                index: 0,
-            });
+            return visitor.visit_map(PropsMapAccess { props, index: 0 });
         }
         // Single-arg struct
         let args = node_args(self.node);
@@ -854,14 +836,15 @@ impl<'de, 'a> de::Deserializer<'de> for NodeContentDeserializer<'a> {
         let args = node_args(self.node);
 
         // Unit variant: first argument is variant name
-        if !args.is_empty() && self.node.children().is_none() {
-            if let Some(s) = args[0].as_string() {
-                return visitor.visit_enum(EnumUnitAccess {
-                    variant: s,
-                    node: self.node,
-                    arg_offset: 1,
-                });
-            }
+        if !args.is_empty()
+            && self.node.children().is_none()
+            && let Some(s) = args[0].as_string()
+        {
+            return visitor.visit_enum(EnumUnitAccess {
+                variant: s,
+                node: self.node,
+                arg_offset: 1,
+            });
         }
 
         // Complex variant: child node named after variant
@@ -989,9 +972,7 @@ impl<'de, 'a> de::Deserializer<'de> for ValueDeserializer<'a> {
 
     fn deserialize_char<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value> {
         match self.value {
-            KdlValue::String(s) if s.len() == 1 => {
-                visitor.visit_char(s.chars().next().unwrap())
-            }
+            KdlValue::String(s) if s.len() == 1 => visitor.visit_char(s.chars().next().unwrap()),
             other => Err(Error::TypeMismatch {
                 expected: "single character",
                 got: format!("{other:?}"),
diff --git a/src/error.rs b/src/error.rs
index f03272c..ff4006c 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -17,10 +17,7 @@ pub enum Error {
 
     /// Expected a specific type but got something else.
     #[error("type mismatch: expected {expected}, got {got}")]
-    TypeMismatch {
-        expected: &'static str,
-        got: String,
-    },
+    TypeMismatch { expected: &'static str, got: String },
 
     /// A required field was missing.
     #[error("missing field: {0}")]
diff --git a/src/ser.rs b/src/ser.rs
index f715388..3b7861c 100644
--- a/src/ser.rs
+++ b/src/ser.rs
@@ -332,11 +332,7 @@ impl ser::Serializer for ValueSerializer {
         self.serialize_seq(Some(len))
     }
 
-    fn serialize_tuple_struct(
-        self,
-        _name: &'static str,
-        len: usize,
-    ) -> Result<SerializeSeq> {
+    fn serialize_tuple_struct(self, _name: &'static str, len: usize) -> Result<SerializeSeq> {
         self.serialize_seq(Some(len))
     }
 
@@ -441,10 +437,7 @@ impl ser::SerializeTupleVariant for SerializeTupleVariant {
     }
 
     fn end(self) -> Result<Value> {
-        Ok(Value::Map(vec![(
-            self.variant,
-            Value::Seq(self.elements),
-        )]))
+        Ok(Value::Map(vec![(self.variant, Value::Seq(self.elements))]))
     }
 }
 
@@ -466,7 +459,7 @@ impl ser::SerializeMap for SerializeMap {
             other => {
                 return Err(Error::Unsupported(format!(
                     "map key must be a string, got {other:?}"
-                )))
+                )));
             }
         };
         self.current_key = Some(key_str);
@@ -535,9 +528,6 @@ impl ser::SerializeStructVariant for SerializeStructVariant {
     }
 
     fn end(self) -> Result<Value> {
-        Ok(Value::Map(vec![(
-            self.variant,
-            Value::Map(self.entries),
-        )]))
+        Ok(Value::Map(vec![(self.variant, Value::Map(self.entries))]))
     }
 }