Skip to main content

ralph/
jsonc.rs

1//! JSONC parsing utilities for Ralph.
2//!
3//! Responsibilities:
4//! - Provide JSONC (JSON with Comments) parsing with comment support.
5//! - Maintain backward compatibility with standard JSON.
6//! - Integrate with existing JSON repair logic for malformed files.
7//!
8//! Not handled here:
9//! - File I/O (callers read/write file contents).
10//! - Round-tripping comments (comments are stripped on rewrite).
11//!
12//! Invariants/assumptions:
13//! - Input is valid UTF-8.
14//! - jsonc-parser is used for parsing; serde_json for serialization.
15
16use anyhow::{Context, Result};
17use serde::de::DeserializeOwned;
18
19/// Parse JSONC (JSON with Comments) into a typed struct.
20/// Falls back to standard JSON parsing for backward compatibility.
21pub fn parse_jsonc<T: DeserializeOwned>(raw: &str, context: &str) -> Result<T> {
22    // Try JSONC parsing first (handles comments and trailing commas)
23    match jsonc_parser::parse_to_serde_value(raw, &Default::default()) {
24        Ok(Some(value)) => {
25            serde_json::from_value(value).with_context(|| format!("parse {} from JSONC", context))
26        }
27        Ok(None) => {
28            // Empty file case - try parsing as empty JSON (will likely fail, but gives proper error)
29            serde_json::from_str::<T>(raw)
30                .with_context(|| format!("parse {} as JSON (empty file)", context))
31        }
32        Err(jsonc_err) => {
33            // Fall back to standard JSON for backward compatibility
34            serde_json::from_str::<T>(raw)
35                .with_context(|| format!("parse {} as JSON/JSONC: {}", context, jsonc_err))
36        }
37    }
38}
39
40/// Serialize to pretty-printed JSON (no comments preserved).
41/// Output is always standard JSON format.
42pub fn to_string_pretty<T: serde::Serialize>(value: &T) -> Result<String> {
43    serde_json::to_string_pretty(value).context("serialize to JSON")
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use serde::Deserialize;
50    use serde::Serialize;
51
52    #[derive(Debug, Deserialize, Serialize, PartialEq)]
53    struct TestConfig {
54        version: u32,
55        name: String,
56    }
57
58    #[test]
59    fn parse_jsonc_accepts_standard_json() {
60        let json = r#"{"version": 1, "name": "test"}"#;
61        let config: TestConfig = parse_jsonc(json, "test config").unwrap();
62        assert_eq!(config.version, 1);
63        assert_eq!(config.name, "test");
64    }
65
66    #[test]
67    fn parse_jsonc_accepts_single_line_comments() {
68        let jsonc = r#"{
69            // This is a comment
70            "version": 1,
71            "name": "test"
72        }"#;
73        let config: TestConfig = parse_jsonc(jsonc, "test config").unwrap();
74        assert_eq!(config.version, 1);
75        assert_eq!(config.name, "test");
76    }
77
78    #[test]
79    fn parse_jsonc_accepts_multi_line_comments() {
80        let jsonc = r#"{
81            /* This is a
82               multi-line comment */
83            "version": 1,
84            "name": "test"
85        }"#;
86        let config: TestConfig = parse_jsonc(jsonc, "test config").unwrap();
87        assert_eq!(config.version, 1);
88        assert_eq!(config.name, "test");
89    }
90
91    #[test]
92    fn parse_jsonc_accepts_trailing_commas() {
93        let jsonc = r#"{
94            "version": 1,
95            "name": "test",
96        }"#;
97        let config: TestConfig = parse_jsonc(jsonc, "test config").unwrap();
98        assert_eq!(config.version, 1);
99        assert_eq!(config.name, "test");
100    }
101
102    #[test]
103    fn parse_jsonc_rejects_invalid_json() {
104        let invalid = r#"{"version": 1, "name": }"#;
105        let result: Result<TestConfig> = parse_jsonc(invalid, "test config");
106        assert!(result.is_err());
107    }
108
109    #[test]
110    fn to_string_pretty_outputs_valid_json() {
111        let config = TestConfig {
112            version: 1,
113            name: "test".to_string(),
114        };
115        let json = to_string_pretty(&config).unwrap();
116        // Verify it's valid JSON by parsing it back
117        let _: TestConfig = serde_json::from_str(&json).unwrap();
118        assert!(json.contains("\"version\": 1"));
119        assert!(json.contains("\"name\": \"test\""));
120    }
121
122    #[test]
123    fn parse_jsonc_handles_mixed_comments_and_trailing_commas() {
124        use serde::Deserialize;
125
126        #[derive(Debug, Deserialize, PartialEq)]
127        struct Task {
128            id: String,
129            title: String,
130        }
131
132        #[derive(Debug, Deserialize, PartialEq)]
133        struct QueueFile {
134            version: u32,
135            tasks: Vec<Task>,
136        }
137
138        let jsonc = r#"{
139            // Single line comment
140            "version": 1,
141            /* Multi-line
142               comment */
143            "tasks": [{
144                "id": "RQ-0001",
145                "title": "Test", // inline comment
146            },]
147        }"#;
148
149        let result: Result<QueueFile> = parse_jsonc(jsonc, "test queue");
150        assert!(
151            result.is_ok(),
152            "Should parse JSONC with mixed comments and trailing commas: {:?}",
153            result.err()
154        );
155        let queue = result.unwrap();
156        assert_eq!(queue.tasks.len(), 1);
157        assert_eq!(queue.tasks[0].id, "RQ-0001");
158    }
159}