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