Repository: cedar-policy/cedar (rust)

Issue:
Addition of `Entities::num_of_entities` function similar to `PolicySet::num_of_policies`
### Category

User level API features/changes

### Describe the feature you'd like to request

A `PolicySet` has a `num_of_policies` function that gets the size of the policy set in constant time. I'd like to do the same to entities, for logging purposes, without iterating through every entity using `.iter`. 

### Describe alternatives you've considered

You could use `Entities::iter` and then run `.count`, but that's linear time and consumes the iterator. 

### Additional context

_No response_

### Is this something that you'd be interested in working on?

- [ ] 👋 I may be able to implement this feature request
- [ ] ⚠️ This feature might incur a breaking change

Pull request:
Add `Entities::len` and `Entities::is_empty`
## Description of changes

Add the `Entities::len` method requested in #1523. This new API returns the number of `Entity`s in the `Entities`.
Also add the `Entities::is_empty` method as recommended by `clippy`.

The PR uses both `len` and `is_empty` in some existing unit tests and includes new tests specific to them.

Resolves #1523 

## Checklist for requesting a review

The change in this PR is (choose one, and delete the other options):

- [ ] A breaking change requiring a major version bump to `cedar-policy` (e.g., changes to the signature of an existing API).
- [x] A backwards-compatible change requiring a minor version bump to `cedar-policy` (e.g., addition of a new API).
- [ ] A bug fix or other functionality change requiring a patch to `cedar-policy`.
- [ ] A change "invisible" to users (e.g., documentation, changes to "internal" crates like `cedar-policy-core`, `cedar-validator`, etc.)
- [ ] A change (breaking or otherwise) that only impacts unreleased or experimental code.

I confirm that this PR (choose one, and delete the other options):

- [x] Updates the "Unreleased" section of the CHANGELOG with a description of my change (required for major/minor version bumps).
- [ ] Does not update the CHANGELOG because my change does not significantly impact released code.

I confirm that [`cedar-spec`](https://github.com/cedar-policy/cedar-spec) (choose one, and delete the other options):

- [ ] Does not require updates because my change does not impact the Cedar formal model or DRT infrastructure.
- [ ] Requires updates, and I have made / will make these updates myself. (Please include in your description a timeline or link to the relevant PR in `cedar-spec`, and how you have tested that your updates are correct.)
- [ ] Requires updates, but I do not plan to make them in the near future. (Make sure that your changes are hidden behind a feature flag to mark them as experimental.)
- [x] I'm not sure how my change impacts `cedar-spec`. (Post your PR anyways, and we'll discuss in the comments.)

