Skip to main content

claude_code_statusline_core/
parser.rs

1//! JSON parsing and format string processing utilities
2//!
3//! This module provides functions for:
4//! - Parsing JSON input from Claude Code
5//! - Processing format strings with variable substitution
6//! - Extracting module names from format strings
7//!
8//! # Format String Syntax
9//!
10//! Format strings use `$` prefix for variables that correspond to module names:
11//! - `$directory` - Replaced with directory module output
12//! - `$claude_model` - Replaced with model information
13//! - `$git_branch` - Replaced with current git branch
14//!
15//! Example: `"$directory $git_branch $claude_model"`
16
17use crate::error::CoreError;
18use crate::types::claude::ClaudeInput;
19use crate::types::context::Context;
20use std::collections::HashMap;
21
22/// Parses JSON string into ClaudeInput structure
23///
24/// Takes raw JSON input from stdin and deserializes it into
25/// a strongly typed ClaudeInput struct.
26///
27/// # Arguments
28///
29/// * `json_str` - Raw JSON string to parse
30///
31/// # Returns
32///
33/// * `Ok(ClaudeInput)` - Successfully parsed input
34/// * `Err` - JSON parsing or validation failed
35///
36/// # Examples
37///
38/// ```
39/// use claude_code_statusline_core::parse_claude_input;
40///
41/// let json = r#"{"session_id":"test","cwd":"/tmp","model":{"id":"claude","display_name":"Claude"}}"#;
42/// let input = parse_claude_input(json).unwrap();
43/// assert_eq!(input.cwd, "/tmp");
44/// ```
45pub fn parse_claude_input(json_str: &str) -> Result<ClaudeInput, CoreError> {
46    Ok(serde_json::from_str(json_str)?)
47}
48
49/// Parses format string and substitutes variables with module outputs
50///
51/// Replaces `$<name>` tokens anywhere in the string (not only when
52/// separated by whitespace) with their corresponding rendered outputs.
53/// Unknown tokens are removed (replaced by an empty string).
54///
55/// # Arguments
56///
57/// * `format` - Format string containing `$<name>` variable tokens
58/// * `_context` - Context (reserved for future use)
59/// * `module_outputs` - Map of module names to their rendered outputs
60///
61/// # Returns
62///
63/// A string with all `$<name>` tokens replaced by their values while
64/// preserving all other characters (including spaces) verbatim.
65///
66/// # Examples
67///
68/// ```no_run
69/// # use std::collections::HashMap;
70/// # use claude_code_statusline_core::parser::parse_format;
71/// # use claude_code_statusline_core::{Context, Config, parse_claude_input};
72/// # let json = r#"{"session_id":"test","cwd":"/tmp","model":{"id":"claude","display_name":"Claude"}}"#;
73/// # let input = parse_claude_input(json).unwrap();
74/// # let context = Context::new(input, Config::default());
75/// let mut outputs = HashMap::new();
76/// outputs.insert("directory".to_string(), "~/project".to_string());
77/// outputs.insert("claude_model".to_string(), "Opus".to_string());
78///
79/// let result = parse_format("$directory $claude_model", &context, &outputs);
80/// assert_eq!(result, "~/project Opus");
81/// ```
82pub fn parse_format(
83    format: &str,
84    _context: &Context,
85    module_outputs: &HashMap<String, String>,
86) -> String {
87    // Scan the string and replace $<name> inline without altering
88    // any other characters. A valid name starts with [A-Za-z_]
89    // and continues with [A-Za-z0-9_]*.
90    let bytes = format.as_bytes();
91    let mut i = 0;
92    let mut out = String::with_capacity(format.len());
93    while i < bytes.len() {
94        if bytes[i] == b'$' {
95            let start = i;
96            let j = i + 1;
97            // validate first identifier char
98            let mut k = j;
99            if k < bytes.len() {
100                let c = bytes[k] as char;
101                if c.is_ascii_alphabetic() || c == '_' {
102                    k += 1;
103                    // consume rest of identifier
104                    while k < bytes.len() {
105                        let c2 = bytes[k] as char;
106                        if c2.is_ascii_alphanumeric() || c2 == '_' {
107                            k += 1;
108                        } else {
109                            break;
110                        }
111                    }
112                    let name = &format[j..k];
113                    // Replace with module output (or empty string if missing)
114                    if let Some(val) = module_outputs.get(name) {
115                        out.push_str(val);
116                    }
117                    i = k;
118                    continue;
119                }
120            }
121            // Not a valid token — treat '$' literally
122            out.push('$');
123            i = start + 1;
124        } else {
125            out.push(bytes[i] as char);
126            i += 1;
127        }
128    }
129    // Avoid a dangling trailing space when unknown tokens are removed
130    // at the end (e.g., "$directory $character"). Do not alter
131    // interior whitespace to preserve precise layout for Powerline-style
132    // compositions.
133    out.trim_end().to_string()
134}
135
136/// Extracts module names from a format string
137///
138/// Scans the format string for variable placeholders (tokens starting with `$`)
139/// and returns a list of module names that need to be rendered.
140///
141/// # Arguments
142///
143/// * `format` - Format string containing variable placeholders
144///
145/// # Returns
146///
147/// A vector of module names found in the format string
148///
149/// # Examples
150///
151/// ```
152/// use claude_code_statusline_core::parser::extract_modules_from_format;
153///
154/// let modules = extract_modules_from_format("$directory $claude_model");
155/// assert_eq!(modules, vec!["directory", "claude_model"]);
156/// ```
157pub fn extract_modules_from_format(format: &str) -> Vec<String> {
158    // Scan for `$<name>` anywhere in the string and return unique names
159    // in encounter order.
160    use std::collections::HashSet;
161    let bytes = format.as_bytes();
162    let mut i = 0;
163    let mut out: Vec<String> = Vec::new();
164    let mut seen: HashSet<String> = HashSet::new();
165    while i < bytes.len() {
166        if bytes[i] == b'$' {
167            let j = i + 1;
168            let mut k = j;
169            if k < bytes.len() {
170                let c = bytes[k] as char;
171                if c.is_ascii_alphabetic() || c == '_' {
172                    k += 1;
173                    while k < bytes.len() {
174                        let c2 = bytes[k] as char;
175                        if c2.is_ascii_alphanumeric() || c2 == '_' {
176                            k += 1;
177                        } else {
178                            break;
179                        }
180                    }
181                    let name = &format[j..k];
182                    if seen.insert(name.to_string()) {
183                        out.push(name.to_string());
184                    }
185                    i = k;
186                    continue;
187                }
188            }
189        }
190        i += 1;
191    }
192    out
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::config::Config;
199    use crate::types::claude::{ModelInfo, WorkspaceInfo};
200
201    #[test]
202    fn test_extract_modules_from_format() {
203        let format = "$directory $claude_model $character";
204        let modules = extract_modules_from_format(format);
205        assert_eq!(modules, vec!["directory", "claude_model", "character"]);
206    }
207
208    #[test]
209    fn test_extract_modules_from_format_with_extra_text() {
210        let format = "prefix $directory middle $claude_model suffix";
211        let modules = extract_modules_from_format(format);
212        assert_eq!(modules, vec!["directory", "claude_model"]);
213    }
214
215    #[test]
216    fn test_extract_modules_from_format_empty() {
217        let format = "no variables here";
218        let modules = extract_modules_from_format(format);
219        assert_eq!(modules, Vec::<String>::new());
220    }
221
222    #[test]
223    fn test_parse_format() {
224        let input = ClaudeInput {
225            hook_event_name: Some("Status".to_string()),
226            session_id: "test-123".to_string(),
227            transcript_path: Some("/test/transcript.json".to_string()),
228            cwd: "/test/dir".to_string(),
229            model: ModelInfo {
230                id: "claude-opus".to_string(),
231                display_name: "Opus".to_string(),
232            },
233            workspace: Some(WorkspaceInfo {
234                current_dir: "/test/dir".to_string(),
235                project_dir: Some("/test/project".to_string()),
236            }),
237            version: Some("1.0.0".to_string()),
238            output_style: None,
239        };
240
241        let config = Config::default();
242        let context = Context::new(input, config);
243
244        let mut module_outputs = HashMap::new();
245        module_outputs.insert("directory".to_string(), "~/project".to_string());
246        module_outputs.insert("claude_model".to_string(), "Opus".to_string());
247
248        let format = "$directory $claude_model $character";
249        let result = parse_format(format, &context, &module_outputs);
250
251        // $character doesn't have output, so it should be removed
252        assert_eq!(result, "~/project Opus");
253    }
254
255    #[test]
256    fn test_parse_format_with_text() {
257        let input = ClaudeInput {
258            hook_event_name: Some("Status".to_string()),
259            session_id: "test-123".to_string(),
260            transcript_path: Some("/test/transcript.json".to_string()),
261            cwd: "/test/dir".to_string(),
262            model: ModelInfo {
263                id: "claude-opus".to_string(),
264                display_name: "Opus".to_string(),
265            },
266            workspace: None,
267            version: Some("1.0.0".to_string()),
268            output_style: None,
269        };
270
271        let config = Config::default();
272        let context = Context::new(input, config);
273
274        let mut module_outputs = HashMap::new();
275        module_outputs.insert("directory".to_string(), "~/project".to_string());
276        module_outputs.insert("character".to_string(), ">".to_string());
277
278        let format = "prefix $directory middle $character suffix";
279        let result = parse_format(format, &context, &module_outputs);
280
281        assert_eq!(result, "prefix ~/project middle > suffix");
282    }
283
284    #[test]
285    fn test_parse_format_edge_cases() {
286        let input = ClaudeInput {
287            hook_event_name: Some("Status".to_string()),
288            session_id: "test-123".to_string(),
289            transcript_path: Some("/test/transcript.json".to_string()),
290            cwd: "/test/dir".to_string(),
291            model: ModelInfo {
292                id: "claude-opus".to_string(),
293                display_name: "Opus".to_string(),
294            },
295            workspace: None,
296            version: Some("1.0.0".to_string()),
297            output_style: None,
298        };
299
300        let config = Config::default();
301        let context = Context::new(input, config);
302
303        // Test with substring variable names
304        let mut module_outputs = HashMap::new();
305        module_outputs.insert("dir".to_string(), "short".to_string());
306        module_outputs.insert("directory".to_string(), "long".to_string());
307
308        // Should handle variable names correctly even when one is a substring of another
309        let format = "$directory $dir";
310        let result = parse_format(format, &context, &module_outputs);
311        assert_eq!(result, "long short");
312
313        // Variables without whitespace boundaries must also be replaced now
314        let format = "prefix$directory suffix";
315        let result = parse_format(format, &context, &module_outputs);
316        assert_eq!(result, "prefixlong suffix");
317    }
318
319    #[test]
320    fn test_parse_valid_claude_input() {
321        let json_str = r#"{
322            "hook_event_name": "Status",
323            "session_id": "test-session-123",
324            "transcript_path": "/path/to/transcript.json",
325            "cwd": "/test/directory",
326            "model": {
327                "id": "claude-opus-4-1",
328                "display_name": "Opus"
329            },
330            "workspace": {
331                "current_dir": "/test/directory",
332                "project_dir": "/test/project"
333            },
334            "version": "1.0.0",
335            "output_style": {
336                "name": "default"
337            }
338        }"#;
339
340        let result = parse_claude_input(json_str);
341        assert!(result.is_ok());
342
343        let input = result.unwrap();
344        assert_eq!(input.session_id, "test-session-123");
345        assert_eq!(input.model.display_name, "Opus");
346        assert_eq!(input.cwd, "/test/directory");
347    }
348
349    #[test]
350    fn test_parse_invalid_json() {
351        let invalid_json = "not a valid json";
352        let result = parse_claude_input(invalid_json);
353        assert!(result.is_err());
354    }
355
356    #[test]
357    fn test_parse_missing_required_field() {
358        // Missing "model" field
359        let json_str = r#"{
360            "hook_event_name": "Status",
361            "session_id": "test-session-123",
362            "transcript_path": "/path/to/transcript.json",
363            "cwd": "/test/directory",
364            "workspace": {
365                "current_dir": "/test/directory",
366                "project_dir": "/test/project"
367            },
368            "version": "1.0.0",
369            "output_style": {
370                "name": "default"
371            }
372        }"#;
373
374        let result = parse_claude_input(json_str);
375        assert!(result.is_err());
376    }
377}