clawspec_core/client/response/redaction/
mod.rs

1//! JSON response redaction support using JSON Pointer (RFC 6901) and JSONPath (RFC 9535).
2//!
3//! This module provides functionality to redact sensitive or dynamic values
4//! in JSON responses for snapshot testing. It allows you to replace or remove
5//! values at specific paths while preserving the original data for test assertions.
6//!
7//! # Path Syntax
8//!
9//! The path syntax is auto-detected based on the prefix:
10//! - Paths starting with `$` use JSONPath (RFC 9535) - supports wildcards
11//! - Paths starting with `/` use JSON Pointer (RFC 6901) - exact paths only
12//!
13//! ## JSONPath Examples (wildcards)
14//!
15//! - `$.items[*].id` - all `id` fields in the `items` array
16//! - `$..id` - all `id` fields anywhere in the document (recursive descent)
17//! - `$.users[0:3].email` - `email` in first 3 users
18//!
19//! ## JSON Pointer Examples (exact paths)
20//!
21//! - `/id` - top-level `id` field
22//! - `/user/email` - nested field
23//! - `/items/0/id` - specific array index
24//!
25//! # Redactor Types
26//!
27//! The [`redact`](RedactionBuilder::redact) method accepts any type implementing [`Redactor`]:
28//!
29//! - **Static values**: `&str`, `String`, `serde_json::Value`
30//! - **Functions**: `Fn(&str, &Value) -> Value` - transform based on path and/or value
31//!
32//! # Example
33//!
34//! ```ignore
35//! use clawspec_core::ApiClient;
36//! use serde::{Deserialize, Serialize};
37//!
38//! #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
39//! struct User {
40//!     id: String,
41//!     name: String,
42//!     created_at: String,
43//! }
44//!
45//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
46//! let client = ApiClient::builder().with_base_path("http://localhost:8080".parse()?).build()?;
47//!
48//! // Using static values
49//! let result = client
50//!     .get("/api/users/123")?
51//!     .await?
52//!     .as_json_redacted::<User>().await?
53//!     .redact("/id", "stable-uuid")?
54//!     .redact("/created_at", "2024-01-01T00:00:00Z")?
55//!     .finish()
56//!     .await;
57//!
58//! // Using JSONPath wildcards with static values
59//! let result = client
60//!     .get("/api/users")?
61//!     .await?
62//!     .as_json_redacted::<Vec<User>>().await?
63//!     .redact("$[*].id", "redacted-uuid")?
64//!     .redact("$[*].created_at", "2024-01-01T00:00:00Z")?
65//!     .finish()
66//!     .await;
67//!
68//! // Using closure for index-based IDs
69//! let result = client
70//!     .get("/api/users")?
71//!     .await?
72//!     .as_json_redacted::<Vec<User>>().await?
73//!     .redact("$[*].id", |path, _val| {
74//!         let idx = path.split('/').nth(1).unwrap_or("0");
75//!         serde_json::json!(format!("user-{idx}"))
76//!     })?
77//!     .finish()
78//!     .await;
79//!
80//! // Test assertions use the real value
81//! assert!(!result.value.is_empty());
82//!
83//! // Snapshots use the redacted value (stable ids and timestamps)
84//! insta::assert_yaml_snapshot!(result.redacted);
85//! # Ok(())
86//! # }
87//! ```
88
89use std::any::{TypeId, type_name};
90
91use headers::ContentType;
92use http::StatusCode;
93use serde::de::DeserializeOwned;
94use serde_json::Deserializer;
95use utoipa::ToSchema;
96use utoipa::openapi::{RefOr, Schema};
97
98use super::output::Output;
99
100mod apply;
101mod path_selector;
102mod redactor;
103mod value_builder;
104
105pub use self::redactor::Redactor;
106pub use self::value_builder::ValueRedactionBuilder;
107use crate::client::CallResult;
108use crate::client::error::ApiClientError;
109use crate::client::openapi::channel::{CollectorMessage, CollectorSender};
110use crate::client::openapi::schema::{SchemaEntry, compute_schema_ref};
111
112impl CallResult {
113    /// Deserializes the JSON response and returns a builder for applying redactions.
114    ///
115    /// This method is similar to [`as_json()`](CallResult::as_json) but returns a
116    /// [`RedactionBuilder`](super::redaction::RedactionBuilder) that allows you to apply redactions
117    /// to the JSON before finalizing the result.
118    ///
119    /// The original value is preserved for test assertions, while the redacted
120    /// JSON can be used for snapshot testing with stable values.
121    ///
122    /// # Type Parameters
123    ///
124    /// * `T` - The type to deserialize into. Must implement [`DeserializeOwned`],
125    ///   [`ToSchema`], and have a `'static` lifetime.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if:
130    /// - The response is not JSON
131    /// - JSON deserialization fails
132    ///
133    /// # Example
134    ///
135    /// ```ignore
136    /// # use clawspec_core::ApiClient;
137    /// # use serde::{Deserialize, Serialize};
138    /// # #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
139    /// # struct User { id: String, name: String }
140    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
141    /// # let client = ApiClient::builder().with_base_path("http://localhost".parse()?).build()?;
142    /// let result = client
143    ///     .get("/api/users/123")?
144    ///     .await?
145    ///     .as_json_redacted::<User>().await?
146    ///     .redact("/id", "stable-uuid")?
147    ///     .finish()
148    ///     .await;
149    ///
150    /// // Use real value for assertions
151    /// assert!(!result.value.id.is_empty());
152    ///
153    /// // Use redacted value for snapshots
154    /// insta::assert_yaml_snapshot!(result.redacted);
155    /// # Ok(())
156    /// # }
157    /// ```
158    pub async fn as_json_redacted<T>(&mut self) -> Result<RedactionBuilder<T>, ApiClientError>
159    where
160        T: DeserializeOwned + ToSchema + 'static,
161    {
162        // Compute schema reference locally (no lock needed)
163        let schema = compute_schema_ref::<T>();
164
165        // Register the schema entry via channel
166        let entry = SchemaEntry::of::<T>();
167        self.collector_sender
168            .send(CollectorMessage::AddSchemaEntry(entry))
169            .await;
170
171        // Access output directly without calling get_output() to defer response registration
172        let Output::Json(json) = self.output() else {
173            return Err(ApiClientError::UnsupportedJsonOutput {
174                output: self.output().clone(),
175                name: type_name::<T>(),
176            });
177        };
178
179        // Delegate to redaction module with deferred registration data
180        // Response will be registered in finish() with the redacted example
181        let builder = super::redaction::create_redaction_builder::<T>(
182            json,
183            self.collector_sender.clone(),
184            self.operation_id().to_string(),
185            self.status(),
186            self.content_type().cloned(),
187            schema,
188        )?;
189
190        Ok(builder)
191    }
192}
193
194/// Result of a redacted JSON response containing both the real and redacted values.
195///
196/// This struct is returned by [`CallResult::as_json_redacted()`] and provides
197/// access to both the original deserialized value and the redacted JSON for
198/// snapshot testing.
199#[derive(Debug, Clone)]
200#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
201pub struct RedactedResult<T> {
202    /// The real deserialized value for test assertions.
203    pub value: T,
204    /// The redacted JSON value for snapshot testing.
205    pub redacted: serde_json::Value,
206}
207
208/// Options for configuring redaction behavior.
209///
210/// Use this struct with [`RedactionBuilder::redact_with_options`] and
211/// [`RedactionBuilder::redact_remove_with`] to customize how redaction
212/// handles edge cases.
213///
214/// # Example
215///
216/// ```ignore
217/// use clawspec_core::RedactOptions;
218///
219/// // Allow empty matches (useful for optional fields)
220/// let options = RedactOptions { allow_empty_match: true };
221///
222/// builder
223///     .redact_with_options("$.optional[*].field", "value", options)?
224///     .finish()
225///     .await;
226/// ```
227#[derive(Debug, Clone, Default)]
228#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
229pub struct RedactOptions {
230    /// If true, matching zero paths is not an error (silent no-op).
231    ///
232    /// By default (`false`), if a path matches nothing, an error is returned.
233    /// This helps catch typos in path expressions. Set to `true` when redacting
234    /// optional fields that may not always be present.
235    pub allow_empty_match: bool,
236}
237
238/// Builder for applying redactions to JSON responses.
239///
240/// This builder allows you to chain multiple redaction operations before
241/// finalizing the result. Paths can use either JSON Pointer (RFC 6901) or
242/// JSONPath (RFC 9535) syntax.
243///
244/// # Path Syntax
245///
246/// The syntax is auto-detected based on the path prefix:
247///
248/// ## JSON Pointer (starts with `/`)
249///
250/// - `/field` - top-level field
251/// - `/field/subfield` - nested field
252/// - `/array/0` - array index
253/// - `/field~1with~1slashes` - `~1` escapes `/`
254/// - `/field~0with~0tildes` - `~0` escapes `~`
255///
256/// ## JSONPath (starts with `$`)
257///
258/// - `$.field` - top-level field
259/// - `$.items[*].id` - all `id` fields in array
260/// - `$..id` - all `id` fields anywhere (recursive)
261/// - `$[0:3]` - array slice
262#[derive(derive_more::Debug)]
263#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
264pub struct RedactionBuilder<T> {
265    value: T,
266    redacted: serde_json::Value,
267    #[debug(skip)]
268    collector_sender: CollectorSender,
269    // Deferred response registration data
270    operation_id: String,
271    status: StatusCode,
272    content_type: Option<ContentType>,
273    schema: RefOr<Schema>,
274}
275
276impl<T> RedactionBuilder<T> {
277    /// Creates a new redaction builder with the original value and JSON.
278    pub(in crate::client) fn new(
279        value: T,
280        json: serde_json::Value,
281        collector_sender: CollectorSender,
282        operation_id: String,
283        status: StatusCode,
284        content_type: Option<ContentType>,
285        schema: RefOr<Schema>,
286    ) -> Self {
287        Self {
288            value,
289            redacted: json,
290            collector_sender,
291            operation_id,
292            status,
293            content_type,
294            schema,
295        }
296    }
297
298    /// Redacts values at the specified path using a redactor.
299    ///
300    /// The path can be either JSON Pointer (RFC 6901) or JSONPath (RFC 9535).
301    /// The syntax is auto-detected based on the prefix:
302    /// - `$...` → JSONPath (supports wildcards)
303    /// - `/...` → JSON Pointer (exact path)
304    ///
305    /// The redactor can be:
306    /// - A static value: `"replacement"` or `serde_json::json!(...)`
307    /// - A closure: `|path, val| transform(path, val)`
308    ///
309    /// # Arguments
310    ///
311    /// * `path` - Path expression (e.g., `/id`, `$.items[*].id`)
312    /// * `redactor` - The redactor to apply (static value or closure)
313    ///
314    /// # Errors
315    ///
316    /// Returns an error if:
317    /// - The path is invalid
318    /// - The path matches no values
319    ///
320    /// # Example
321    ///
322    /// ```ignore
323    /// # use clawspec_core::ApiClient;
324    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
325    /// # let client = ApiClient::builder().with_base_path("http://localhost".parse()?).build()?;
326    /// // Static value
327    /// let result = client
328    ///     .get("/api/users/123")?
329    ///     .await?
330    ///     .as_json_redacted::<serde_json::Value>().await?
331    ///     .redact("/id", "test-uuid")?
332    ///     .finish()
333    ///     .await;
334    ///
335    /// // Closure for index-based IDs
336    /// let result = client
337    ///     .get("/api/users")?
338    ///     .await?
339    ///     .as_json_redacted::<Vec<serde_json::Value>>().await?
340    ///     .redact("$[*].id", |path, _val| {
341    ///         let idx = path.split('/').nth(1).unwrap_or("0");
342    ///         serde_json::json!(format!("user-{idx}"))
343    ///     })?
344    ///     .finish()
345    ///     .await;
346    /// # Ok(())
347    /// # }
348    /// ```
349    pub fn redact<R: Redactor>(self, path: &str, redactor: R) -> Result<Self, ApiClientError> {
350        self.redact_with_options(path, redactor, RedactOptions::default())
351    }
352
353    /// Redacts values at the specified path with configurable options.
354    ///
355    /// This is like [`redact`](Self::redact) but allows customizing
356    /// behavior through [`RedactOptions`].
357    ///
358    /// # Arguments
359    ///
360    /// * `path` - Path expression (e.g., `/id`, `$.items[*].id`)
361    /// * `redactor` - The redactor to apply
362    /// * `options` - Configuration options
363    ///
364    /// # Example
365    ///
366    /// ```ignore
367    /// use clawspec_core::RedactOptions;
368    ///
369    /// // Allow empty matches for optional fields
370    /// let options = RedactOptions { allow_empty_match: true };
371    ///
372    /// builder
373    ///     .redact_with_options("$.optional[*].field", "value", options)?
374    ///     .finish()
375    ///     .await;
376    /// ```
377    pub fn redact_with_options<R: Redactor>(
378        mut self,
379        path: &str,
380        redactor: R,
381        options: RedactOptions,
382    ) -> Result<Self, ApiClientError> {
383        apply::apply_redaction(&mut self.redacted, path, redactor, options)?;
384        Ok(self)
385    }
386
387    /// Removes values at the specified path.
388    ///
389    /// This completely removes the field from objects or the element from arrays,
390    /// unlike setting it to `null`.
391    ///
392    /// The path can be either JSON Pointer (RFC 6901) or JSONPath (RFC 9535).
393    ///
394    /// # Arguments
395    ///
396    /// * `path` - Path expression to remove
397    ///
398    /// # Errors
399    ///
400    /// Returns an error if:
401    /// - The path is invalid
402    /// - The path matches no values
403    ///
404    /// # Example
405    ///
406    /// ```ignore
407    /// # use clawspec_core::ApiClient;
408    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
409    /// # let client = ApiClient::builder().with_base_path("http://localhost".parse()?).build()?;
410    /// // Remove specific field
411    /// let result = client
412    ///     .get("/api/users/123")?
413    ///     .await?
414    ///     .as_json_redacted::<serde_json::Value>().await?
415    ///     .redact_remove("/password")?
416    ///     .finish()
417    ///     .await;
418    ///
419    /// // Remove field from all array elements
420    /// let result = client
421    ///     .get("/api/users")?
422    ///     .await?
423    ///     .as_json_redacted::<Vec<serde_json::Value>>().await?
424    ///     .redact_remove("$[*].password")?
425    ///     .finish()
426    ///     .await;
427    /// # Ok(())
428    /// # }
429    /// ```
430    pub fn redact_remove(self, path: &str) -> Result<Self, ApiClientError> {
431        self.redact_remove_with(path, RedactOptions::default())
432    }
433
434    /// Removes values at the specified path with configurable options.
435    ///
436    /// This is like [`redact_remove`](Self::redact_remove) but allows customizing
437    /// behavior through [`RedactOptions`].
438    ///
439    /// # Arguments
440    ///
441    /// * `path` - Path expression to remove
442    /// * `options` - Configuration options
443    ///
444    /// # Example
445    ///
446    /// ```ignore
447    /// use clawspec_core::RedactOptions;
448    ///
449    /// // Allow empty matches for optional fields
450    /// let options = RedactOptions { allow_empty_match: true };
451    ///
452    /// builder
453    ///     .redact_remove_with("$.optional[*].field", options)?
454    ///     .finish()
455    ///     .await;
456    /// ```
457    pub fn redact_remove_with(
458        mut self,
459        path: &str,
460        options: RedactOptions,
461    ) -> Result<Self, ApiClientError> {
462        apply::apply_remove(&mut self.redacted, path, options)?;
463        Ok(self)
464    }
465
466    /// Finalizes the redaction and returns the result.
467    ///
468    /// This consumes the builder and returns a [`RedactedResult`] containing
469    /// both the original value and the redacted JSON.
470    ///
471    /// The redacted JSON value is recorded as an example in both the OpenAPI
472    /// schema for type `T` and in the response content for this operation.
473    pub async fn finish(self) -> RedactedResult<T>
474    where
475        T: ToSchema + 'static,
476    {
477        // Add example to schemas via channel
478        self.collector_sender
479            .send(CollectorMessage::AddExample {
480                type_id: TypeId::of::<T>(),
481                type_name: type_name::<T>(),
482                example: self.redacted.clone(),
483            })
484            .await;
485
486        // Register response with the redacted example via channel
487        self.collector_sender
488            .send(CollectorMessage::RegisterResponseWithExample {
489                operation_id: self.operation_id.clone(),
490                status: self.status,
491                content_type: self.content_type.clone(),
492                schema: self.schema.clone(),
493                example: self.redacted.clone(),
494            })
495            .await;
496
497        RedactedResult {
498            value: self.value,
499            redacted: self.redacted,
500        }
501    }
502}
503
504/// Creates a RedactionBuilder from a JSON string.
505///
506/// This is a helper function used internally by `CallResult::as_json_redacted()`.
507/// It deserializes the JSON into the target type and prepares it for redaction.
508///
509/// # Arguments
510///
511/// * `json` - The JSON string to deserialize and prepare for redaction
512/// * `collector_sender` - The channel sender to record the redacted example to
513/// * `operation_id` - The operation ID for deferred response registration
514/// * `status` - The HTTP status code of the response
515/// * `content_type` - The content type of the response
516/// * `schema` - The OpenAPI schema reference for the response type
517///
518/// # Type Parameters
519///
520/// * `T` - The type to deserialize into. Must implement [`DeserializeOwned`],
521///   [`ToSchema`], and have a `'static` lifetime.
522///
523/// # Errors
524///
525/// Returns an error if:
526/// - JSON deserialization fails
527/// - JSON parsing fails for the redaction copy
528pub(crate) fn create_redaction_builder<T>(
529    json: &str,
530    collector_sender: CollectorSender,
531    operation_id: String,
532    status: StatusCode,
533    content_type: Option<ContentType>,
534    schema: RefOr<Schema>,
535) -> Result<RedactionBuilder<T>, ApiClientError>
536where
537    T: DeserializeOwned + ToSchema + 'static,
538{
539    // Deserialize the original value
540    let deserializer = &mut Deserializer::from_str(json);
541    let value: T = serde_path_to_error::deserialize(deserializer).map_err(|err| {
542        ApiClientError::JsonError {
543            path: err.path().to_string(),
544            error: err.into_inner(),
545            body: json.to_string(),
546        }
547    })?;
548
549    // Parse JSON for redaction
550    let json_value = serde_json::from_str::<serde_json::Value>(json).map_err(|error| {
551        ApiClientError::JsonError {
552            path: String::new(),
553            error,
554            body: json.to_string(),
555        }
556    })?;
557
558    Ok(RedactionBuilder::new(
559        value,
560        json_value,
561        collector_sender,
562        operation_id,
563        status,
564        content_type,
565        schema,
566    ))
567}
568
569/// Create a redaction builder for an arbitrary JSON value.
570///
571/// This allows applying the same redaction patterns used for response bodies
572/// to any JSON value, such as the full OpenAPI specification.
573///
574/// # Path Syntax
575///
576/// The syntax is auto-detected based on the path prefix:
577/// - `$...` → JSONPath (RFC 9535) - supports wildcards
578/// - `/...` → JSON Pointer (RFC 6901) - exact path only
579///
580/// # Example
581///
582/// ```rust
583/// use clawspec_core::redact_value;
584/// use serde_json::json;
585///
586/// let value = json!({
587///     "id": "550e8400-e29b-41d4-a716-446655440000",
588///     "created_at": "2024-12-28T10:30:00Z"
589/// });
590///
591/// let redacted = redact_value(value)
592///     .redact("/id", "ENTITY_ID").unwrap()
593///     .redact("/created_at", "TIMESTAMP").unwrap()
594///     .finish();
595///
596/// assert_eq!(redacted["id"], "ENTITY_ID");
597/// assert_eq!(redacted["created_at"], "TIMESTAMP");
598/// ```
599///
600/// # Use Case: Stabilizing OpenAPI Specifications
601///
602/// ```rust
603/// use clawspec_core::redact_value;
604/// use serde_json::json;
605///
606/// let openapi_json = json!({
607///     "paths": {
608///         "/users": {
609///             "get": {
610///                 "responses": {
611///                     "200": {
612///                         "content": {
613///                             "application/json": {
614///                                 "example": {
615///                                     "id": "real-uuid",
616///                                     "created_at": "2024-12-28T10:30:00Z"
617///                                 }
618///                             }
619///                         }
620///                     }
621///                 }
622///             }
623///         }
624///     }
625/// });
626///
627/// // Stabilize all dynamic values in the OpenAPI spec
628/// let stabilized = redact_value(openapi_json)
629///     .redact("$..example.id", "ENTITY_ID").unwrap()
630///     .redact("$..example.created_at", "TIMESTAMP").unwrap()
631///     .finish();
632/// ```
633pub fn redact_value(value: serde_json::Value) -> ValueRedactionBuilder {
634    ValueRedactionBuilder::new(value)
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use http::StatusCode;
641    use serde::{Deserialize, Serialize};
642    use serde_json::json;
643    use utoipa::openapi::RefOr;
644
645    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
646    struct TestStruct {
647        id: String,
648        name: String,
649        items: Vec<TestItem>,
650    }
651
652    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
653    struct TestItem {
654        id: String,
655        value: i32,
656    }
657
658    fn create_test_builder() -> RedactionBuilder<TestStruct> {
659        let value = TestStruct {
660            id: "real-uuid".to_string(),
661            name: "Test".to_string(),
662            items: vec![
663                TestItem {
664                    id: "item-1".to_string(),
665                    value: 10,
666                },
667                TestItem {
668                    id: "item-2".to_string(),
669                    value: 20,
670                },
671            ],
672        };
673
674        let json = json!({
675            "id": "real-uuid",
676            "name": "Test",
677            "items": [
678                {"id": "item-1", "value": 10},
679                {"id": "item-2", "value": 20}
680            ]
681        });
682
683        let sender = CollectorSender::dummy();
684        let schema = RefOr::T(utoipa::openapi::ObjectBuilder::new().build().into());
685
686        RedactionBuilder::new(
687            value,
688            json,
689            sender,
690            "test_op".to_string(),
691            StatusCode::OK,
692            None,
693            schema,
694        )
695    }
696
697    #[test]
698    fn test_redact_options_default() {
699        let options = RedactOptions::default();
700
701        assert!(!options.allow_empty_match);
702    }
703
704    #[test]
705    fn test_redact_options_allow_empty_match() {
706        let options = RedactOptions {
707            allow_empty_match: true,
708        };
709
710        assert!(options.allow_empty_match);
711    }
712
713    #[test]
714    fn test_redact_options_debug() {
715        let options = RedactOptions {
716            allow_empty_match: true,
717        };
718        let debug_str = format!("{options:?}");
719
720        assert!(debug_str.contains("RedactOptions"));
721        assert!(debug_str.contains("allow_empty_match"));
722    }
723
724    #[test]
725    fn test_redact_options_clone() {
726        let original = RedactOptions {
727            allow_empty_match: true,
728        };
729        let cloned = original.clone();
730
731        assert_eq!(original.allow_empty_match, cloned.allow_empty_match);
732    }
733
734    #[test]
735    fn test_redaction_builder_redact_success() {
736        let builder = create_test_builder();
737        let result = builder.redact("/id", "stable-uuid");
738
739        assert!(result.is_ok());
740        let builder = result.expect("redaction should succeed");
741        assert_eq!(
742            builder.redacted.get("id").and_then(|v| v.as_str()),
743            Some("stable-uuid")
744        );
745    }
746
747    #[test]
748    fn test_redaction_builder_redact_jsonpath_wildcard() {
749        let builder = create_test_builder();
750        let result = builder.redact("$.items[*].id", "redacted-id");
751
752        assert!(result.is_ok());
753        let builder = result.expect("redaction should succeed");
754        let items = builder.redacted.get("items").expect("should have items");
755        let items_array = items.as_array().expect("should be array");
756
757        for item in items_array {
758            assert_eq!(item.get("id").and_then(|v| v.as_str()), Some("redacted-id"));
759        }
760    }
761
762    #[test]
763    fn test_redaction_builder_redact_with_closure() {
764        let builder = create_test_builder();
765        let result = builder.redact("$.items[*].id", |path: &str, _val: &serde_json::Value| {
766            let idx = path.split('/').nth(2).unwrap_or("?");
767            json!(format!("item-idx-{idx}"))
768        });
769
770        assert!(result.is_ok());
771        let builder = result.expect("redaction should succeed");
772        let items = builder.redacted.get("items").expect("should have items");
773        let items_array = items.as_array().expect("should be array");
774
775        assert_eq!(
776            items_array[0].get("id").and_then(|v| v.as_str()),
777            Some("item-idx-0")
778        );
779        assert_eq!(
780            items_array[1].get("id").and_then(|v| v.as_str()),
781            Some("item-idx-1")
782        );
783    }
784
785    #[test]
786    fn test_redaction_builder_redact_error_no_match() {
787        let builder = create_test_builder();
788        let result = builder.redact("$.nonexistent", "value");
789
790        assert!(result.is_err());
791    }
792
793    #[test]
794    fn test_redaction_builder_redact_with_options_allow_empty() {
795        let builder = create_test_builder();
796        let options = RedactOptions {
797            allow_empty_match: true,
798        };
799        let result = builder.redact_with_options("$.nonexistent", "value", options);
800
801        assert!(result.is_ok());
802    }
803
804    #[test]
805    fn test_redaction_builder_redact_remove_success() {
806        let builder = create_test_builder();
807        let result = builder.redact_remove("/id");
808
809        assert!(result.is_ok());
810        let builder = result.expect("removal should succeed");
811        assert!(builder.redacted.get("id").is_none());
812    }
813
814    #[test]
815    fn test_redaction_builder_redact_remove_jsonpath() {
816        let builder = create_test_builder();
817        let result = builder.redact_remove("$.items[*].id");
818
819        assert!(result.is_ok());
820        let builder = result.expect("removal should succeed");
821        let items = builder.redacted.get("items").expect("should have items");
822        let items_array = items.as_array().expect("should be array");
823
824        for item in items_array {
825            assert!(item.get("id").is_none());
826        }
827    }
828
829    #[test]
830    fn test_redaction_builder_redact_remove_error_no_match() {
831        let builder = create_test_builder();
832        let result = builder.redact_remove("$.nonexistent");
833
834        assert!(result.is_err());
835    }
836
837    #[test]
838    fn test_redaction_builder_redact_remove_with_allow_empty() {
839        let builder = create_test_builder();
840        let options = RedactOptions {
841            allow_empty_match: true,
842        };
843        let result = builder.redact_remove_with("$.nonexistent", options);
844
845        assert!(result.is_ok());
846    }
847
848    #[test]
849    fn test_redaction_builder_chained_redactions() {
850        let builder = create_test_builder();
851        let result = builder
852            .redact("/id", "stable-id")
853            .and_then(|b| b.redact("/name", "Redacted Name"))
854            .and_then(|b| b.redact("$.items[*].id", "item-redacted"));
855
856        assert!(result.is_ok());
857        let builder = result.expect("chained redactions should succeed");
858        assert_eq!(
859            builder.redacted.get("id").and_then(|v| v.as_str()),
860            Some("stable-id")
861        );
862        assert_eq!(
863            builder.redacted.get("name").and_then(|v| v.as_str()),
864            Some("Redacted Name")
865        );
866    }
867
868    #[test]
869    fn test_redaction_builder_preserves_original_value() {
870        let builder = create_test_builder();
871        let original_id = builder.value.id.clone();
872        let result = builder.redact("/id", "stable-id");
873
874        assert!(result.is_ok());
875        let builder = result.expect("redaction should succeed");
876        // Original value should be preserved
877        assert_eq!(builder.value.id, original_id);
878        // Redacted value should be different
879        assert_eq!(
880            builder.redacted.get("id").and_then(|v| v.as_str()),
881            Some("stable-id")
882        );
883    }
884
885    #[test]
886    fn test_redacted_result_fields() {
887        let result = RedactedResult {
888            value: TestStruct {
889                id: "real".to_string(),
890                name: "Test".to_string(),
891                items: vec![],
892            },
893            redacted: json!({"id": "fake", "name": "Test", "items": []}),
894        };
895
896        assert_eq!(result.value.id, "real");
897        assert_eq!(
898            result.redacted.get("id").and_then(|v| v.as_str()),
899            Some("fake")
900        );
901    }
902
903    #[test]
904    fn test_redacted_result_debug() {
905        let result = RedactedResult {
906            value: "test".to_string(),
907            redacted: json!("redacted"),
908        };
909        let debug_str = format!("{result:?}");
910
911        assert!(debug_str.contains("RedactedResult"));
912    }
913
914    #[test]
915    fn test_redacted_result_clone() {
916        let original = RedactedResult {
917            value: "test".to_string(),
918            redacted: json!("redacted"),
919        };
920        let cloned = original.clone();
921
922        assert_eq!(original.value, cloned.value);
923        assert_eq!(original.redacted, cloned.redacted);
924    }
925
926    #[test]
927    fn test_redaction_builder_debug() {
928        let builder = create_test_builder();
929        let debug_str = format!("{builder:?}");
930
931        assert!(debug_str.contains("RedactionBuilder"));
932        assert!(debug_str.contains("operation_id"));
933    }
934
935    #[test]
936    fn test_create_redaction_builder_success() {
937        let json = r#"{"id": "uuid", "name": "Test"}"#;
938        let sender = CollectorSender::dummy();
939        let schema = RefOr::T(utoipa::openapi::ObjectBuilder::new().build().into());
940
941        let result = create_redaction_builder::<serde_json::Value>(
942            json,
943            sender,
944            "op_id".to_string(),
945            StatusCode::OK,
946            None,
947            schema,
948        );
949
950        assert!(result.is_ok());
951    }
952
953    #[test]
954    fn test_create_redaction_builder_invalid_json() {
955        let json = r#"{"invalid json"#;
956        let sender = CollectorSender::dummy();
957        let schema = RefOr::T(utoipa::openapi::ObjectBuilder::new().build().into());
958
959        let result = create_redaction_builder::<serde_json::Value>(
960            json,
961            sender,
962            "op_id".to_string(),
963            StatusCode::OK,
964            None,
965            schema,
966        );
967
968        assert!(result.is_err());
969    }
970
971    #[test]
972    fn test_invalid_path_syntax_error() {
973        let builder = create_test_builder();
974        // Path without $ or / prefix should fail
975        let result = builder.redact("invalid_path", "value");
976
977        assert!(result.is_err());
978    }
979
980    #[test]
981    fn test_invalid_jsonpath_syntax() {
982        let builder = create_test_builder();
983        // Invalid JSONPath syntax
984        let result = builder.redact("$[[[invalid", "value");
985
986        assert!(result.is_err());
987    }
988}