I confirm that [`docs.cedarpolicy.com`](https://docs.cedarpolicy.com/) (choose one, and delete the other options):

- [ ] Does not require updates because my change does not impact the Cedar language specification.
- [x] Requires updates, and I have made / will make these updates myself. (Please include in your description a timeline or link to the relevant PR in [`cedar-docs`](https://github.com/cedar-policy/cedar-docs). PRs should be targeted at a `staging-X.Y` branch, not `main`.)
- [ ] I'm not sure how my change impacts the documentation. (Post your PR anyways, and we'll discuss in the comments.)


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 2f9396ad..8101f5f5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -255,9 +255,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
 
 [[package]]
 name = "cc"
-version = "1.2.17"
+version = "1.2.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a"
+checksum = "525046617d8376e3db1deffb079e91cef90a89fc3ca5c185bbf8c9ecdd15cd5c"
 dependencies = [
  "shlex",
 ]
@@ -473,9 +473,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "4.5.34"
+version = "4.5.35"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e958897981290da2a852763fe9cdb89cd36977a5d729023127095fa94d95e2ff"
+checksum = "d8aa86934b44c19c50f87cc2790e19f54f7a67aedb64101c2e1a2e5ecfb73944"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -483,9 +483,9 @@ dependencies = [
 
 [[package]]
 name = "clap_builder"
-version = "4.5.34"
+version = "4.5.35"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83b0f35019843db2160b5bb19ae09b4e6411ac33fc6a712003c33e03090e2489"
+checksum = "2414dbb2dd0695280da6ea9261e327479e9d37b0630f6b53ba2a11c60c679fd9"
 dependencies = [
  "anstream",
  "anstyle",
@@ -677,9 +677,9 @@ dependencies = [
 
 [[package]]
 name = "deranged"
-version = "0.4.1"
+version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28cfac68e08048ae1883171632c2aef3ebc555621ae56fbccce1cbf22dd7f058"
+checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
 dependencies = [
  "powerfmt",
  "serde",
@@ -810,9 +810,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
 
 [[package]]
 name = "errno"
-version = "0.3.10"
+version = "0.3.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d"
+checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e"
 dependencies = [
  "libc",
  "windows-sys 0.59.0",
@@ -1026,9 +1026,9 @@ dependencies = [
 
 [[package]]
 name = "iana-time-zone"
-version = "0.1.62"
+version = "0.1.63"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2fd658b06e56721792c5df4475705b6cda790e9298d19d2f8af083457bcd127"
+checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
 dependencies = [
  "android_system_properties",
  "core-foundation-sys",
@@ -1341,9 +1341,9 @@ dependencies = [
 
 [[package]]
 name = "miniz_oxide"
-version = "0.8.5"
+version = "0.8.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5"
+checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430"
 dependencies = [
  "adler2",
 ]
@@ -1640,9 +1640,9 @@ dependencies = [
 
 [[package]]
 name = "prettyplease"
-version = "0.2.31"
+version = "0.2.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5316f57387668042f561aae71480de936257848f9c43ce528e311d89a07cadeb"
+checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6"
 dependencies = [
  "proc-macro2",
  "syn 2.0.100",
@@ -1823,9 +1823,9 @@ dependencies = [
 
 [[package]]
 name = "redox_syscall"
-version = "0.5.10"
+version = "0.5.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1"
+checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3"
 dependencies = [
  "bitflags",
 ]
@@ -1947,9 +1947,9 @@ dependencies = [
 
 [[package]]
 name = "rustix"
-version = "1.0.3"
+version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e56a18552996ac8d29ecc3b190b4fdbb2d91ca4ec396de7bbffaf43f3d637e96"
+checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf"
 dependencies = [
  "bitflags",
  "errno",
@@ -2152,9 +2152,9 @@ dependencies = [
 
 [[package]]
 name = "smallvec"
-version = "1.14.0"
+version = "1.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd"
+checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
 
 [[package]]
 name = "smol_str"
@@ -2680,11 +2680,37 @@ dependencies = [
 
 [[package]]
 name = "windows-core"
-version = "0.52.0"
+version = "0.61.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
+checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
 dependencies = [
- "windows-targets",
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.100",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.100",
 ]
 
 [[package]]
@@ -2693,6 +2719,24 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
 
+[[package]]
+name = "windows-result"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97"
+dependencies = [
+ "windows-link",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.52.0"
diff --git a/cedar-policy-core/src/entities.rs b/cedar-policy-core/src/entities.rs
index ce5f5edd..738a69be 100644
--- a/cedar-policy-core/src/entities.rs
+++ b/cedar-policy-core/src/entities.rs
@@ -253,6 +253,16 @@ impl Entities {
         })
     }
 
+    /// Returns the length of the `Entities` object
+    pub fn len(&self) -> usize {
+        self.entities.len()
+    }
+
+    /// Returns `true` if the `Entities` object is empty
+    pub fn is_empty(&self) -> bool {
+        self.entities.is_empty()
+    }
+
     /// Convert an `Entities` object into a JSON value suitable for parsing in
     /// via `EntityJsonParser`.
     ///
@@ -2079,6 +2089,34 @@ mod entities_tests {
         )
     }
 
+    #[test]
+    fn test_len() {
+        let (e0, e1, e2, e3) = test_entities();
+        let v = vec![e0.clone(), e1.clone(), e2.clone(), e3.clone()];
+        let es = Entities::from_entities(
+            v,
+            None::<&NoEntitiesSchema>,
+            TCComputation::ComputeNow,
+            Extensions::all_available(),
+        )
+        .expect("Failed to construct entities");
+        assert_eq!(es.len(), 4);
+        assert!(!es.is_empty());
+    }
+
+    #[test]
+    fn test_is_empty() {
+        let es = Entities::from_entities(
+            vec![],
+            None::<&NoEntitiesSchema>,
+            TCComputation::ComputeNow,
+            Extensions::all_available(),
+        )
+        .expect("Failed to construct entities");
+        assert_eq!(es.len(), 0);
+        assert!(es.is_empty());
+    }
+
     #[test]
     fn test_iter() {
         let (e0, e1, e2, e3) = test_entities();
diff --git a/cedar-policy/CHANGELOG.md b/cedar-policy/CHANGELOG.md
index cccd9fd9..89f9e8e4 100644
--- a/cedar-policy/CHANGELOG.md
+++ b/cedar-policy/CHANGELOG.md
@@ -26,7 +26,6 @@ Cedar Language Version: TBD
   edge cases, policies that previously failed to validate under strict validation
   will now pass validation, probably with an `ImpossiblePolicy` warning. (#1355,
   resolving #638)
-- Added `PartialResponse::unknown_entities` method (#1557)
 
 ### Added
 
@@ -45,6 +44,8 @@ Cedar Language Version: TBD
   are valid for a particular policy or template.) (#1547)
 - Added `EntityId::unescaped()`, analogous to `EntityId::escaped()`. This is simply an
   alias for `EntityId::as_ref()` with the `AsRef` impl that produces `&str`. (#1555)
+- Added `PartialResponse::unknown_entities` method (#1557)
+- Added `Entities::len` and `Entities::is_empty` methods (#1562, resolving #1523)
 
 ## [4.3.3] - 2025-02-25
 
diff --git a/cedar-policy/src/api.rs b/cedar-policy/src/api.rs
index a7009391..d847b1eb 100644
--- a/cedar-policy/src/api.rs
+++ b/cedar-policy/src/api.rs
@@ -776,6 +776,16 @@ impl Entities {
         Some(entity.ancestors().map(EntityUid::ref_cast))
     }
 
+    /// Returns the number of `Entity`s in the `Entities`
+    pub fn len(&self) -> usize {
+        self.0.len()
+    }
+
+    /// Returns true if the `Entities` contains no `Entity`s
+    pub fn is_empty(&self) -> bool {
+        self.0.is_empty()
+    }
+
     /// Dump an `Entities` object into an entities JSON file.
     ///
     /// The resulting JSON will be suitable for parsing in via


Test patch:
diff --git a/cedar-policy/src/test/test.rs b/cedar-policy/src/test/test.rs
index 3e16b40d..c6765dbe 100644
--- a/cedar-policy/src/test/test.rs
+++ b/cedar-policy/src/test/test.rs
@@ -1389,6 +1389,8 @@ mod ancestors_tests {
         let b = Entity::new_no_attrs(b_euid.clone(), HashSet::from([a_euid.clone()]));
         let c = Entity::new_no_attrs(c_euid.clone(), HashSet::from([b_euid.clone()]));
         let es = Entities::from_entities([a, b, c], None).unwrap();
+        assert_eq!(es.len(), 3);
+        assert!(!es.is_empty());
         let ans = es.ancestors(&c_euid).unwrap().collect::<HashSet<_>>();
         assert_eq!(ans.len(), 2);
         assert!(ans.contains(&b_euid));
@@ -1449,9 +1451,9 @@ mod entity_validate_tests {
         .expect("should be a valid schema")
     }
 
-    fn validate_entity(entity: Entity, schema: &Schema) -> Result<(), EntitiesError> {
-        let _ = Entities::from_entities([entity], Some(schema))?;
-        Ok(())
+    fn validate_entity(entity: Entity, schema: &Schema) -> Result<Entities, EntitiesError> {
+        let es = Entities::from_entities([entity], Some(schema))?;
+        Ok(es)
     }
 
     #[test]
@@ -1509,9 +1511,13 @@ mod entity_validate_tests {
             HashSet::new(),
         )
         .unwrap();
-        validate_entity(entity.clone(), &schema()).unwrap();
+        let es = validate_entity(entity.clone(), &schema()).unwrap();
+        // Note: `es` includes the action entity defined in the schema
+        assert_eq!(es.len(), 2);
         let (uid, attrs, parents) = entity.into_inner();
-        validate_entity(Entity::new(uid, attrs, parents).unwrap(), &schema()).unwrap();
+        let es = validate_entity(Entity::new(uid, attrs, parents).unwrap(), &schema()).unwrap();
+        // Note: `es` includes the action entity defined in the schema
+        assert_eq!(es.len(), 2);
     }
 
     #[test]
@@ -1571,7 +1577,7 @@ mod entity_validate_tests {
         )
         .unwrap();
         match validate_entity(entity, &schema) {
-            Ok(()) => panic!("expected an error due to extraneous parent"),
+            Ok(_) => panic!("expected an error due to extraneous parent"),
             Err(e) => {
                 expect_err(
                     "",
@@ -1636,7 +1642,7 @@ mod entity_validate_tests {
         )
         .unwrap();
         match validate_entity(entity, &schema) {
-            Ok(()) => panic!("expected an error due to missing attribute `numDirectReports`"),
+            Ok(_) => panic!("expected an error due to missing attribute `numDirectReports`"),
             Err(e) => {
                 expect_err(
                     "",
@@ -1703,7 +1709,7 @@ mod entity_validate_tests {
         )
         .unwrap();
         match validate_entity(entity, &schema) {
-            Ok(()) => panic!("expected an error due to extraneous attribute"),
+            Ok(_) => panic!("expected an error due to extraneous attribute"),
             Err(e) => {
                 expect_err(
                     "",
@@ -1717,7 +1723,7 @@ mod entity_validate_tests {
 
         let entity = Entity::new_no_attrs(EntityUid::from_strs("Manager", "jane"), HashSet::new());
         match validate_entity(entity, &schema) {
-            Ok(()) => panic!("expected an error due to unexpected entity type"),
+            Ok(_) => panic!("expected an error due to unexpected entity type"),
             Err(e) => {
                 expect_err(
                     "",
@@ -2754,6 +2760,7 @@ mod schema_based_parsing_tests {
         let parsed = Entities::from_json_value(entitiesjson.clone(), None)
             .expect("Should parse without error");
         assert_eq!(parsed.iter().count(), 1);
+        assert_eq!(parsed.len(), 1);
         let parsed = parsed
             .get(&EntityUid::from_strs("Employee", "12UA45"))
             .expect("that should be the employee id");
@@ -2791,6 +2798,7 @@ mod schema_based_parsing_tests {
         let parsed = Entities::from_json_value(entitiesjson, Some(&schema))
             .expect("Should parse without error");
         assert_eq!(parsed.iter().count(), 2); // Employee::"12UA45" and the one action
+        assert_eq!(parsed.len(), 2);
         assert_eq!(
             parsed
                 .iter()
@@ -3198,6 +3206,7 @@ mod schema_based_parsing_tests {
                 .count(),
             1
         );
+        assert_eq!(parsed.len(), 2);
         let parsed = parsed
             .get(&EntityUid::from_strs("XYZCorp::Employee", "12UA45"))
             .expect("that should be the employee type and id");
@@ -3289,6 +3298,7 @@ mod schema_based_parsing_tests {
                 .count(),
             1
         );
+        assert_eq!(parsed.len(), 2);
 
         // "department" shouldn't be required
         let entitiesjson = json!(
@@ -3313,6 +3323,7 @@ mod schema_based_parsing_tests {
                 .count(),
             1
         );
+        assert_eq!(parsed.len(), 2);
     }
 
     #[test]
@@ -3524,6 +3535,8 @@ mod schema_based_parsing_tests {
         let schema = Schema::from_schema_fragments([fragment]).unwrap();
         let action_entities = schema.action_entities().unwrap();
 
+        assert_eq!(action_entities.len(), 5);
+
         let a_euid = EntityUid::from_strs("Action", "A");
         let b_euid = EntityUid::from_strs("Action", "B");
         let c_euid = EntityUid::from_strs("Action", "C");