From 1eeced31164aaba02dd002ffe9296e2b8131a58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Thu, 15 Jan 2026 18:34:44 +0100 Subject: [PATCH] feat(cli): accept more file types on retain-files (#163) * feat(cli): accept more file types on retain-files * feat(cli): accept more file types on retain-files --- .github/workflows/test.yml | 40 +++--- hindsight-cli/src/api.rs | 147 ++++++++++++++++++++++ hindsight-cli/src/commands/memory.rs | 176 +++++++++++++++++++++++---- hindsight-cli/src/config.rs | 154 +++++++++++++++++++++++ hindsight-cli/src/output.rs | 144 +++++++++++++++++++++- 5 files changed, 608 insertions(+), 53 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7ce3e7fa..348ab35a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -153,8 +153,15 @@ jobs: - name: Build docs run: npm run build --workspace=hindsight-docs - build-rust-cli: + test-rust-cli: runs-on: ubuntu-latest + env: + HINDSIGHT_API_LLM_PROVIDER: groq + HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }} + HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b + HINDSIGHT_API_URL: http://localhost:8888 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu steps: - uses: actions/checkout@v4 @@ -171,6 +178,10 @@ jobs: hindsight-cli/target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Run unit tests + working-directory: hindsight-cli + run: cargo test + - name: Build CLI working-directory: hindsight-cli run: cargo build --release @@ -182,29 +193,6 @@ jobs: path: hindsight-cli/target/release/hindsight retention-days: 1 - test-rust-cli: - runs-on: ubuntu-latest - needs: build-rust-cli - env: - HINDSIGHT_API_LLM_PROVIDER: groq - HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }} - HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b - HINDSIGHT_API_URL: http://localhost:8888 - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu - - steps: - - uses: actions/checkout@v4 - - - name: Download CLI artifact - uses: actions/download-artifact@v4 - with: - name: hindsight-cli - path: /tmp/cli - - - name: Make CLI executable - run: chmod +x /tmp/cli/hindsight - - name: Install uv uses: astral-sh/setup-uv@v5 with: @@ -251,7 +239,7 @@ jobs: - name: Run CLI smoke test run: | - HINDSIGHT_CLI=/tmp/cli/hindsight ./hindsight-cli/smoke-test.sh + HINDSIGHT_CLI=hindsight-cli/target/release/hindsight ./hindsight-cli/smoke-test.sh - name: Show API server logs if: always() @@ -777,7 +765,7 @@ jobs: test-doc-examples: runs-on: ubuntu-latest - needs: build-rust-cli + needs: test-rust-cli env: HINDSIGHT_API_LLM_PROVIDER: groq HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }} diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index 38efde87..4ceceea0 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -55,6 +55,7 @@ pub struct MemoryPutResult { pub items_count: i64, pub message: String, pub is_async: bool, + pub operation_id: Option, } #[derive(Clone)] @@ -161,10 +162,54 @@ impl ApiClient { items_count: result.items_count, message: format!("Stored {} memory units", result.items_count), is_async: result.async_, + operation_id: result.operation_id, }) }) } + /// Poll an operation until it completes or fails. + /// Returns Ok(true) if completed successfully, Ok(false) if failed, Err if polling error. + pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option)> { + self.runtime.block_on(async { + loop { + let response = self.client.list_operations(agent_id, None).await?; + let ops = response.into_inner(); + + // Find our operation + let op = ops.operations.iter().find(|o| o.id == operation_id); + + match op { + Some(operation) => { + if verbose { + eprintln!("Operation {} status: {}", operation_id, operation.status); + } + match operation.status.as_str() { + "pending" => { + // Still running, wait and poll again + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + "completed" => { + // Operation completed successfully + return Ok((true, None)); + } + "failed" => { + return Ok((false, operation.error_message.clone())); + } + _ => { + // Unknown status, treat as failed + return Ok((false, Some(format!("Unknown status: {}", operation.status)))); + } + } + } + None => { + // Operation not in list means it completed successfully (removed from pending/failed) + return Ok((true, None)); + } + } + } + }) + } + pub fn delete_memory(&self, _agent_id: &str, _unit_id: &str, _verbose: bool) -> Result { // Note: Individual memory deletion is no longer supported in the API anyhow::bail!("Individual memory deletion is no longer supported. Use 'memory clear' to clear all memories.") @@ -281,3 +326,105 @@ pub use types::{ ReflectResponse, RetainRequest, }; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_operation_deserialize() { + let json = r#"{ + "id": "test-op-123", + "task_type": "retain", + "items_count": 5, + "document_id": "doc-456", + "created_at": "2024-01-15T10:00:00Z", + "status": "pending", + "error_message": null + }"#; + let op: Operation = serde_json::from_str(json).unwrap(); + assert_eq!(op.id, "test-op-123"); + assert_eq!(op.task_type, "retain"); + assert_eq!(op.items_count, 5); + assert_eq!(op.document_id, Some("doc-456".to_string())); + assert_eq!(op.status, "pending"); + assert!(op.error_message.is_none()); + } + + #[test] + fn test_operation_deserialize_with_error() { + let json = r#"{ + "id": "test-op-456", + "task_type": "retain", + "items_count": 3, + "document_id": null, + "created_at": "2024-01-15T10:00:00Z", + "status": "failed", + "error_message": "Something went wrong" + }"#; + let op: Operation = serde_json::from_str(json).unwrap(); + assert_eq!(op.status, "failed"); + assert_eq!(op.error_message, Some("Something went wrong".to_string())); + } + + #[test] + fn test_memory_put_result_serialize() { + let result = MemoryPutResult { + success: true, + items_count: 10, + message: "Stored 10 memory units".to_string(), + is_async: true, + operation_id: Some("op-789".to_string()), + }; + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"success\":true")); + assert!(json.contains("\"items_count\":10")); + assert!(json.contains("\"is_async\":true")); + assert!(json.contains("\"operation_id\":\"op-789\"")); + } + + #[test] + fn test_memory_put_result_without_operation_id() { + let result = MemoryPutResult { + success: true, + items_count: 5, + message: "Stored 5 memory units".to_string(), + is_async: false, + operation_id: None, + }; + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"operation_id\":null")); + } + + #[test] + fn test_operations_response_deserialize() { + let json = r#"{ + "bank_id": "test-bank", + "operations": [ + { + "id": "op-1", + "task_type": "retain", + "items_count": 2, + "document_id": null, + "created_at": "2024-01-15T10:00:00Z", + "status": "pending", + "error_message": null + }, + { + "id": "op-2", + "task_type": "retain", + "items_count": 3, + "document_id": "doc-123", + "created_at": "2024-01-15T11:00:00Z", + "status": "completed", + "error_message": null + } + ] + }"#; + let ops: OperationsResponse = serde_json::from_str(json).unwrap(); + assert_eq!(ops.bank_id, "test-bank"); + assert_eq!(ops.operations.len(), 2); + assert_eq!(ops.operations[0].status, "pending"); + assert_eq!(ops.operations[1].status, "completed"); + } +} diff --git a/hindsight-cli/src/commands/memory.rs b/hindsight-cli/src/commands/memory.rs index 00696b6c..3cfb4909 100644 --- a/hindsight-cli/src/commands/memory.rs +++ b/hindsight-cli/src/commands/memory.rs @@ -21,6 +21,17 @@ fn parse_budget(budget: &str) -> Budget { } } +// Helper function to check if a file has a text-based extension +fn is_text_file(path: &std::path::Path) -> bool { + const TEXT_EXTENSIONS: &[&str] = &[ + "txt", "md", "json", "yaml", "yml", "toml", "xml", "csv", "log", "rst", "adoc", + ]; + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| TEXT_EXTENSIONS.contains(&ext.to_lowercase().as_str())) + .unwrap_or(false) +} + pub fn recall( client: &ApiClient, agent_id: &str, @@ -229,29 +240,23 @@ pub fn retain_files( .filter(|e| e.file_type().is_file()) { let path = entry.path(); - if let Some(ext) = path.extension() { - if ext == "txt" || ext == "md" { - files.push(path.to_path_buf()); - } + if is_text_file(&path) { + files.push(path.to_path_buf()); } } } else { for entry in fs::read_dir(&path)? { let entry = entry?; let path = entry.path(); - if path.is_file() { - if let Some(ext) = path.extension() { - if ext == "txt" || ext == "md" { - files.push(path); - } - } + if path.is_file() && is_text_file(&path) { + files.push(path); } } } } if files.is_empty() { - ui::print_warning("No .txt or .md files found"); + ui::print_warning("No text files found (supported: txt, md, json, yaml, yml, toml, xml, csv, log, rst, adoc)"); return Ok(()); } @@ -286,19 +291,20 @@ pub fn retain_files( pb.finish_with_message("Files processed"); + // Always use async mode for the API call + let request = RetainRequest { + items, + async_: true, + document_tags: None, + }; + let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Retaining memories...")) + Some(ui::create_spinner("Submitting retain request...")) } else { None }; - let request = RetainRequest { - items, - async_: r#async, - document_tags: None, - }; - - let response = client.retain(agent_id, &request, r#async, verbose); + let response = client.retain(agent_id, &request, true, verbose); if let Some(mut sp) = spinner { sp.finish(); @@ -306,16 +312,55 @@ pub fn retain_files( match response { Ok(result) => { - if output_format == OutputFormat::Pretty { - ui::print_success("Files retained successfully"); - if result.is_async { - println!(" Status: queued for background processing"); + if r#async { + // User requested async mode - return immediately + if output_format == OutputFormat::Pretty { + ui::print_success("Files queued for processing"); println!(" Items: {}", result.items_count); + if let Some(op_id) = &result.operation_id { + println!(" Operation ID: {}", op_id); + } } else { - println!(" Total units created: {}", result.items_count); + output::print_output(&result, output_format)?; } } else { - output::print_output(&result, output_format)?; + // Poll until completion + if let Some(operation_id) = &result.operation_id { + let poll_spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Processing memories...")) + } else { + None + }; + + let (success, error_msg) = client.poll_operation(agent_id, operation_id, verbose)?; + + if let Some(mut sp) = poll_spinner { + sp.finish(); + } + + if success { + if output_format == OutputFormat::Pretty { + ui::print_success("Files retained successfully"); + println!(" Items processed: {}", result.items_count); + } else { + output::print_output(&result, output_format)?; + } + } else { + let msg = error_msg.unwrap_or_else(|| "Unknown error".to_string()); + if output_format == OutputFormat::Pretty { + ui::print_error(&format!("Retain operation failed: {}", msg)); + } + anyhow::bail!("Retain operation failed: {}", msg); + } + } else { + // No operation ID returned, shouldn't happen with async=true + if output_format == OutputFormat::Pretty { + ui::print_success("Files retained successfully"); + println!(" Items processed: {}", result.items_count); + } else { + output::print_output(&result, output_format)?; + } + } } Ok(()) } @@ -428,3 +473,84 @@ pub fn clear( Err(e) => Err(e) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_is_text_file_supported_extensions() { + let supported = [ + "file.txt", "file.md", "file.json", "file.yaml", "file.yml", + "file.toml", "file.xml", "file.csv", "file.log", "file.rst", "file.adoc", + ]; + for filename in supported { + assert!( + is_text_file(Path::new(filename)), + "{} should be recognized as a text file", + filename + ); + } + } + + #[test] + fn test_is_text_file_case_insensitive() { + assert!(is_text_file(Path::new("file.JSON"))); + assert!(is_text_file(Path::new("file.TXT"))); + assert!(is_text_file(Path::new("file.Md"))); + assert!(is_text_file(Path::new("file.YAML"))); + } + + #[test] + fn test_is_text_file_unsupported_extensions() { + let unsupported = [ + "file.pdf", "file.doc", "file.docx", "file.png", "file.jpg", + "file.exe", "file.bin", "file.zip", "file.tar", "file.gz", + ]; + for filename in unsupported { + assert!( + !is_text_file(Path::new(filename)), + "{} should NOT be recognized as a text file", + filename + ); + } + } + + #[test] + fn test_is_text_file_no_extension() { + assert!(!is_text_file(Path::new("README"))); + assert!(!is_text_file(Path::new("Makefile"))); + assert!(!is_text_file(Path::new(".gitignore"))); + } + + #[test] + fn test_is_text_file_with_path() { + assert!(is_text_file(Path::new("/some/path/to/file.json"))); + assert!(is_text_file(Path::new("../relative/path/file.md"))); + assert!(!is_text_file(Path::new("/path/to/image.png"))); + } + + #[test] + fn test_parse_budget_valid_values() { + assert!(matches!(parse_budget("low"), Budget::Low)); + assert!(matches!(parse_budget("mid"), Budget::Mid)); + assert!(matches!(parse_budget("high"), Budget::High)); + } + + #[test] + fn test_parse_budget_case_insensitive() { + assert!(matches!(parse_budget("LOW"), Budget::Low)); + assert!(matches!(parse_budget("MID"), Budget::Mid)); + assert!(matches!(parse_budget("HIGH"), Budget::High)); + assert!(matches!(parse_budget("Low"), Budget::Low)); + assert!(matches!(parse_budget("High"), Budget::High)); + } + + #[test] + fn test_parse_budget_defaults_to_mid() { + assert!(matches!(parse_budget("invalid"), Budget::Mid)); + assert!(matches!(parse_budget(""), Budget::Mid)); + assert!(matches!(parse_budget("unknown"), Budget::Mid)); + } +} diff --git a/hindsight-cli/src/config.rs b/hindsight-cli/src/config.rs index 2cf4e5ae..5ed7701a 100644 --- a/hindsight-cli/src/config.rs +++ b/hindsight-cli/src/config.rs @@ -8,6 +8,7 @@ const DEFAULT_API_URL: &str = "http://localhost:8888"; const CONFIG_FILE_NAME: &str = "config"; const CONFIG_DIR_NAME: &str = ".hindsight"; +#[derive(Debug)] pub struct Config { pub api_url: String, pub api_key: Option, @@ -174,3 +175,156 @@ pub fn generate_doc_id() -> String { let now = chrono::Local::now(); format!("cli_put_{}", now.format("%Y%m%d_%H%M%S")) } + +/// Parse a simple TOML-like config line and extract value. +/// Handles both quoted and unquoted values. +pub fn parse_config_value(line: &str, key: &str) -> Option { + let line = line.trim(); + if !line.starts_with(key) { + return None; + } + line.split('=').nth(1).map(|value| { + value.trim().trim_matches('"').trim_matches('\'').to_string() + }).filter(|v| !v.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_source_display() { + assert_eq!(format!("{}", ConfigSource::LocalFile), "config file"); + assert_eq!(format!("{}", ConfigSource::Environment), "environment variable"); + assert_eq!(format!("{}", ConfigSource::Default), "default"); + } + + #[test] + fn test_validate_and_create_valid_http() { + let config = Config::validate_and_create( + "http://localhost:8888".to_string(), + None, + ConfigSource::Default, + ); + assert!(config.is_ok()); + let config = config.unwrap(); + assert_eq!(config.api_url, "http://localhost:8888"); + assert_eq!(config.source, ConfigSource::Default); + } + + #[test] + fn test_validate_and_create_valid_https() { + let config = Config::validate_and_create( + "https://api.example.com".to_string(), + Some("secret-key".to_string()), + ConfigSource::Environment, + ); + assert!(config.is_ok()); + let config = config.unwrap(); + assert_eq!(config.api_url, "https://api.example.com"); + assert_eq!(config.api_key, Some("secret-key".to_string())); + assert_eq!(config.source, ConfigSource::Environment); + } + + #[test] + fn test_validate_and_create_invalid_url() { + let config = Config::validate_and_create( + "localhost:8888".to_string(), + None, + ConfigSource::Default, + ); + assert!(config.is_err()); + let err = config.unwrap_err().to_string(); + assert!(err.contains("Invalid API URL")); + assert!(err.contains("Must start with http:// or https://")); + } + + #[test] + fn test_validate_and_create_ftp_url() { + let config = Config::validate_and_create( + "ftp://example.com".to_string(), + None, + ConfigSource::Default, + ); + assert!(config.is_err()); + } + + #[test] + fn test_generate_doc_id_format() { + let doc_id = generate_doc_id(); + assert!(doc_id.starts_with("cli_put_")); + // Should be cli_put_YYYYMMDD_HHMMSS format + assert!(doc_id.len() > 20); // cli_put_ (8) + date (8) + _ (1) + time (6) = 23 + } + + #[test] + fn test_generate_doc_id_uniqueness() { + let id1 = generate_doc_id(); + std::thread::sleep(std::time::Duration::from_secs(1)); + let id2 = generate_doc_id(); + // IDs generated at different times should be different + assert_ne!(id1, id2); + } + + #[test] + fn test_parse_config_value_quoted() { + assert_eq!( + parse_config_value(r#"api_url = "http://localhost:8888""#, "api_url"), + Some("http://localhost:8888".to_string()) + ); + } + + #[test] + fn test_parse_config_value_single_quoted() { + assert_eq!( + parse_config_value("api_url = 'http://localhost:8888'", "api_url"), + Some("http://localhost:8888".to_string()) + ); + } + + #[test] + fn test_parse_config_value_unquoted() { + assert_eq!( + parse_config_value("api_url = http://localhost:8888", "api_url"), + Some("http://localhost:8888".to_string()) + ); + } + + #[test] + fn test_parse_config_value_with_spaces() { + assert_eq!( + parse_config_value(" api_url = \"http://localhost:8888\" ", "api_url"), + Some("http://localhost:8888".to_string()) + ); + } + + #[test] + fn test_parse_config_value_wrong_key() { + assert_eq!( + parse_config_value("api_key = secret", "api_url"), + None + ); + } + + #[test] + fn test_parse_config_value_empty() { + assert_eq!( + parse_config_value("api_url = ", "api_url"), + None + ); + assert_eq!( + parse_config_value("api_url = \"\"", "api_url"), + None + ); + } + + #[test] + fn test_config_api_url_accessor() { + let config = Config { + api_url: "http://test:8080".to_string(), + api_key: None, + source: ConfigSource::Default, + }; + assert_eq!(config.api_url(), "http://test:8080"); + } +} diff --git a/hindsight-cli/src/output.rs b/hindsight-cli/src/output.rs index 0845f801..d0f0d716 100644 --- a/hindsight-cli/src/output.rs +++ b/hindsight-cli/src/output.rs @@ -8,13 +8,35 @@ pub enum OutputFormat { Yaml, } +impl OutputFormat { + /// Parse output format from string + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "json" => Some(OutputFormat::Json), + "yaml" | "yml" => Some(OutputFormat::Yaml), + "pretty" | "text" => Some(OutputFormat::Pretty), + _ => None, + } + } +} + +/// Format data as JSON string +pub fn to_json(data: &T) -> Result { + Ok(serde_json::to_string_pretty(data)?) +} + +/// Format data as YAML string +pub fn to_yaml(data: &T) -> Result { + Ok(serde_yaml::to_string(data)?) +} + pub fn print_output(data: &T, format: OutputFormat) -> Result<()> { match format { OutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(data)?); + println!("{}", to_json(data)?); } OutputFormat::Yaml => { - println!("{}", serde_yaml::to_string(data)?); + println!("{}", to_yaml(data)?); } OutputFormat::Pretty => { // This should not be called - pretty printing is handled in ui.rs @@ -23,3 +45,121 @@ pub fn print_output(data: &T, format: OutputFormat) -> Result<()> } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct TestData { + name: String, + count: i32, + active: bool, + } + + #[test] + fn test_output_format_from_str_json() { + assert_eq!(OutputFormat::from_str("json"), Some(OutputFormat::Json)); + assert_eq!(OutputFormat::from_str("JSON"), Some(OutputFormat::Json)); + assert_eq!(OutputFormat::from_str("Json"), Some(OutputFormat::Json)); + } + + #[test] + fn test_output_format_from_str_yaml() { + assert_eq!(OutputFormat::from_str("yaml"), Some(OutputFormat::Yaml)); + assert_eq!(OutputFormat::from_str("YAML"), Some(OutputFormat::Yaml)); + assert_eq!(OutputFormat::from_str("yml"), Some(OutputFormat::Yaml)); + assert_eq!(OutputFormat::from_str("YML"), Some(OutputFormat::Yaml)); + } + + #[test] + fn test_output_format_from_str_pretty() { + assert_eq!(OutputFormat::from_str("pretty"), Some(OutputFormat::Pretty)); + assert_eq!(OutputFormat::from_str("PRETTY"), Some(OutputFormat::Pretty)); + assert_eq!(OutputFormat::from_str("text"), Some(OutputFormat::Pretty)); + } + + #[test] + fn test_output_format_from_str_invalid() { + assert_eq!(OutputFormat::from_str("xml"), None); + assert_eq!(OutputFormat::from_str("csv"), None); + assert_eq!(OutputFormat::from_str(""), None); + } + + #[test] + fn test_to_json() { + let data = TestData { + name: "test".to_string(), + count: 42, + active: true, + }; + let json = to_json(&data).unwrap(); + assert!(json.contains("\"name\": \"test\"")); + assert!(json.contains("\"count\": 42")); + assert!(json.contains("\"active\": true")); + } + + #[test] + fn test_to_yaml() { + let data = TestData { + name: "test".to_string(), + count: 42, + active: true, + }; + let yaml = to_yaml(&data).unwrap(); + assert!(yaml.contains("name: test")); + assert!(yaml.contains("count: 42")); + assert!(yaml.contains("active: true")); + } + + #[test] + fn test_to_json_array() { + let data = vec![ + TestData { name: "a".to_string(), count: 1, active: true }, + TestData { name: "b".to_string(), count: 2, active: false }, + ]; + let json = to_json(&data).unwrap(); + assert!(json.contains("\"name\": \"a\"")); + assert!(json.contains("\"name\": \"b\"")); + } + + #[test] + fn test_to_yaml_array() { + let data = vec![ + TestData { name: "a".to_string(), count: 1, active: true }, + TestData { name: "b".to_string(), count: 2, active: false }, + ]; + let yaml = to_yaml(&data).unwrap(); + assert!(yaml.contains("name: a")); + assert!(yaml.contains("name: b")); + } + + #[test] + fn test_output_format_equality() { + assert_eq!(OutputFormat::Json, OutputFormat::Json); + assert_ne!(OutputFormat::Json, OutputFormat::Yaml); + assert_ne!(OutputFormat::Yaml, OutputFormat::Pretty); + } + + #[test] + fn test_output_format_clone() { + let format = OutputFormat::Json; + let cloned = format.clone(); + assert_eq!(format, cloned); + } + + #[test] + fn test_to_json_special_chars() { + let data = TestData { + name: "test\"with\\special\nchars".to_string(), + count: 0, + active: false, + }; + let json = to_json(&data).unwrap(); + // JSON should properly escape special characters + assert!(json.contains("\\\"")); + assert!(json.contains("\\\\")); + assert!(json.contains("\\n")); + } +}