synthra 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +4 -0
  3. data/.rubocop.yml +40 -0
  4. data/.yardopts +18 -0
  5. data/ACCESS_DOCS.md +116 -0
  6. data/ADVANCED_FEATURES_SUMMARY.md +245 -0
  7. data/CHANGELOG.md +498 -0
  8. data/CODE_OF_CONDUCT.md +122 -0
  9. data/CONTRIBUTING.md +307 -0
  10. data/DOCUMENTATION.md +204 -0
  11. data/DOCUMENTATION_GUIDE.md +194 -0
  12. data/LICENSE +22 -0
  13. data/README.md +1458 -0
  14. data/Rakefile +128 -0
  15. data/benchmark/README.md +591 -0
  16. data/benchmark/batch_generation.rb +63 -0
  17. data/benchmark/engine_comparison.rb +295 -0
  18. data/benchmark/single_record.rb +108 -0
  19. data/benchmark/streaming.rb +74 -0
  20. data/docs/ACTIVERECORD_INFERENCE.md +524 -0
  21. data/docs/API_SERVER.md +278 -0
  22. data/docs/CONFIG_FILE.md +315 -0
  23. data/docs/DATA_CONTRACTS.md +312 -0
  24. data/docs/ENHANCED_REPL.md +304 -0
  25. data/docs/FACTORY_BOT.md +271 -0
  26. data/docs/GITHUB_ACTION.md +420 -0
  27. data/docs/GRAPHQL_EXPORT.md +303 -0
  28. data/docs/GRAPHQL_FEDERATION.md +651 -0
  29. data/docs/HOW_TO_GENERATE_DOCS.md +252 -0
  30. data/docs/LSP.md +189 -0
  31. data/docs/MIGRATION_GENERATOR.md +547 -0
  32. data/docs/MOCK_SERVER.md +280 -0
  33. data/docs/NATIVE_ENGINE.md +298 -0
  34. data/docs/OPENAPI_EXPORT.md +252 -0
  35. data/docs/PERFORMANCE_MODE.md +228 -0
  36. data/docs/PERSONAS.md +547 -0
  37. data/docs/PROPERTY_TESTING.md +284 -0
  38. data/docs/PROTOBUF_EXPORT.md +276 -0
  39. data/docs/QUALITY_METRICS.md +555 -0
  40. data/docs/QUICK_REFERENCE.md +88 -0
  41. data/docs/RAILS_ENGINE.md +404 -0
  42. data/docs/RAILS_INTEGRATION.md +454 -0
  43. data/docs/README.md +175 -0
  44. data/docs/REPL_QUICK_REFERENCE.md +149 -0
  45. data/docs/RUST_INTEGRATION_GUIDE.md +1376 -0
  46. data/docs/SCENARIOS.md +576 -0
  47. data/docs/SECURITY_TESTING.md +318 -0
  48. data/docs/SNAPSHOT_TESTING.md +308 -0
  49. data/docs/STRING_TEXT_TYPES_REFERENCE.md +512 -0
  50. data/docs/TERRAFORM_EXPORT.md +447 -0
  51. data/docs/TIME_TRAVEL.md +507 -0
  52. data/docs/USAGE_GUIDE.md +980 -0
  53. data/docs/WEBHOOK_SIMULATOR.md +579 -0
  54. data/docs/advanced_features.md +183 -0
  55. data/docs/api_reference.md +153 -0
  56. data/docs/behaviors.md +138 -0
  57. data/docs/best_practices.md +191 -0
  58. data/docs/dsl_reference.md +172 -0
  59. data/docs/examples.md +162 -0
  60. data/docs/generation_modes.md +125 -0
  61. data/docs/getting_started.md +92 -0
  62. data/docs/index.html +177 -0
  63. data/docs/strategic/CLOUD_REGISTRY_ARCHITECTURE.md +268 -0
  64. data/docs/strategic/SIMD_OPTIMIZATION_STRATEGY.md +153 -0
  65. data/docs/troubleshooting.md +215 -0
  66. data/docs/type_reference.md +224 -0
  67. data/examples/hostile_mode_demo.rb +64 -0
  68. data/exe/synthra +6 -0
  69. data/ext/synthra_native/Cargo.lock +972 -0
  70. data/ext/synthra_native/Cargo.toml +46 -0
  71. data/ext/synthra_native/extconf.rb +41 -0
  72. data/ext/synthra_native/src/generator.rs +397 -0
  73. data/ext/synthra_native/src/lib.rs +229 -0
  74. data/ext/synthra_native/src/types.rs +1485 -0
  75. data/lib/generators/synthra/install_generator.rb +86 -0
  76. data/lib/generators/synthra/templates/api_response.dsl +15 -0
  77. data/lib/generators/synthra/templates/fake_data.rake +116 -0
  78. data/lib/generators/synthra/templates/synthra.yml +31 -0
  79. data/lib/generators/synthra/templates/synthra_support.rb +19 -0
  80. data/lib/generators/synthra/templates/user.dsl +11 -0
  81. data/lib/synthra/activerecord_inference.rb +394 -0
  82. data/lib/synthra/api.rb +381 -0
  83. data/lib/synthra/api_server.rb +718 -0
  84. data/lib/synthra/behaviors/applicator.rb +165 -0
  85. data/lib/synthra/behaviors/base.rb +155 -0
  86. data/lib/synthra/behaviors/close_connection.rb +60 -0
  87. data/lib/synthra/behaviors/deprecated.rb +100 -0
  88. data/lib/synthra/behaviors/failure.rb +69 -0
  89. data/lib/synthra/behaviors/latency.rb +103 -0
  90. data/lib/synthra/behaviors/partial_data.rb +83 -0
  91. data/lib/synthra/behaviors/randomize_order.rb +74 -0
  92. data/lib/synthra/behaviors/registry.rb +238 -0
  93. data/lib/synthra/behaviors/simulate_error.rb +79 -0
  94. data/lib/synthra/cli/commands/base.rb +105 -0
  95. data/lib/synthra/cli/commands/diff.rb +227 -0
  96. data/lib/synthra/cli/commands/docs.rb +67 -0
  97. data/lib/synthra/cli/commands/export.rb +150 -0
  98. data/lib/synthra/cli/commands/generate.rb +72 -0
  99. data/lib/synthra/cli/commands/import.rb +80 -0
  100. data/lib/synthra/cli/commands/lint.rb +53 -0
  101. data/lib/synthra/cli/commands/live.rb +57 -0
  102. data/lib/synthra/cli/commands/seed.rb +99 -0
  103. data/lib/synthra/cli/commands/validate.rb +28 -0
  104. data/lib/synthra/cli.rb +2471 -0
  105. data/lib/synthra/config_file.rb +129 -0
  106. data/lib/synthra/configuration.rb +281 -0
  107. data/lib/synthra/contracts_registry.rb +408 -0
  108. data/lib/synthra/database_seeder.rb +268 -0
  109. data/lib/synthra/deterministic_ids.rb +218 -0
  110. data/lib/synthra/documentation_generator.rb +414 -0
  111. data/lib/synthra/engine.rb +251 -0
  112. data/lib/synthra/errors.rb +1169 -0
  113. data/lib/synthra/export/base.rb +85 -0
  114. data/lib/synthra/export/csv.rb +101 -0
  115. data/lib/synthra/export/graphql.rb +266 -0
  116. data/lib/synthra/export/graphql_federation.rb +377 -0
  117. data/lib/synthra/export/graphviz.rb +258 -0
  118. data/lib/synthra/export/javascript.rb +327 -0
  119. data/lib/synthra/export/json_data.rb +61 -0
  120. data/lib/synthra/export/json_schema.rb +290 -0
  121. data/lib/synthra/export/openapi.rb +514 -0
  122. data/lib/synthra/export/protobuf.rb +483 -0
  123. data/lib/synthra/export/python.rb +560 -0
  124. data/lib/synthra/export/sql.rb +381 -0
  125. data/lib/synthra/export/sql_insert.rb +152 -0
  126. data/lib/synthra/export/terraform.rb +501 -0
  127. data/lib/synthra/export/type_mapping.rb +389 -0
  128. data/lib/synthra/export/typescript.rb +300 -0
  129. data/lib/synthra/export/xml_data.rb +104 -0
  130. data/lib/synthra/export/yaml_data.rb +56 -0
  131. data/lib/synthra/export.rb +404 -0
  132. data/lib/synthra/factory_bot_integration.rb +157 -0
  133. data/lib/synthra/field.rb +440 -0
  134. data/lib/synthra/functions/registry.rb +104 -0
  135. data/lib/synthra/generator/context.rb +372 -0
  136. data/lib/synthra/generator/engine.rb +336 -0
  137. data/lib/synthra/generator/faker_adapter.rb +425 -0
  138. data/lib/synthra/generator/modes.rb +444 -0
  139. data/lib/synthra/generator/resolver.rb +256 -0
  140. data/lib/synthra/generator/rng.rb +273 -0
  141. data/lib/synthra/generator/streamer.rb +63 -0
  142. data/lib/synthra/generator/uniqueness.rb +118 -0
  143. data/lib/synthra/initializer.rb +97 -0
  144. data/lib/synthra/limits.rb +279 -0
  145. data/lib/synthra/live_preview.rb +518 -0
  146. data/lib/synthra/loader_config.rb +78 -0
  147. data/lib/synthra/lsp/server.rb +689 -0
  148. data/lib/synthra/migration_generator.rb +301 -0
  149. data/lib/synthra/mixin.rb +174 -0
  150. data/lib/synthra/mock_server.rb +458 -0
  151. data/lib/synthra/native_engine.rb +304 -0
  152. data/lib/synthra/openapi_importer.rb +228 -0
  153. data/lib/synthra/output/json_formatter.rb +40 -0
  154. data/lib/synthra/output/ndjson_formatter.rb +55 -0
  155. data/lib/synthra/parser/ast.rb +1287 -0
  156. data/lib/synthra/parser/lexer.rb +1152 -0
  157. data/lib/synthra/parser/parser.rb +1664 -0
  158. data/lib/synthra/parser/tokens.rb +461 -0
  159. data/lib/synthra/performance_mode.rb +364 -0
  160. data/lib/synthra/personas.rb +397 -0
  161. data/lib/synthra/property_testing.rb +172 -0
  162. data/lib/synthra/quality_metrics.rb +405 -0
  163. data/lib/synthra/rails_test_helper.rb +248 -0
  164. data/lib/synthra/registry.rb +533 -0
  165. data/lib/synthra/relationships.rb +193 -0
  166. data/lib/synthra/repl/enhanced_repl.rb +607 -0
  167. data/lib/synthra/repl/formatter.rb +191 -0
  168. data/lib/synthra/scenarios.rb +423 -0
  169. data/lib/synthra/schema.rb +605 -0
  170. data/lib/synthra/schema_inheritance.rb +104 -0
  171. data/lib/synthra/schema_versioning.rb +212 -0
  172. data/lib/synthra/snapshot_testing.rb +199 -0
  173. data/lib/synthra/time_travel.rb +338 -0
  174. data/lib/synthra/type_definitions.rb +274 -0
  175. data/lib/synthra/types/address_location/addresses.rb +125 -0
  176. data/lib/synthra/types/address_location/airports.rb +200 -0
  177. data/lib/synthra/types/address_location/banks_hospitals.rb +207 -0
  178. data/lib/synthra/types/address_location/locations.rb +406 -0
  179. data/lib/synthra/types/base.rb +48 -0
  180. data/lib/synthra/types/commerce_products/commerce.rb +119 -0
  181. data/lib/synthra/types/commerce_products/companies.rb +71 -0
  182. data/lib/synthra/types/commerce_products/construction.rb +126 -0
  183. data/lib/synthra/types/commerce_products/products.rb +205 -0
  184. data/lib/synthra/types/core/collections.rb +513 -0
  185. data/lib/synthra/types/core/defaults.rb +76 -0
  186. data/lib/synthra/types/core/enums.rb +102 -0
  187. data/lib/synthra/types/core/identifiers.rb +445 -0
  188. data/lib/synthra/types/core/primitives.rb +586 -0
  189. data/lib/synthra/types/core/references.rb +466 -0
  190. data/lib/synthra/types/core/sequences.rb +304 -0
  191. data/lib/synthra/types/crypto/crypto.rb +162 -0
  192. data/lib/synthra/types/date_time/dates.rb +387 -0
  193. data/lib/synthra/types/finance_banking/banking.rb +424 -0
  194. data/lib/synthra/types/finance_banking/credit_cards.rb +24 -0
  195. data/lib/synthra/types/finance_banking/identifiers.rb +93 -0
  196. data/lib/synthra/types/formula.rb +184 -0
  197. data/lib/synthra/types/health_medical/medical.rb +150 -0
  198. data/lib/synthra/types/hostile_payloads.rb +122 -0
  199. data/lib/synthra/types/json_array.rb +51 -0
  200. data/lib/synthra/types/media_entertainment/media.rb +62 -0
  201. data/lib/synthra/types/naughty_string.rb +30 -0
  202. data/lib/synthra/types/personal_names/chinese.rb +41 -0
  203. data/lib/synthra/types/personal_names/identifiers.rb +187 -0
  204. data/lib/synthra/types/personal_names/names.rb +246 -0
  205. data/lib/synthra/types/personal_names/national_id.rb +89 -0
  206. data/lib/synthra/types/personal_names/titles_suffixes.rb +41 -0
  207. data/lib/synthra/types/regex.rb +248 -0
  208. data/lib/synthra/types/registry.rb +182 -0
  209. data/lib/synthra/types/repeating_element.rb +50 -0
  210. data/lib/synthra/types/scenario.rb +29 -0
  211. data/lib/synthra/types/technology_internet/apps.rb +67 -0
  212. data/lib/synthra/types/technology_internet/communication.rb +162 -0
  213. data/lib/synthra/types/technology_internet/devices.rb +80 -0
  214. data/lib/synthra/types/technology_internet/formats.rb +139 -0
  215. data/lib/synthra/types/technology_internet/networking.rb +143 -0
  216. data/lib/synthra/types/template.rb +92 -0
  217. data/lib/synthra/types/text_content/business.rb +128 -0
  218. data/lib/synthra/types/text_content/colors.rb +70 -0
  219. data/lib/synthra/types/text_content/misc.rb +237 -0
  220. data/lib/synthra/types/text_content/security.rb +130 -0
  221. data/lib/synthra/types/text_content/text_generation.rb +532 -0
  222. data/lib/synthra/types/travel/travel.rb +150 -0
  223. data/lib/synthra/utils/string_distance.rb +133 -0
  224. data/lib/synthra/validator/dsl_validator.rb +339 -0
  225. data/lib/synthra/validator/path_validator.rb +615 -0
  226. data/lib/synthra/version.rb +35 -0
  227. data/lib/synthra/webhook_simulator.rb +341 -0
  228. data/lib/synthra.rb +259 -0
  229. data/schemas/address.dsl +16 -0
  230. data/schemas/api_response.dsl +8 -0
  231. data/schemas/error_payload.dsl +9 -0
  232. data/schemas/order.dsl +10 -0
  233. data/schemas/order_item.dsl +8 -0
  234. data/schemas/payment.dsl +11 -0
  235. data/schemas/social_post.dsl +13 -0
  236. data/schemas/user.dsl +10 -0
  237. data/scripts/batch_fix_all.rb +96 -0
  238. data/scripts/delete_old_files.rb +32 -0
  239. data/scripts/fix_all_grouped_files.rb +137 -0
  240. data/scripts/fix_all_indentation.rb +48 -0
  241. data/scripts/fix_all_syntax.rb +124 -0
  242. data/scripts/fix_grouped_files.rb +184 -0
  243. data/scripts/fix_syntax_errors.rb +108 -0
  244. data/scripts/group_domain_types.rb +112 -0
  245. data/scripts/group_domain_types_fixed.rb +150 -0
  246. data/scripts/merge_domains_to_one_file.rb +68 -0
  247. data/scripts/move_existing_types.rb +130 -0
  248. data/scripts/split_grouped_types.rb +142 -0
  249. data/tech_docs/README.md +134 -0
  250. data/tech_docs/advanced/streaming.md +405 -0
  251. data/tech_docs/advanced/thread_safety.md +253 -0
  252. data/tech_docs/api/overview.md +485 -0
  253. data/tech_docs/appendices/type_chart.md +193 -0
  254. data/tech_docs/basic_concepts.md +383 -0
  255. data/tech_docs/behaviors/overview.md +346 -0
  256. data/tech_docs/dsl/complex_types.md +790 -0
  257. data/tech_docs/dsl/core_types.md +464 -0
  258. data/tech_docs/dsl/datetime_types.md +325 -0
  259. data/tech_docs/dsl/field_modifiers.md +414 -0
  260. data/tech_docs/dsl/grammar.md +431 -0
  261. data/tech_docs/dsl/schema_definition.md +399 -0
  262. data/tech_docs/export/README.md +276 -0
  263. data/tech_docs/installation.md +273 -0
  264. data/tech_docs/integration/ci_cd.md +707 -0
  265. data/tech_docs/integration/ci_cd_guide.md +579 -0
  266. data/tech_docs/integration/factory_bot.md +485 -0
  267. data/tech_docs/integration/rails.md +630 -0
  268. data/tech_docs/integration/rspec.md +449 -0
  269. data/tech_docs/modes/overview.md +350 -0
  270. data/tech_docs/performance/NATIVE_RUST_EXTENSION.md +1270 -0
  271. data/tech_docs/performance/OPTIMIZATION_GUIDE.md +901 -0
  272. data/tech_docs/quick_start.md +256 -0
  273. data/tech_docs/templates/README.md +805 -0
  274. data/tech_docs/tutorials/advanced.md +371 -0
  275. data/tech_docs/tutorials/getting_started.md +189 -0
  276. data/tech_docs/tutorials/intermediate.md +231 -0
  277. data/tech_docs/tutorials/template_gallery.md +569 -0
  278. data/tech_example/01_basic_usage.rb +238 -0
  279. data/tech_example/02_types_demo.rb +336 -0
  280. data/tech_example/04_cli_usage.md +429 -0
  281. data/tech_example/05_database_seeding.rb +283 -0
  282. data/tech_example/07_rspec_integration.rb +359 -0
  283. data/tech_example/10_custom_types.rb +387 -0
  284. data/tech_example/12_behaviors.rb +372 -0
  285. data/tech_example/13_twitter_dm_example.rb +120 -0
  286. data/tech_example/14_exact_json_structure.rb +253 -0
  287. data/tech_example/16_lsp_server.rb +56 -0
  288. data/tech_example/17_property_testing.rb +92 -0
  289. data/tech_example/18_enhanced_repl.rb +120 -0
  290. data/tech_example/19_security_fuzzing.rb +145 -0
  291. data/tech_example/NEW_FEATURES.md +169 -0
  292. data/tech_example/README.md +118 -0
  293. data/tech_example/schemas/api_response.dsl +123 -0
  294. data/tech_example/schemas/ecommerce.dsl +121 -0
  295. data/tech_example/schemas/twitter_dm.dsl +109 -0
  296. data/tech_example/schemas/user.dsl +36 -0
  297. data/vscode-extension/README.md +246 -0
  298. data/vscode-extension/language-configuration.json +31 -0
  299. data/vscode-extension/package.json +55 -0
  300. data/vscode-extension/snippets/fakedatadsl.json +198 -0
  301. data/vscode-extension/syntaxes/fakedatadsl.tmLanguage.json +128 -0
  302. metadata +478 -0
@@ -0,0 +1,2471 @@
1
+ # frozen_string_literal: true
2
+
3
+ # =============================================================================
4
+
5
+ # Synthra Command Line Interface
6
+ # =============================================================================
7
+ #
8
+ # Provides a command-line interface for validating schemas and generating
9
+ # fake data. The CLI supports multiple output formats and generation modes.
10
+ #
11
+ # @example Validate a schema
12
+ # $ synthra validate schemas/user.dsl
13
+ # Schema valid: schemas/user.dsl
14
+ #
15
+ # @example Generate fake data
16
+ # $ synthra generate User --dir schemas --count 10 --pretty
17
+ #
18
+ # @example Generate NDJSON for streaming
19
+ # $ synthra generate User --dir schemas --count 1000 --ndjson
20
+ #
21
+ # =============================================================================
22
+
23
+
24
+ require "optparse"
25
+ require "fileutils"
26
+
27
+ module Synthra
28
+
29
+ # Command-line interface for Synthra
30
+ #
31
+ # The CLI provides commands for schema validation and data generation.
32
+ # It supports multiple output formats (JSON, NDJSON) and generation
33
+ # modes (random, edge, invalid, mixed).
34
+ #
35
+ # Commands:
36
+ # - `validate <path>`: Check if a DSL file is syntactically valid
37
+ # - `lint <path|dir>`: Lint schemas for path errors, missing fields, etc.
38
+ # - `generate <SchemaName>`: Generate fake data for a schema
39
+ #
40
+ # @example Run the CLI
41
+ # cli = CLI.new(ARGV)
42
+ # exit_code = cli.run
43
+ #
44
+
45
+ class CLI
46
+
47
+ # Exit code for successful execution
48
+ # @return [Integer] 0
49
+ EXIT_SUCCESS = 0
50
+
51
+ # Exit code for parse/validation errors
52
+ # @return [Integer] 1
53
+ EXIT_PARSE_ERROR = 1
54
+
55
+ # Exit code for runtime errors
56
+ # @return [Integer] 2
57
+ EXIT_RUNTIME_ERROR = 2
58
+
59
+ # Create a new CLI instance
60
+ #
61
+ # @param args [Array<String>] command-line arguments (typically ARGV)
62
+ #
63
+ # @example
64
+ # cli = CLI.new(["generate", "User", "--count", "10"])
65
+ #
66
+
67
+ def initialize(args)
68
+ @args = args
69
+ @options = {
70
+ count: 1, # Number of records to generate
71
+ mode: :random, # Generation mode
72
+ format: :json, # Output format
73
+ pretty: false, # Pretty-print JSON
74
+ dir: "." # Directory containing .dsl files
75
+ }
76
+ end
77
+
78
+ # Run the CLI command
79
+ #
80
+ # Parses the command and options, executes the appropriate command,
81
+ # and returns an exit code.
82
+ #
83
+ # @return [Integer] exit code (0 = success, 1 = parse error, 2 = runtime error)
84
+ #
85
+ # @example
86
+ # exit_code = cli.run
87
+ # exit(exit_code)
88
+ #
89
+
90
+ def run
91
+ return show_help if @args.empty?
92
+
93
+ command = @args.shift
94
+ case command
95
+ when "validate"
96
+ validate_command
97
+ when "lint"
98
+ lint_command
99
+ when "preview"
100
+ preview_command
101
+ when "generate"
102
+ generate_command
103
+ when "info"
104
+ info_command
105
+ when "diff"
106
+ diff_command
107
+ when "repl"
108
+ repl_command # :nocov:
109
+ when "export"
110
+ export_command
111
+ when "graph"
112
+ graph_command
113
+ when "watch"
114
+ watch_command # :nocov:
115
+ when "lsp"
116
+ lsp_command
117
+ when "import"
118
+ import_command
119
+ when "docs"
120
+ docs_command
121
+ when "live"
122
+ live_command # :nocov:
123
+ when "seed"
124
+ seed_command
125
+ when "server"
126
+ server_command # :nocov:
127
+ when "contracts"
128
+ contracts_command
129
+ when "perf"
130
+ perf_command
131
+ when "--help", "-h", "help"
132
+ show_help
133
+ when "--version", "-v"
134
+ show_version
135
+ else
136
+ $stderr.puts "Unknown command: #{command}"
137
+ $stderr.puts "Run 'synthra --help' for usage information."
138
+ EXIT_PARSE_ERROR
139
+ end
140
+ rescue Synthra::Errors::ParseError => e
141
+ $stderr.puts "Parse error: #{e.message}"
142
+ EXIT_PARSE_ERROR
143
+ rescue Synthra::Errors::Error => e
144
+ $stderr.puts "Error: #{e.message}"
145
+ EXIT_RUNTIME_ERROR
146
+ rescue Errno::ENOENT => e
147
+ $stderr.puts "File not found: #{e.message}"
148
+ EXIT_PARSE_ERROR
149
+ end
150
+
151
+ private
152
+
153
+ # Execute the validate command
154
+ #
155
+ # Loads and parses a DSL file to check for syntax errors.
156
+ # Prints success message or error details.
157
+ #
158
+ # @return [Integer] exit code
159
+ #
160
+
161
+ def validate_command
162
+ parse_validate_options
163
+ path = @args.shift
164
+
165
+ unless path
166
+ $stderr.puts "Usage: synthra validate <path>"
167
+ return EXIT_PARSE_ERROR
168
+ end
169
+
170
+ registry = Registry.new
171
+ registry.load_file(path)
172
+ puts "Schema valid: #{path}"
173
+ EXIT_SUCCESS
174
+ end
175
+
176
+ # Execute the lint command
177
+ #
178
+ # Loads schemas and checks for semantic errors:
179
+ # - Invalid copy() paths
180
+ # - Missing map_by_field key fields
181
+ # - Undefined schema references
182
+ # - Potential typos with suggestions
183
+ #
184
+ # @return [Integer] exit code
185
+ #
186
+
187
+ def lint_command
188
+ parse_lint_options
189
+ path = @args.shift
190
+
191
+ unless path
192
+ $stderr.puts "Usage: synthra lint <path|directory>"
193
+ return EXIT_PARSE_ERROR
194
+ end
195
+
196
+ registry = Registry.new
197
+
198
+ # Load schemas without automatic validation (we'll validate manually for better output)
199
+ if File.directory?(path)
200
+ load_schemas_from_dir_no_validate(registry, path)
201
+ else
202
+ registry.load_file(path, validate: false)
203
+ end
204
+
205
+ # Run validation and collect errors
206
+ errors = registry.validate_paths
207
+
208
+ if errors.empty?
209
+ puts "✓ No lint errors found in #{registry.size} schema(s)"
210
+ if @options[:verbose]
211
+ puts "\nSchemas checked:"
212
+ registry.names.sort.each { |name| puts " - #{name}" }
213
+ end
214
+ EXIT_SUCCESS
215
+ else
216
+ puts "✗ Found #{errors.length} lint error(s):\n\n"
217
+
218
+ errors.each_with_index do |error, i|
219
+ puts "#{i + 1}. #{error.message}"
220
+ # :nocov:
221
+ if error.respond_to?(:suggestions) && error.suggestions&.any?
222
+ puts " Did you mean: #{error.suggestions.first(3).join(", ")}?"
223
+ end
224
+ # :nocov:
225
+ puts
226
+ end
227
+
228
+ # In strict mode, treat warnings as errors
229
+ if @options[:strict]
230
+ $stderr.puts "Strict mode: #{errors.length} error(s) treated as failures"
231
+ end
232
+
233
+ EXIT_PARSE_ERROR
234
+ end
235
+ end
236
+
237
+ # Execute the preview command
238
+ #
239
+ # Generates a single record for quick schema testing.
240
+ # Always outputs pretty-printed JSON with schema info header.
241
+ #
242
+ # @return [Integer] exit code
243
+ #
244
+
245
+ def preview_command
246
+ parse_preview_options
247
+ schema_name = @args.shift
248
+
249
+ unless schema_name
250
+ $stderr.puts "Usage: synthra preview <SchemaName> [options]"
251
+ return EXIT_PARSE_ERROR
252
+ end
253
+
254
+ registry = Registry.new
255
+ load_schemas_from_dir(registry, @options[:dir])
256
+
257
+ schema = registry.schema(schema_name)
258
+
259
+ # Generate single record
260
+ options = { registry: registry, mode: @options[:mode] }
261
+ options[:seed] = @options[:seed] if @options[:seed]
262
+
263
+ record = schema.generate(**options)
264
+
265
+ # Output with header
266
+ puts "# #{schema_name} (#{schema.fields.length} fields)"
267
+ puts JSON.pretty_generate(record)
268
+
269
+ EXIT_SUCCESS
270
+ rescue KeyError
271
+ $stderr.puts "Unknown schema: #{schema_name}"
272
+ $stderr.puts "Available schemas: #{registry.schemas.keys.join(", ")}"
273
+ EXIT_PARSE_ERROR
274
+ end
275
+
276
+ # Execute the info command
277
+ #
278
+ # Shows information about schemas including version, fields, and deprecation status.
279
+ #
280
+ # @return [Integer] exit code
281
+ #
282
+
283
+ def info_command
284
+ parse_info_options
285
+ path = @args.shift
286
+
287
+ unless path
288
+ $stderr.puts "Usage: synthra info <path|directory> [options]"
289
+ return EXIT_PARSE_ERROR
290
+ end
291
+
292
+ registry = Registry.new
293
+
294
+ if File.directory?(path)
295
+ load_schemas_from_dir_no_validate(registry, path)
296
+ else
297
+ registry.load_file(path, validate: false)
298
+ end
299
+
300
+ schemas = registry.schemas
301
+
302
+ if schemas.empty?
303
+ puts "No schemas found."
304
+ return EXIT_SUCCESS
305
+ end
306
+
307
+ puts "=" * 60
308
+ puts "Schema Information"
309
+ puts "=" * 60
310
+ puts
311
+
312
+ schemas.each do |name, schema|
313
+ puts "📦 #{name}"
314
+ puts " Version: #{schema.version || 'not specified'}"
315
+ puts " Fields: #{schema.fields.length}"
316
+ puts " Behaviors: #{schema.behaviors.length}"
317
+
318
+ # :nocov:
319
+ if schema.deprecated?
320
+ puts " ⚠️ DEPRECATED: #{schema.deprecation_message || 'No message'}"
321
+ end
322
+ # :nocov:
323
+
324
+ if @options[:verbose]
325
+ puts " Field list:"
326
+ schema.fields.each do |field|
327
+ optional = field.optional? ? "?" : ""
328
+ nullable = field.nullable? ? "?" : ""
329
+ puts " - #{field.name}#{optional}: #{field.type_name}#{nullable}"
330
+ end
331
+ end
332
+ puts
333
+ end
334
+
335
+ EXIT_SUCCESS
336
+ end
337
+
338
+ # Execute the diff command
339
+ #
340
+ # Compares two schema files/directories and shows differences.
341
+ #
342
+ # @return [Integer] exit code
343
+ #
344
+
345
+ def diff_command
346
+ parse_diff_options
347
+ path1 = @args.shift
348
+ path2 = @args.shift
349
+
350
+ unless path1 && path2
351
+ $stderr.puts "Usage: synthra diff <path1> <path2>"
352
+ return EXIT_PARSE_ERROR
353
+ end
354
+
355
+ registry1 = Registry.new
356
+ registry2 = Registry.new
357
+
358
+ load_path(registry1, path1)
359
+ load_path(registry2, path2)
360
+
361
+ schemas1 = registry1.schemas
362
+ schemas2 = registry2.schemas
363
+
364
+ all_names = (schemas1.keys + schemas2.keys).uniq.sort
365
+
366
+ puts "=" * 60
367
+ puts "Schema Diff"
368
+ puts "=" * 60
369
+ puts
370
+
371
+ has_diff = false
372
+
373
+ all_names.each do |name|
374
+ s1 = schemas1[name]
375
+ s2 = schemas2[name]
376
+
377
+ if s1.nil?
378
+ puts "➕ #{name} (added in #{path2})"
379
+ has_diff = true
380
+ elsif s2.nil?
381
+ puts "➖ #{name} (removed in #{path2})"
382
+ has_diff = true
383
+ else
384
+ diff = compare_schemas(s1, s2)
385
+ if diff.any?
386
+ puts "📝 #{name} (modified)"
387
+ diff.each { |d| puts " #{d}" }
388
+ has_diff = true
389
+ end
390
+ end
391
+ end
392
+
393
+ puts "✅ No differences found." unless has_diff
394
+ EXIT_SUCCESS
395
+ end
396
+
397
+ # Execute the repl command
398
+ #
399
+ # Starts an interactive REPL for testing schemas.
400
+ #
401
+ # @return [Integer] exit code
402
+ #
403
+
404
+ # :nocov:
405
+ def repl_command
406
+ parse_repl_options
407
+ require_relative "repl/formatter"
408
+
409
+ puts "=" * 60
410
+ puts "Synthra Interactive REPL (Enhanced)"
411
+ puts "=" * 60
412
+ puts
413
+ puts "Commands:"
414
+ puts " load <path> - Load schemas from file/directory"
415
+ puts " list - List loaded schemas"
416
+ puts " info <Schema> - Show schema details"
417
+ puts " gen <Schema> - Generate one record (JSON)"
418
+ puts " gen <Schema> N - Generate N records (JSON)"
419
+ puts " table <Schema> - Generate one record (table view)"
420
+ puts " table <Schema> N - Generate N records (table view)"
421
+ puts " debug <Schema> - Step-through debug generation"
422
+ puts " inspect <Schema> - Generate and show detailed inspection"
423
+ puts " seed <number> - Set seed for deterministic output"
424
+ puts " mode <mode> - Set mode (random/edge/invalid/hostile/mixed)"
425
+ puts " help - Show this help"
426
+ puts " quit/exit - Exit REPL"
427
+ puts
428
+
429
+ registry = Registry.new
430
+ current_seed = nil
431
+ current_mode = :random
432
+
433
+ loop do
434
+ print "synthra> "
435
+ input = $stdin.gets&.strip
436
+ break if input.nil?
437
+
438
+ case input
439
+ when /^load\s+(.+)$/
440
+ path = $1.strip
441
+ begin
442
+ if File.directory?(path)
443
+ load_schemas_from_dir_no_validate(registry, path)
444
+ else
445
+ registry.load_file(path, validate: false)
446
+ end
447
+ puts "✅ Loaded #{registry.size} schema(s)"
448
+ rescue => e
449
+ puts "❌ Error: #{e.message}"
450
+ end
451
+
452
+ when "list"
453
+ if registry.size == 0
454
+ puts "No schemas loaded. Use 'load <path>' first."
455
+ else
456
+ puts "Loaded schemas:"
457
+ registry.schemas.each do |name, schema|
458
+ version = schema.version ? " v#{schema.version}" : ""
459
+ deprecated = schema.deprecated? ? " [DEPRECATED]" : ""
460
+ puts " - #{name}#{version}#{deprecated}"
461
+ end
462
+ end
463
+
464
+ when /^info\s+(\w+)$/
465
+ name = $1
466
+ begin
467
+ schema = registry.schema(name)
468
+ puts "Schema: #{name}"
469
+ puts " Version: #{schema.version || 'not specified'}"
470
+ puts " Fields: #{schema.fields.length}"
471
+ schema.fields.each do |f|
472
+ optional = f.optional? ? "?" : ""
473
+ nullable = f.nullable? ? " (nullable)" : ""
474
+ puts " - #{f.name}#{optional}: #{f.type_name}#{nullable}"
475
+ end
476
+ rescue KeyError
477
+ puts "❌ Schema '#{name}' not found"
478
+ end
479
+
480
+ when /^gen\s+(\w+)(?:\s+(\d+))?$/
481
+ name = $1
482
+ count = ($2 || "1").to_i
483
+ begin
484
+ schema = registry.schema(name)
485
+ opts = { registry: registry, mode: current_mode }
486
+ opts[:seed] = current_seed if current_seed
487
+
488
+ if count == 1
489
+ record = schema.generate(**opts)
490
+ puts JSON.pretty_generate(record)
491
+ else
492
+ records = schema.generate_many(count, **opts)
493
+ puts JSON.pretty_generate(records)
494
+ end
495
+ rescue KeyError
496
+ puts "❌ Schema '#{name}' not found"
497
+ rescue => e
498
+ puts "❌ Error: #{e.message}"
499
+ puts e.backtrace.first(3).join("\n") if @options[:verbose]
500
+ end
501
+
502
+ when /^table\s+(\w+)(?:\s+(\d+))?$/
503
+ name = $1
504
+ count = ($2 || "1").to_i
505
+ begin
506
+ schema = registry.schema(name)
507
+ opts = { registry: registry, mode: current_mode }
508
+ opts[:seed] = current_seed if current_seed
509
+
510
+ if count == 1
511
+ record = schema.generate(**opts)
512
+ puts REPL::Formatter.table(record)
513
+ else
514
+ records = schema.generate_many(count, **opts)
515
+ puts REPL::Formatter.table(records)
516
+ end
517
+ rescue KeyError
518
+ puts "❌ Schema '#{name}' not found"
519
+ rescue => e
520
+ puts "❌ Error: #{e.message}"
521
+ end
522
+
523
+ when /^debug\s+(\w+)$/
524
+ name = $1
525
+ begin
526
+ schema = registry.schema(name)
527
+ debug_generation(schema, registry, current_mode, current_seed)
528
+ rescue KeyError
529
+ puts "❌ Schema '#{name}' not found"
530
+ rescue => e
531
+ puts "❌ Error: #{e.message}"
532
+ end
533
+
534
+ when /^inspect\s+(\w+)$/
535
+ name = $1
536
+ begin
537
+ schema = registry.schema(name)
538
+ opts = { registry: registry, mode: current_mode }
539
+ opts[:seed] = current_seed if current_seed
540
+
541
+ record = schema.generate(**opts)
542
+ puts "📊 Generated Record:"
543
+ puts "=" * 60
544
+ puts REPL::Formatter.table(record)
545
+ puts
546
+ puts "📋 JSON Format:"
547
+ puts JSON.pretty_generate(record)
548
+ puts
549
+ puts "📏 Statistics:"
550
+ puts " Fields: #{record.keys.length}"
551
+ puts " Total size: #{JSON.generate(record).bytesize} bytes"
552
+ record.each do |key, value|
553
+ type = value.class.name
554
+ size = case value
555
+ when String then value.bytesize
556
+ when Hash then "#{value.keys.length} keys"
557
+ when Array then "#{value.length} items"
558
+ else value.inspect.length
559
+ end
560
+ puts " #{key}: #{type} (#{size})"
561
+ end
562
+ rescue KeyError
563
+ puts "❌ Schema '#{name}' not found"
564
+ rescue => e
565
+ puts "❌ Error: #{e.message}"
566
+ end
567
+
568
+ when /^seed\s+(\d+)$/
569
+ current_seed = $1.to_i
570
+ puts "✅ Seed set to #{current_seed}"
571
+
572
+ when /^mode\s+(random|edge|invalid|hostile|mixed)$/
573
+ current_mode = $1.to_sym
574
+ puts "✅ Mode set to #{current_mode}"
575
+
576
+ when "help"
577
+ puts "Commands: load, list, info, gen, table, debug, inspect, seed, mode, help, quit"
578
+
579
+ when "quit", "exit"
580
+ puts "Goodbye!"
581
+ break
582
+
583
+ when ""
584
+ # Ignore empty input
585
+
586
+ else
587
+ puts "Unknown command. Type 'help' for available commands."
588
+ end
589
+ end
590
+
591
+ EXIT_SUCCESS
592
+ end
593
+
594
+ # Debug generation with step-through
595
+ #
596
+ # @param schema [Schema] schema to generate from
597
+ # @param registry [Registry] schema registry
598
+ # @param mode [Symbol] generation mode
599
+ # @param seed [Integer, nil] seed for determinism
600
+ #
601
+ def debug_generation(schema, registry, mode, seed)
602
+ puts "🐛 Debug Mode: Generating #{schema.name}"
603
+ puts "=" * 60
604
+ puts "Press Enter to continue after each field, 'q' to quit, 'c' to continue without pausing"
605
+ puts
606
+
607
+ field_index = 0
608
+ context_snapshot = nil
609
+
610
+ # Set up callback to intercept field generation
611
+ original_callback = Synthra.configuration.on_field_generated
612
+
613
+ Synthra.configuration.on_field_generated = lambda do |schema_name, field_name, value, duration|
614
+ field_index += 1
615
+
616
+ puts "\n[Field #{field_index}] #{field_name}"
617
+ puts "─" * 60
618
+ puts "Value: #{REPL::Formatter.format_value(value, 80)}"
619
+ puts "Duration: #{duration}ms" if duration
620
+ puts "Schema: #{schema_name}"
621
+ puts
622
+
623
+ loop do
624
+ print "Press Enter to continue, 'q' to quit, 'c' to continue without pausing: "
625
+ response = $stdin.gets&.strip&.downcase
626
+
627
+ case response
628
+ when "q", "quit"
629
+ Synthra.configuration.on_field_generated = original_callback
630
+ puts "Debug cancelled."
631
+ return
632
+ when "c", "continue"
633
+ Synthra.configuration.on_field_generated = original_callback
634
+ puts "Continuing without pausing..."
635
+ return
636
+ when "", nil
637
+ break
638
+ else
639
+ puts "Invalid input. Press Enter, 'q', or 'c'."
640
+ end
641
+ end
642
+ end
643
+
644
+ begin
645
+ opts = { registry: registry, mode: mode }
646
+ opts[:seed] = seed if seed
647
+
648
+ result = schema.generate(**opts)
649
+
650
+ puts
651
+ puts "=" * 60
652
+ puts "✅ Generation Complete!"
653
+ puts "=" * 60
654
+ puts
655
+ puts "Final Record:"
656
+ puts REPL::Formatter.table(result)
657
+ ensure
658
+ Synthra.configuration.on_field_generated = original_callback
659
+ end
660
+ end
661
+ # :nocov:
662
+
663
+ # Execute the export command
664
+ #
665
+ # Exports schema in various formats (JSON Schema, etc.)
666
+ #
667
+ # @return [Integer] exit code
668
+ #
669
+
670
+ def export_command
671
+ parse_export_options
672
+ schema_name = @args.shift
673
+
674
+ registry = Registry.new
675
+ load_schemas_from_dir_no_validate(registry, @options[:dir])
676
+
677
+ format = @options[:export_format]
678
+
679
+ # Handle "export all" mode for bulk exports
680
+ if @options[:all]
681
+ output = case format
682
+ when "typescript", "ts"
683
+ Export::Typescript.export_all(registry)
684
+ when "javascript", "js"
685
+ Export::Javascript.export_all(registry)
686
+ when "python", "py", "pydantic"
687
+ Export::Python.export_all(registry, style: @options[:python_style])
688
+ when "dataclass"
689
+ Export::Python.export_all(registry, style: :dataclass)
690
+ when "sql", "sql-ddl", "postgresql", "mysql", "sqlite"
691
+ Export::Sql.export_all(registry, dialect: @options[:sql_dialect])
692
+ when "graphviz", "dot"
693
+ Export::Graphviz.new(nil, registry: registry).export
694
+ when "protobuf", "proto"
695
+ Export::Protobuf.export_all(registry, package: @options[:package] || "generated")
696
+ when "openapi", "swagger"
697
+ Export::OpenAPI.new(registry,
698
+ title: @options[:title] || "Synthra API",
699
+ format: @options[:output]&.end_with?(".json") ? :json : :yaml
700
+ ).export
701
+ else
702
+ $stderr.puts "Bulk export not supported for format: #{format}"
703
+ $stderr.puts "Supported: typescript, javascript, python, sql, graphviz, protobuf, openapi"
704
+ return EXIT_PARSE_ERROR
705
+ end
706
+ else
707
+ unless schema_name
708
+ $stderr.puts "Usage: synthra export <SchemaName> [options]"
709
+ $stderr.puts " synthra export --all [options]"
710
+ $stderr.puts ""
711
+ $stderr.puts "Schema formats: json-schema, typescript, javascript, sql, graphviz"
712
+ $stderr.puts "Data formats: json, csv, sql-insert, yaml, xml"
713
+ return EXIT_PARSE_ERROR
714
+ end
715
+
716
+ schema = registry.schema(schema_name)
717
+ export_opts = build_export_options(registry)
718
+
719
+ output = case format
720
+ # Schema export formats
721
+ when "json-schema"
722
+ Export::JsonSchema.new(schema, **export_opts).export
723
+ when "typescript", "ts"
724
+ Export::Typescript.new(schema, **export_opts).export
725
+ when "javascript", "js"
726
+ Export::Javascript.new(schema, **export_opts).export
727
+ when "python", "py", "pydantic"
728
+ Export::Python.new(schema, **export_opts.merge(style: @options[:python_style])).export
729
+ when "dataclass"
730
+ Export::Python.new(schema, **export_opts.merge(style: :dataclass)).export
731
+ when "sql", "sql-ddl", "postgresql", "mysql", "sqlite"
732
+ dialect = %w[postgresql mysql sqlite].include?(format) ? format.to_sym : @options[:sql_dialect]
733
+ Export::Sql.new(schema, registry: registry, dialect: dialect).export
734
+ when "graphviz", "dot"
735
+ Export::Graphviz.new(schema, **export_opts).export
736
+ when "protobuf", "proto"
737
+ Export::Protobuf.new(schema, **export_opts.merge(
738
+ package: @options[:package] || "generated"
739
+ )).export
740
+ when "openapi", "swagger"
741
+ mini_registry = Registry.new
742
+ mini_registry.register_schema(schema.name, schema)
743
+ Export::OpenAPI.new(mini_registry,
744
+ title: @options[:title] || "#{schema.name} API",
745
+ format: @options[:output]&.end_with?(".json") ? :json : :yaml
746
+ ).export
747
+
748
+ # Data export formats
749
+ when "json", "json-data"
750
+ Export::JsonData.new(schema, **export_opts.merge(
751
+ pretty: @options[:pretty],
752
+ envelope: @options[:envelope]
753
+ )).export
754
+ when "csv"
755
+ Export::Csv.new(schema, **export_opts).export
756
+ when "sql-insert", "insert"
757
+ Export::SqlInsert.new(schema, **export_opts.merge(
758
+ dialect: @options[:sql_dialect],
759
+ batch_insert: @options[:batch_insert]
760
+ )).export
761
+ when "yaml", "yml"
762
+ Export::YamlData.new(schema, **export_opts).export
763
+ when "xml"
764
+ Export::XmlData.new(schema, **export_opts.merge(
765
+ root: @options[:root],
766
+ item: @options[:item]
767
+ )).export
768
+ else
769
+ $stderr.puts "Unknown export format: #{format}"
770
+ $stderr.puts ""
771
+ $stderr.puts "Schema formats: json-schema, typescript, javascript, python, sql, graphviz, protobuf, openapi"
772
+ $stderr.puts "Data formats: json, csv, sql-insert, yaml, xml"
773
+ return EXIT_PARSE_ERROR
774
+ end
775
+ end
776
+
777
+ # Determine output path
778
+ output_path = if @options[:output]
779
+ @options[:output]
780
+ elsif @options[:auto_file]
781
+ output_dir = @options[:output_dir] || "."
782
+ filename = Export.auto_filename(
783
+ @options[:all] ? "all_schemas" : schema_name,
784
+ format
785
+ )
786
+ File.join(output_dir, filename)
787
+ else
788
+ nil
789
+ end
790
+
791
+ if output_path
792
+ FileUtils.mkdir_p(File.dirname(output_path)) if File.dirname(output_path) != "."
793
+ File.write(output_path, output)
794
+ puts "✓ Exported to #{output_path}"
795
+ else
796
+ puts output
797
+ end
798
+
799
+ EXIT_SUCCESS
800
+ rescue KeyError
801
+ $stderr.puts "Unknown schema: #{schema_name}"
802
+ $stderr.puts "Available schemas: #{registry.names.join(", ")}"
803
+ EXIT_PARSE_ERROR
804
+ end
805
+
806
+ def build_export_options(registry)
807
+ {
808
+ registry: registry,
809
+ count: @options[:count],
810
+ seed: @options[:seed],
811
+ mode: @options[:mode] || :random
812
+ }.compact
813
+ end
814
+
815
+ # Export schema as JSON Schema
816
+ #
817
+ # @param schema [Schema] schema to export
818
+ # @param registry [Registry] registry for resolving references
819
+ # @return [String] JSON Schema as JSON string
820
+ #
821
+ # :nocov:
822
+ def export_json_schema(schema, registry)
823
+ json_schema = {
824
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
825
+ "$id" => "#{schema.name.downcase}.json",
826
+ "title" => schema.name,
827
+ "type" => "object",
828
+ "properties" => {},
829
+ "required" => []
830
+ }
831
+
832
+ schema.fields.each do |field|
833
+ prop = field_to_json_schema(field, registry)
834
+ json_schema["properties"][field.name] = prop
835
+ json_schema["required"] << field.name unless field.optional?
836
+ end
837
+
838
+ JSON.pretty_generate(json_schema)
839
+ end
840
+
841
+ # Convert a field to JSON Schema property
842
+ #
843
+ # @param field [Field] field to convert
844
+ # @param registry [Registry] registry for resolving references
845
+ # @return [Hash] JSON Schema property definition
846
+ #
847
+
848
+ def field_to_json_schema(field, registry)
849
+ type_name = field.type_name
850
+ prop = {}
851
+
852
+ case type_name
853
+ when "uuid", "ulid"
854
+ prop = { "type" => "string", "format" => "uuid" }
855
+ when "email"
856
+ prop = { "type" => "string", "format" => "email" }
857
+ when "url"
858
+ prop = { "type" => "string", "format" => "uri" }
859
+ when "date", "past_date", "future_date"
860
+ prop = { "type" => "string", "format" => "date" }
861
+ when "timestamp", "now"
862
+ prop = { "type" => "string", "format" => "date-time" }
863
+ when "number", "integer", "id_sequence"
864
+ prop = { "type" => "integer" }
865
+ if field.type_args[:range]
866
+ range = field.type_args[:range]
867
+ prop["minimum"] = range.min if range.respond_to?(:min)
868
+ prop["maximum"] = range.max if range.respond_to?(:max)
869
+ end
870
+ when "float", "money"
871
+ prop = { "type" => "number" }
872
+ when "boolean"
873
+ prop = { "type" => "boolean" }
874
+ when "text", "name", "full_name", "first_name", "last_name", "phone", "city", "country", "country_code", "postal_code"
875
+ prop = { "type" => "string" }
876
+ when "enum"
877
+ values = field.type_args[:values]&.map { |v| v.respond_to?(:value) ? v.value : v } || []
878
+ prop = { "type" => "string", "enum" => values }
879
+ when "const"
880
+ value = field.type_args[:value]
881
+ prop = { "const" => value }
882
+ when "array"
883
+ element_type = field.type_args[:element]
884
+ size = field.type_args[:size]
885
+ prop = { "type" => "array" }
886
+ if element_type && registry.schema?(element_type.to_s)
887
+ prop["items"] = { "$ref" => "#/$defs/#{element_type}" }
888
+ else
889
+ prop["items"] = { "type" => "string" }
890
+ end
891
+ if size.is_a?(Range)
892
+ prop["minItems"] = size.min
893
+ prop["maxItems"] = size.max
894
+ elsif size.is_a?(Hash)
895
+ prop["minItems"] = size[:min] if size[:min]
896
+ prop["maxItems"] = size[:max] if size[:max]
897
+ end
898
+ else
899
+ # Assume it's a schema reference
900
+ if registry.schema?(type_name)
901
+ prop = { "$ref" => "#/$defs/#{type_name}" }
902
+ else
903
+ prop = { "type" => "string" }
904
+ end
905
+ end
906
+
907
+ prop["nullable"] = true if field.nullable?
908
+ prop
909
+ end
910
+ # :nocov:
911
+
912
+ # Export schema as TypeScript interface
913
+ #
914
+ # @param schema [Schema] schema to export
915
+ # @param registry [Registry] registry for resolving references
916
+ # @return [String] TypeScript interface definition
917
+ #
918
+ # :nocov:
919
+ def export_typescript(schema, registry)
920
+ lines = ["interface #{schema.name} {"]
921
+
922
+ schema.fields.each do |field|
923
+ ts_type = field_to_typescript(field, registry)
924
+ optional = field.optional? ? "?" : ""
925
+ nullable = field.nullable? ? " | null" : ""
926
+ lines << " #{field.name}#{optional}: #{ts_type}#{nullable};"
927
+ end
928
+
929
+ lines << "}"
930
+ lines.join("\n")
931
+ end
932
+
933
+ # Convert a field to TypeScript type
934
+ #
935
+ # @param field [Field] field to convert
936
+ # @param registry [Registry] registry for resolving references
937
+ # @return [String] TypeScript type
938
+ #
939
+
940
+ def field_to_typescript(field, registry)
941
+ type_name = field.type_name
942
+
943
+ case type_name
944
+ when "uuid", "ulid", "email", "url", "date", "past_date", "future_date", "timestamp", "now", "text", "name", "full_name", "first_name", "last_name", "phone", "city", "country", "country_code", "postal_code"
945
+ "string"
946
+ when "number", "integer", "id_sequence", "float", "money"
947
+ "number"
948
+ when "boolean"
949
+ "boolean"
950
+ when "enum"
951
+ values = field.type_args[:values]&.map { |v| "\"#{v.respond_to?(:value) ? v.value : v}\"" } || []
952
+ values.join(" | ")
953
+ when "const"
954
+ value = field.type_args[:value]
955
+ value.is_a?(String) ? "\"#{value}\"" : value.to_s
956
+ when "array"
957
+ element_type = field.type_args[:element]
958
+ if element_type && registry.schema?(element_type.to_s)
959
+ "#{element_type}[]"
960
+ else
961
+ "string[]"
962
+ end
963
+ else
964
+ registry.schema?(type_name) ? type_name : "any"
965
+ end
966
+ end
967
+ # :nocov:
968
+
969
+ # Parse options for export command
970
+ #
971
+ # @return [void]
972
+ #
973
+
974
+ def parse_export_options
975
+ @options[:export_format] = "json-schema"
976
+ @options[:output] = nil
977
+ @options[:output_dir] = nil
978
+ @options[:auto_file] = false
979
+ @options[:sql_dialect] = :postgresql
980
+ @options[:count] = 10
981
+ @options[:all] = false
982
+ @options[:pretty] = true
983
+ @options[:batch_insert] = false
984
+ @options[:envelope] = nil
985
+ @options[:root] = nil
986
+ @options[:item] = nil
987
+ @options[:python_style] = :pydantic
988
+
989
+ OptionParser.new do |opts|
990
+ opts.banner = "Usage: synthra export <SchemaName> [options]"
991
+
992
+ opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
993
+ @options[:dir] = v
994
+ end
995
+
996
+ opts.on("-f", "--format FORMAT", "Export format (see list below)") do |v|
997
+ @options[:export_format] = v
998
+ end
999
+
1000
+ opts.on("-o", "--output FILE", "Output file (default: stdout)") do |v|
1001
+ @options[:output] = v
1002
+ end
1003
+
1004
+ opts.on("--out-dir DIR", "Output directory for auto-generated files") do |v|
1005
+ @options[:output_dir] = v
1006
+ @options[:auto_file] = true
1007
+ end
1008
+
1009
+ opts.on("--auto-file", "Auto-generate filename based on schema and format") do
1010
+ @options[:auto_file] = true
1011
+ end
1012
+
1013
+ opts.on("--dialect DIALECT", "SQL dialect: postgresql, mysql, sqlite") do |v|
1014
+ @options[:sql_dialect] = v.to_sym
1015
+ end
1016
+
1017
+ opts.on("-c", "--count COUNT", Integer, "Number of records for data exports (default: 10)") do |v|
1018
+ @options[:count] = v
1019
+ end
1020
+
1021
+ opts.on("-s", "--seed SEED", Integer, "Seed for deterministic generation") do |v|
1022
+ @options[:seed] = v
1023
+ end
1024
+
1025
+ opts.on("-a", "--all", "Export all schemas (typescript/javascript/python/sql)") do
1026
+ @options[:all] = true
1027
+ end
1028
+
1029
+ opts.on("--batch", "Use batch INSERT for sql-insert format") do
1030
+ @options[:batch_insert] = true
1031
+ end
1032
+
1033
+ opts.on("--compact", "Compact output (no pretty-print)") do
1034
+ @options[:pretty] = false
1035
+ end
1036
+
1037
+ opts.on("--envelope KEY", "Wrap JSON output in envelope with this key") do |v|
1038
+ @options[:envelope] = v
1039
+ end
1040
+
1041
+ opts.on("--root NAME", "Root element name for XML") do |v|
1042
+ @options[:root] = v
1043
+ end
1044
+
1045
+ opts.on("--item NAME", "Item element name for XML") do |v|
1046
+ @options[:item] = v
1047
+ end
1048
+
1049
+ opts.on("--style STYLE", "Python style: pydantic, dataclass, typed_dict") do |v|
1050
+ @options[:python_style] = v.to_sym
1051
+ end
1052
+ end.parse!(@args)
1053
+ end
1054
+
1055
+ # Execute the graph command
1056
+ #
1057
+ # Generates GraphViz DOT output for schema visualization.
1058
+ #
1059
+ # @return [Integer] exit code
1060
+ #
1061
+
1062
+ def graph_command
1063
+ parse_graph_options
1064
+ path = @args.shift || @options[:dir]
1065
+
1066
+ registry = Registry.new
1067
+ if File.directory?(path)
1068
+ load_schemas_from_dir_no_validate(registry, path)
1069
+ else
1070
+ registry.load_file(path, validate: false)
1071
+ end
1072
+
1073
+ # Use the Export::Graphviz module
1074
+ exporter = Export::Graphviz.new(nil, registry: registry,
1075
+ direction: @options[:direction] || "LR",
1076
+ group: @options[:group])
1077
+ dot_output = exporter.export
1078
+
1079
+ if @options[:output]
1080
+ File.write(@options[:output], dot_output)
1081
+ puts "✓ Generated graph to #{@options[:output]}"
1082
+
1083
+ # Try to render PNG/SVG if graphviz is installed
1084
+ # :nocov:
1085
+ if @options[:render] && system("which dot > /dev/null 2>&1")
1086
+ render_format = @options[:render_format] || "png"
1087
+ output_file = @options[:output].sub(/\.dot$/, ".#{render_format}")
1088
+ system("dot", "-T#{render_format}", @options[:output], "-o", output_file)
1089
+ puts "✓ Rendered #{render_format.upcase} to #{output_file}"
1090
+ end
1091
+ # :nocov:
1092
+ else
1093
+ puts dot_output
1094
+ end
1095
+
1096
+ EXIT_SUCCESS
1097
+ end
1098
+
1099
+ # Generate GraphViz DOT representation of schemas
1100
+ #
1101
+ # @param registry [Registry] registry containing schemas
1102
+ # @return [String] DOT format string
1103
+ #
1104
+ # :nocov:
1105
+ def generate_graphviz(registry)
1106
+ lines = [
1107
+ "digraph Synthra {",
1108
+ " rankdir=LR;",
1109
+ " node [shape=record, fontname=\"Helvetica\"];",
1110
+ " edge [fontname=\"Helvetica\", fontsize=10];",
1111
+ ""
1112
+ ]
1113
+
1114
+ registry.schemas.each do |name, schema|
1115
+ # Create node for schema
1116
+ fields = schema.fields.map do |f|
1117
+ optional = f.optional? ? "?" : ""
1118
+ nullable = f.nullable? ? "?" : ""
1119
+ "#{f.name}#{optional}: #{f.type_name}#{nullable}"
1120
+ end
1121
+
1122
+ label = "{#{name}|#{fields.join("\\l")}\\l}"
1123
+ color = schema.deprecated? ? "gray" : "black"
1124
+ lines << " #{name} [label=\"#{label}\", color=#{color}];"
1125
+ end
1126
+
1127
+ lines << ""
1128
+
1129
+ # Create edges for references
1130
+ registry.schemas.each do |name, schema|
1131
+ schema.fields.each do |field|
1132
+ type_name = field.type_name
1133
+
1134
+ # Direct schema reference
1135
+ if registry.schema?(type_name)
1136
+ lines << " #{name} -> #{type_name} [label=\"#{field.name}\"];"
1137
+ end
1138
+
1139
+ # Array element type
1140
+ if type_name == "array"
1141
+ element = field.type_args[:element]
1142
+ if element && registry.schema?(element.to_s)
1143
+ lines << " #{name} -> #{element} [label=\"#{field.name}[]\", style=dashed];"
1144
+ end
1145
+ end
1146
+
1147
+ # map_by_field entries
1148
+ if type_name == "map_by_field"
1149
+ entries = field.type_args[:entries] || {}
1150
+ entries.each do |entry_name, entry_info|
1151
+ schema_name = entry_info.is_a?(Hash) ? entry_info[:schema] : entry_info.to_s
1152
+ if schema_name && registry.schema?(schema_name)
1153
+ lines << " #{name} -> #{schema_name} [label=\"#{field.name}.#{entry_name}\", style=dotted];"
1154
+ end
1155
+ end
1156
+ end
1157
+ end
1158
+ end
1159
+
1160
+ lines << "}"
1161
+ lines.join("\n")
1162
+ end
1163
+ # :nocov:
1164
+
1165
+ # Parse options for graph command
1166
+ #
1167
+ # @return [void]
1168
+ #
1169
+
1170
+ def parse_graph_options
1171
+ @options[:output] = nil
1172
+ @options[:render] = false
1173
+ @options[:render_format] = "png"
1174
+ @options[:direction] = "LR"
1175
+ @options[:group] = false
1176
+
1177
+ OptionParser.new do |opts|
1178
+ opts.banner = "Usage: synthra graph <path|directory> [options]"
1179
+
1180
+ opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
1181
+ @options[:dir] = v
1182
+ end
1183
+
1184
+ opts.on("-o", "--output FILE", "Output DOT file") do |v|
1185
+ @options[:output] = v
1186
+ end
1187
+
1188
+ opts.on("-r", "--render", "Also render image (requires graphviz)") do
1189
+ @options[:render] = true
1190
+ end
1191
+
1192
+ opts.on("--format FORMAT", "Render format: png, svg, pdf (default: png)") do |v|
1193
+ @options[:render_format] = v
1194
+ end
1195
+
1196
+ opts.on("--direction DIR", "Graph direction: LR, TB, BT, RL (default: LR)") do |v|
1197
+ @options[:direction] = v.upcase
1198
+ end
1199
+
1200
+ opts.on("-g", "--group", "Group schemas by category") do
1201
+ @options[:group] = true
1202
+ end
1203
+ end.parse!(@args)
1204
+ end
1205
+
1206
+ # Execute the watch command
1207
+ #
1208
+ # Watches for schema changes and validates on change.
1209
+ #
1210
+ # @return [Integer] exit code
1211
+ #
1212
+
1213
+ # :nocov:
1214
+ def watch_command
1215
+ parse_watch_options
1216
+ path = @args.shift || @options[:dir]
1217
+
1218
+ unless File.directory?(path)
1219
+ $stderr.puts "Watch requires a directory path"
1220
+ return EXIT_PARSE_ERROR
1221
+ end
1222
+
1223
+ puts "👁 Watching #{path} for changes..."
1224
+ puts " Press Ctrl+C to stop"
1225
+ puts
1226
+
1227
+ # Track file modification times
1228
+ mtimes = {}
1229
+ Dir.glob(File.join(path, "*.dsl")).each do |f|
1230
+ mtimes[f] = File.mtime(f)
1231
+ end
1232
+
1233
+ loop do
1234
+ sleep(@options[:interval] || 1)
1235
+
1236
+ # Check for new or modified files
1237
+ current_files = Dir.glob(File.join(path, "*.dsl"))
1238
+
1239
+ current_files.each do |f|
1240
+ current_mtime = File.mtime(f)
1241
+
1242
+ if mtimes[f].nil?
1243
+ puts "➕ New file: #{File.basename(f)}"
1244
+ validate_and_report(f)
1245
+ mtimes[f] = current_mtime
1246
+ elsif mtimes[f] < current_mtime
1247
+ puts "📝 Changed: #{File.basename(f)}"
1248
+ validate_and_report(f)
1249
+ mtimes[f] = current_mtime
1250
+ end
1251
+ end
1252
+
1253
+ # Check for deleted files
1254
+ (mtimes.keys - current_files).each do |f|
1255
+ puts "➖ Deleted: #{File.basename(f)}"
1256
+ mtimes.delete(f)
1257
+ end
1258
+ end
1259
+ rescue Interrupt
1260
+ puts "\n👋 Stopped watching"
1261
+ EXIT_SUCCESS
1262
+ end
1263
+ # :nocov:
1264
+
1265
+ # Validate a file and report results
1266
+ #
1267
+ # @param path [String] file path
1268
+ # @return [void]
1269
+ #
1270
+ # :nocov:
1271
+ def validate_and_report(path)
1272
+ registry = Registry.new
1273
+ begin
1274
+ registry.load_file(path)
1275
+ errors = registry.validate_paths
1276
+
1277
+ if errors.empty?
1278
+ puts " ✓ Valid"
1279
+ else
1280
+ puts " ✗ #{errors.length} error(s)"
1281
+ errors.each { |e| puts " - #{e.message}" }
1282
+ end
1283
+ rescue Synthra::Errors::ParseError => e
1284
+ puts " ✗ Parse error: #{e.message}"
1285
+ end
1286
+ puts
1287
+ end
1288
+ # :nocov:
1289
+
1290
+ # Parse options for watch command
1291
+ #
1292
+ # @return [void]
1293
+ #
1294
+ # :nocov:
1295
+ def parse_watch_options
1296
+ @options[:interval] = 1
1297
+
1298
+ OptionParser.new do |opts|
1299
+ opts.banner = "Usage: synthra watch <directory>"
1300
+
1301
+ opts.on("-i", "--interval SECONDS", Integer, "Check interval in seconds") do |v|
1302
+ @options[:interval] = v
1303
+ end
1304
+ end.parse!(@args)
1305
+ end
1306
+ # :nocov:
1307
+
1308
+ # Load schemas from a path (file or directory)
1309
+ #
1310
+ # @param registry [Registry] registry to load into
1311
+ # @param path [String] file or directory path
1312
+ # @return [void]
1313
+ #
1314
+
1315
+ def load_path(registry, path)
1316
+ if File.directory?(path)
1317
+ load_schemas_from_dir_no_validate(registry, path)
1318
+ else
1319
+ registry.load_file(path, validate: false)
1320
+ end
1321
+ end
1322
+
1323
+ # Compare two schemas and return differences
1324
+ #
1325
+ # @param s1 [Schema] first schema
1326
+ # @param s2 [Schema] second schema
1327
+ # @return [Array<String>] list of differences
1328
+ #
1329
+
1330
+ def compare_schemas(s1, s2)
1331
+ diff = []
1332
+
1333
+ # Compare versions
1334
+ # :nocov:
1335
+ if s1.version != s2.version
1336
+ diff << "version: #{s1.version || 'nil'} → #{s2.version || 'nil'}"
1337
+ end
1338
+ # :nocov:
1339
+
1340
+ # Compare deprecation
1341
+ # :nocov:
1342
+ if s1.deprecated? != s2.deprecated?
1343
+ diff << "deprecated: #{s1.deprecated?} → #{s2.deprecated?}"
1344
+ end
1345
+ # :nocov:
1346
+
1347
+ # Compare fields
1348
+ f1_names = s1.fields.map(&:name)
1349
+ f2_names = s2.fields.map(&:name)
1350
+
1351
+ (f2_names - f1_names).each { |f| diff << "+ field: #{f}" }
1352
+ (f1_names - f2_names).each { |f| diff << "- field: #{f}" }
1353
+
1354
+ # Compare field types
1355
+ (f1_names & f2_names).each do |name|
1356
+ field1 = s1.field(name)
1357
+ field2 = s2.field(name)
1358
+ if field1.type_name != field2.type_name
1359
+ diff << "~ #{name}: #{field1.type_name} → #{field2.type_name}"
1360
+ end
1361
+ end
1362
+
1363
+ diff
1364
+ end
1365
+
1366
+ # Parse options for info command
1367
+ #
1368
+ # @return [void]
1369
+ #
1370
+
1371
+ def parse_info_options
1372
+ @options[:verbose] = false
1373
+
1374
+ OptionParser.new do |opts|
1375
+ opts.banner = "Usage: synthra info <path|directory>"
1376
+
1377
+ opts.on("-v", "--verbose", "Show field details") do
1378
+ @options[:verbose] = true
1379
+ end
1380
+ end.parse!(@args)
1381
+ end
1382
+
1383
+ # Parse options for diff command
1384
+ #
1385
+ # @return [void]
1386
+ #
1387
+
1388
+ def parse_diff_options
1389
+ OptionParser.new do |opts|
1390
+ opts.banner = "Usage: synthra diff <path1> <path2>"
1391
+ end.parse!(@args)
1392
+ end
1393
+
1394
+ # Parse options for repl command
1395
+ #
1396
+ # @return [void]
1397
+ #
1398
+ # :nocov:
1399
+ def parse_repl_options
1400
+ @options[:verbose] = false
1401
+ OptionParser.new do |opts|
1402
+ opts.banner = "Usage: synthra repl [options]"
1403
+ opts.on("-v", "--verbose", "Show verbose output including stack traces") do
1404
+ @options[:verbose] = true
1405
+ end
1406
+ end.parse!(@args)
1407
+ end
1408
+ # :nocov:
1409
+
1410
+ # Load schemas from directory without validation
1411
+ #
1412
+ # @param registry [Registry] registry to load schemas into
1413
+ # @param dir [String] directory path
1414
+ # @return [void]
1415
+ #
1416
+
1417
+ def load_schemas_from_dir_no_validate(registry, dir)
1418
+ dir = File.expand_path(dir)
1419
+ raise ArgumentError, "Invalid directory path: #{dir}" if dir.include?("..")
1420
+ raise Errno::ENOENT, "Directory not found: #{dir}" unless Dir.exist?(dir)
1421
+
1422
+ pattern = File.join(dir, "*.dsl")
1423
+ files = Dir.glob(pattern)
1424
+
1425
+ if files.empty?
1426
+ $stderr.puts "No .dsl files found in #{dir}"
1427
+ return
1428
+ end
1429
+
1430
+ files.each do |f|
1431
+ file_path = File.expand_path(f)
1432
+ raise ArgumentError, "Invalid file path: #{f}" unless file_path.start_with?(dir)
1433
+ registry.load_file(f, validate: false)
1434
+ end
1435
+ end
1436
+
1437
+ # Execute the generate command
1438
+ #
1439
+ # Loads schemas from a directory, finds the specified schema,
1440
+ # and generates fake data according to options.
1441
+ #
1442
+ # @return [Integer] exit code
1443
+ #
1444
+
1445
+ def generate_command
1446
+ parse_generate_options
1447
+ schema_name = @args.shift
1448
+
1449
+ unless schema_name
1450
+ $stderr.puts "Usage: synthra generate <SchemaName> [options]"
1451
+ return EXIT_PARSE_ERROR
1452
+ end
1453
+
1454
+ registry = Registry.new
1455
+ load_schemas_from_dir(registry, @options[:dir])
1456
+
1457
+ schema = registry.schema(schema_name)
1458
+ generate_output(schema)
1459
+ EXIT_SUCCESS
1460
+ rescue KeyError
1461
+ $stderr.puts "Unknown schema: #{schema_name}"
1462
+ $stderr.puts "Available schemas: #{registry.schemas.keys.join(", ")}"
1463
+ EXIT_PARSE_ERROR
1464
+ end
1465
+
1466
+ # Load all .dsl files from a directory
1467
+ #
1468
+ # @param registry [Registry] registry to load schemas into
1469
+ # @param dir [String] directory path
1470
+ # @return [void]
1471
+ #
1472
+
1473
+ def load_schemas_from_dir(registry, dir)
1474
+
1475
+ # Validate and sanitize directory path to prevent path traversal
1476
+ dir = File.expand_path(dir)
1477
+
1478
+ # Reject paths with .. components (shouldn't happen after expand_path, but double-check)
1479
+ # :nocov:
1480
+ if dir.include?("..")
1481
+ raise ArgumentError, "Invalid directory path: #{dir}"
1482
+ end
1483
+ # :nocov:
1484
+
1485
+ # Verify directory exists
1486
+ unless Dir.exist?(dir)
1487
+ raise Errno::ENOENT, "Directory not found: #{dir}"
1488
+ end
1489
+
1490
+ pattern = File.join(dir, "*.dsl")
1491
+ files = Dir.glob(pattern)
1492
+
1493
+ if files.empty?
1494
+ $stderr.puts "No .dsl files found in #{dir}"
1495
+ return
1496
+ end
1497
+
1498
+ # Validate all files are within the directory (prevent symlink attacks)
1499
+ files.each do |f|
1500
+ file_path = File.expand_path(f)
1501
+ # :nocov:
1502
+ unless file_path.start_with?(dir)
1503
+ raise ArgumentError, "Invalid file path: #{f}"
1504
+ end
1505
+ # :nocov:
1506
+ registry.load_file(f)
1507
+ end
1508
+ end
1509
+
1510
+ # Generate and output fake data
1511
+ #
1512
+ # Handles both JSON and NDJSON output formats.
1513
+ #
1514
+ # @param schema [Schema] schema to generate from
1515
+ # @return [void]
1516
+ #
1517
+
1518
+ def generate_output(schema)
1519
+ options = { seed: @options[:seed], mode: @options[:mode] }.compact
1520
+
1521
+ if @options[:format] == :ndjson
1522
+
1523
+ # NDJSON: One JSON object per line, streamed
1524
+ schema.generate_stream(count: @options[:count], **options).each do |record|
1525
+ puts JSON.generate(sanitize_for_json(record))
1526
+ end
1527
+ else
1528
+
1529
+ # JSON: Single array or object
1530
+ records = schema.generate_many(@options[:count], **options)
1531
+ output = @options[:count] == 1 ? records.first : records
1532
+
1533
+ if @options[:pretty]
1534
+ puts JSON.pretty_generate(sanitize_for_json(output))
1535
+ else
1536
+ puts JSON.generate(sanitize_for_json(output))
1537
+ end
1538
+ end
1539
+ end
1540
+
1541
+ # Sanitize values for JSON serialization
1542
+ # Converts Infinity/NaN to null for valid JSON output
1543
+ #
1544
+ # @param value [Object] value to sanitize
1545
+ # @return [Object] sanitized value
1546
+ #
1547
+ def sanitize_for_json(value)
1548
+ case value
1549
+ when Float
1550
+ if value.infinite? || value.nan?
1551
+ nil
1552
+ else
1553
+ value
1554
+ end
1555
+ when Hash
1556
+ value.transform_values { |v| sanitize_for_json(v) }
1557
+ when Array
1558
+ value.map { |v| sanitize_for_json(v) }
1559
+ else
1560
+ value
1561
+ end
1562
+ end
1563
+
1564
+ # Parse options for validate command
1565
+ #
1566
+ # @return [void]
1567
+ #
1568
+
1569
+ def parse_validate_options
1570
+ OptionParser.new do |opts|
1571
+ opts.banner = "Usage: synthra validate <path>"
1572
+ end.parse!(@args)
1573
+ end
1574
+
1575
+ # Parse options for lint command
1576
+ #
1577
+ # @return [void]
1578
+ #
1579
+
1580
+ def parse_lint_options
1581
+ @options[:verbose] = false
1582
+ @options[:strict] = false
1583
+
1584
+ OptionParser.new do |opts|
1585
+ opts.banner = "Usage: synthra lint <path|directory>"
1586
+
1587
+ opts.on("-v", "--verbose", "Show detailed output including schema names") do
1588
+ @options[:verbose] = true
1589
+ end
1590
+
1591
+ opts.on("--strict", "Treat warnings as errors (exit non-zero)") do
1592
+ @options[:strict] = true
1593
+ end
1594
+ end.parse!(@args)
1595
+ end
1596
+
1597
+ # Parse options for preview command
1598
+ #
1599
+ # @return [void]
1600
+ #
1601
+
1602
+ def parse_preview_options
1603
+ OptionParser.new do |opts|
1604
+ opts.banner = "Usage: synthra preview <SchemaName> [options]"
1605
+
1606
+ opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
1607
+ @options[:dir] = v
1608
+ end
1609
+
1610
+ opts.on("-s", "--seed SEED", Integer, "Seed for deterministic generation") do |v|
1611
+ @options[:seed] = v
1612
+ end
1613
+
1614
+ opts.on("-m", "--mode MODE", %i[random edge invalid mixed], "Generation mode") do |v|
1615
+ @options[:mode] = v
1616
+ end
1617
+ end.parse!(@args)
1618
+ end
1619
+
1620
+ # Parse options for generate command
1621
+ #
1622
+ # @return [void]
1623
+ #
1624
+
1625
+ def parse_generate_options
1626
+ OptionParser.new do |opts|
1627
+ opts.banner = "Usage: synthra generate <SchemaName> [options]"
1628
+
1629
+ opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
1630
+ @options[:dir] = v
1631
+ end
1632
+
1633
+ opts.on("-c", "--count COUNT", Integer, "Number of records to generate") do |v|
1634
+ @options[:count] = v
1635
+ end
1636
+
1637
+ opts.on("-s", "--seed SEED", Integer, "Seed for deterministic generation") do |v|
1638
+ @options[:seed] = v
1639
+ end
1640
+
1641
+ opts.on("-m", "--mode MODE", %i[random edge invalid mixed], "Generation mode") do |v|
1642
+ @options[:mode] = v
1643
+ end
1644
+
1645
+ opts.on("--json", "Output as JSON array (default)") do
1646
+ @options[:format] = :json
1647
+ end
1648
+
1649
+ opts.on("--ndjson", "Output as newline-delimited JSON") do
1650
+ @options[:format] = :ndjson
1651
+ end
1652
+
1653
+ opts.on("--pretty", "Pretty-print JSON output") do
1654
+ @options[:pretty] = true
1655
+ end
1656
+ end.parse!(@args)
1657
+ end
1658
+
1659
+ # Display help information
1660
+ #
1661
+ # @return [Integer] EXIT_SUCCESS
1662
+ #
1663
+
1664
+ def show_help
1665
+ puts <<~HELP
1666
+ Synthra - Generate fake data from a human-friendly DSL
1667
+
1668
+ Usage:
1669
+ synthra <command> [options]
1670
+
1671
+ Commands:
1672
+ validate <path> Validate a DSL schema file (syntax check)
1673
+ lint <path|dir> Lint schemas for semantic errors (copy paths, etc.)
1674
+ preview <SchemaName> Preview a single record (quick testing)
1675
+ generate <SchemaName> Generate fake data for a schema
1676
+ info <path|dir> Show schema information (version, fields, etc.)
1677
+ diff <path1> <path2> Compare two schema files/directories
1678
+ export <SchemaName> Export schema as JSON Schema or TypeScript
1679
+ graph <path|dir> Generate GraphViz visualization of schemas
1680
+ watch <directory> Watch directory for changes and validate
1681
+ repl Start interactive REPL for testing
1682
+ lsp Start Language Server Protocol server
1683
+ import <file> Import schemas from OpenAPI/Swagger
1684
+ docs <directory> Generate HTML documentation
1685
+ live <directory> Start live preview server with web UI
1686
+ seed Seed database with generated data
1687
+ server Start production API server
1688
+ contracts Manage data contracts registry
1689
+ perf High-performance generation mode
1690
+
1691
+ Lint Options:
1692
+ -v, --verbose Show detailed output including schema names
1693
+ --strict Treat warnings as errors (exit non-zero)
1694
+
1695
+ Preview Options:
1696
+ -d, --dir DIR Directory containing .dsl files (default: .)
1697
+ -s, --seed SEED Seed for deterministic generation
1698
+ -m, --mode MODE Generation mode: random, edge, invalid, mixed
1699
+
1700
+ Generate Options:
1701
+ -d, --dir DIR Directory containing .dsl files (default: .)
1702
+ -c, --count COUNT Number of records to generate (default: 1)
1703
+ -s, --seed SEED Seed for deterministic generation
1704
+ -m, --mode MODE Generation mode: random, edge, invalid, mixed
1705
+ --json Output as JSON array (default)
1706
+ --ndjson Output as newline-delimited JSON
1707
+ --pretty Pretty-print JSON output
1708
+
1709
+ Export Options:
1710
+ -d, --dir DIR Directory containing .dsl files
1711
+ -f, --format FORMAT Export format (see formats below)
1712
+ -o, --output FILE Output file (default: stdout)
1713
+ --out-dir DIR Output directory (auto-generates filename)
1714
+ --auto-file Auto-generate filename based on schema and format
1715
+ -c, --count COUNT Number of records for data exports (default: 10)
1716
+ -s, --seed SEED Seed for deterministic generation
1717
+ -a, --all Export all schemas (typescript/javascript/python/sql)
1718
+ --dialect DIALECT SQL dialect: postgresql, mysql, sqlite
1719
+ --style STYLE Python style: pydantic, dataclass, typed_dict
1720
+ --batch Use batch INSERT for sql-insert format
1721
+ --compact No pretty-printing for JSON output
1722
+ --envelope KEY Wrap JSON in envelope with this key
1723
+ --root NAME Root element name for XML
1724
+ --item NAME Item element name for XML
1725
+
1726
+ Schema Export Formats (types/structure):
1727
+ json-schema JSON Schema (draft 2020-12)
1728
+ typescript, ts TypeScript interfaces
1729
+ javascript, js JavaScript with JSDoc types
1730
+ python, py, pydantic Python Pydantic models
1731
+ dataclass Python dataclasses
1732
+ sql, sql-ddl SQL CREATE TABLE (use --dialect)
1733
+ graphviz, dot GraphViz DOT diagram
1734
+ protobuf, proto Protocol Buffers / gRPC
1735
+ openapi, swagger OpenAPI 3.0 specification
1736
+
1737
+ Data Export Formats (generated records):
1738
+ json, json-data JSON array of records
1739
+ csv CSV with headers
1740
+ sql-insert, insert SQL INSERT statements (use --dialect)
1741
+ yaml, yml YAML format
1742
+ xml XML format (use --root, --item)
1743
+
1744
+ Graph Options:
1745
+ -d, --dir DIR Directory containing .dsl files
1746
+ -o, --output FILE Output DOT file
1747
+ -r, --render Also render image (requires graphviz installed)
1748
+ --format FORMAT Render format: png, svg, pdf (default: png)
1749
+ --direction DIR Graph direction: LR, TB, BT, RL (default: LR)
1750
+ -g, --group Group schemas by category
1751
+
1752
+ Watch Options:
1753
+ -i, --interval SECONDS Check interval in seconds (default: 1)
1754
+
1755
+ Info Options:
1756
+ -v, --verbose Show field details
1757
+
1758
+ Server Options (Production API):
1759
+ -d, --dir DIR Directory containing .dsl files
1760
+ -p, --port PORT Server port (default: 3000)
1761
+ -h, --host HOST Server host (default: 0.0.0.0)
1762
+ -e, --env ENV Environment: development, production
1763
+ --no-cors Disable CORS headers
1764
+ --rate-limit LIMIT Rate limit per minute (0 = unlimited)
1765
+ --api-key KEY Require API key authentication
1766
+
1767
+ Contracts Options:
1768
+ -d, --dir DIR Contracts directory (default: contracts)
1769
+ -s, --schema-dir DIR Schema directory
1770
+ -v, --version VERSION Version (semver)
1771
+ -m, --message MSG Changelog message
1772
+ --sunset DATE Sunset date for deprecation
1773
+
1774
+ Performance Mode Options:
1775
+ -d, --dir DIR Directory containing .dsl files
1776
+ -c, --count COUNT Number of records (default: 100000)
1777
+ -o, --output FILE Output file (for file generation)
1778
+ -f, --format FORMAT Output format: ndjson, json, csv
1779
+ -s, --seed SEED Random seed for deterministic generation
1780
+ -b, --benchmark Run benchmark instead of generating
1781
+
1782
+ Examples:
1783
+ synthra validate schemas/user.dsl
1784
+ synthra lint schemas/
1785
+ synthra lint schemas/ --strict
1786
+ synthra preview User --dir schemas
1787
+ synthra generate User --dir schemas --count 10
1788
+ synthra generate User --dir schemas --count 1000 --ndjson
1789
+
1790
+ # Schema exports (types/structure)
1791
+ synthra export User -d schemas -f json-schema
1792
+ synthra export User -d schemas -f typescript -o user.ts
1793
+ synthra export User -d schemas -f javascript -o user.js
1794
+ synthra export User -d schemas -f python --style pydantic
1795
+ synthra export User -d schemas -f dataclass -o models.py
1796
+ synthra export User -d schemas -f sql --dialect mysql
1797
+ synthra export --all -d schemas -f typescript -o types.ts
1798
+ synthra export --all -d schemas -f python -o models.py
1799
+
1800
+ # Auto-generate files (--auto-file or --out-dir)
1801
+ synthra export User -d schemas -f typescript --auto-file
1802
+ synthra export User -d schemas -f python --out-dir ./generated
1803
+ synthra export User -d schemas -f csv -c 100 --out-dir ./data
1804
+
1805
+ # Data exports (generated records)
1806
+ synthra export User -d schemas -f json -c 100 -o users.json
1807
+ synthra export User -d schemas -f csv -c 1000 -o users.csv
1808
+ synthra export User -d schemas -f sql-insert -c 100 --dialect postgresql
1809
+ synthra export User -d schemas -f sql-insert -c 1000 --batch
1810
+ synthra export User -d schemas -f yaml -c 50 -o users.yaml
1811
+ synthra export User -d schemas -f xml -c 20 --root users --item user
1812
+
1813
+ # GraphViz visualization
1814
+ synthra graph schemas/ --output schema.dot --render
1815
+ synthra graph schemas/ -o schema.dot --render --format svg
1816
+
1817
+ # Other commands
1818
+ synthra watch schemas/
1819
+ synthra info schemas/ --verbose
1820
+ synthra diff schemas/v1/ schemas/v2/
1821
+ synthra repl
1822
+ synthra lsp
1823
+
1824
+ # Production API Server
1825
+ synthra server schemas/ --port 3000
1826
+ synthra server schemas/ --port 8080 --env production
1827
+ synthra server schemas/ --api-key secret123 --rate-limit 100
1828
+
1829
+ # Data Contracts Registry
1830
+ synthra contracts publish User -v 1.0.0 -s schemas/
1831
+ synthra contracts deprecate User -v 1.0.0 --sunset 2026-06-01
1832
+ synthra contracts list
1833
+ synthra contracts history User
1834
+ synthra contracts diff User 1.0.0 2.0.0
1835
+
1836
+ # High-Performance Mode (millions of records)
1837
+ synthra perf User -d schemas/ -c 1000000 -o users.ndjson
1838
+ synthra perf User -d schemas/ -c 100000 -f csv -o users.csv
1839
+ synthra perf User -d schemas/ --benchmark
1840
+
1841
+ # Protocol Buffers / gRPC export
1842
+ synthra export User -d schemas/ -f protobuf -o user.proto
1843
+ synthra export --all -d schemas/ -f protobuf -o all.proto
1844
+
1845
+ # OpenAPI export
1846
+ synthra export --all -d schemas/ -f openapi -o api.yaml
1847
+
1848
+ Exit Codes:
1849
+ 0 Success
1850
+ 1 Parse/validation error
1851
+ 2 Runtime error
1852
+ HELP
1853
+ EXIT_SUCCESS
1854
+ end
1855
+
1856
+ # Execute the LSP server command
1857
+ #
1858
+ # Starts the Language Server Protocol server for IDE integration.
1859
+ # Provides features like go-to-definition, hover, completion, and diagnostics.
1860
+ #
1861
+ # @return [Integer] exit code (never returns normally - runs server loop)
1862
+ #
1863
+ # :nocov:
1864
+ def lsp_command
1865
+ require_relative "lsp/server"
1866
+ server = LSP::Server.new
1867
+ server.run
1868
+ EXIT_SUCCESS
1869
+ rescue Interrupt
1870
+ EXIT_SUCCESS
1871
+ end
1872
+ # :nocov:
1873
+
1874
+ # Execute the import command
1875
+ #
1876
+ # Imports schemas from external formats (OpenAPI, JSON Schema).
1877
+ #
1878
+ # @return [Integer] exit code
1879
+ #
1880
+ def import_command
1881
+ parse_import_options
1882
+ path = @args.shift
1883
+
1884
+ unless path
1885
+ $stderr.puts "Usage: synthra import <openapi.yaml|json-schema.json> [options]"
1886
+ return EXIT_PARSE_ERROR
1887
+ end
1888
+
1889
+ cmd = CLI::Commands::Import.new
1890
+ cmd.call(path, @options)
1891
+ end
1892
+
1893
+ # Execute the docs command
1894
+ #
1895
+ # Generates HTML documentation from schemas.
1896
+ #
1897
+ # @return [Integer] exit code
1898
+ #
1899
+ def docs_command
1900
+ parse_docs_options
1901
+ path = @args.shift || @options[:dir]
1902
+
1903
+ unless path && File.directory?(path)
1904
+ $stderr.puts "Usage: synthra docs <schema-directory> [options]"
1905
+ return EXIT_PARSE_ERROR
1906
+ end
1907
+
1908
+ cmd = CLI::Commands::Docs.new
1909
+ cmd.call(path, @options)
1910
+ end
1911
+
1912
+ # Execute the live command
1913
+ #
1914
+ # Starts a live preview server with web UI.
1915
+ #
1916
+ # @return [Integer] exit code
1917
+ #
1918
+ # :nocov:
1919
+ def live_command
1920
+ parse_live_options
1921
+ path = @args.shift || @options[:dir]
1922
+
1923
+ unless path && File.directory?(path)
1924
+ $stderr.puts "Usage: synthra live <schema-directory> [options]"
1925
+ return EXIT_PARSE_ERROR
1926
+ end
1927
+
1928
+ cmd = CLI::Commands::Live.new
1929
+ cmd.call(path, @options)
1930
+ end
1931
+ # :nocov:
1932
+
1933
+ # Execute the seed command
1934
+ #
1935
+ # Seeds databases with generated data.
1936
+ #
1937
+ # @return [Integer] exit code
1938
+ #
1939
+ def seed_command
1940
+ parse_seed_options
1941
+ cmd = CLI::Commands::Seed.new
1942
+ cmd.call(@options)
1943
+ end
1944
+
1945
+ # Parse options for import command
1946
+ #
1947
+ # @return [void]
1948
+ #
1949
+ def parse_import_options
1950
+ @options[:output] = "schemas"
1951
+
1952
+ OptionParser.new do |opts|
1953
+ opts.banner = "Usage: synthra import <file> [options]"
1954
+
1955
+ opts.on("-o", "--output DIR", "Output directory for generated schemas") do |v|
1956
+ @options[:output] = v
1957
+ end
1958
+ end.parse!(@args)
1959
+ end
1960
+
1961
+ # Parse options for docs command
1962
+ #
1963
+ # @return [void]
1964
+ #
1965
+ def parse_docs_options
1966
+ @options[:output] = "docs"
1967
+ @options[:title] = "Synthra Schema Documentation"
1968
+
1969
+ OptionParser.new do |opts|
1970
+ opts.banner = "Usage: synthra docs <directory> [options]"
1971
+
1972
+ opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
1973
+ @options[:dir] = v
1974
+ end
1975
+
1976
+ opts.on("-o", "--output DIR", "Output directory for documentation") do |v|
1977
+ @options[:output] = v
1978
+ end
1979
+
1980
+ opts.on("-t", "--title TITLE", "Documentation title") do |v|
1981
+ @options[:title] = v
1982
+ end
1983
+ end.parse!(@args)
1984
+ end
1985
+
1986
+ # Parse options for live command
1987
+ #
1988
+ # @return [void]
1989
+ #
1990
+ # :nocov:
1991
+ def parse_live_options
1992
+ @options[:port] = 4567
1993
+
1994
+ OptionParser.new do |opts|
1995
+ opts.banner = "Usage: synthra live <directory> [options]"
1996
+
1997
+ opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
1998
+ @options[:dir] = v
1999
+ end
2000
+
2001
+ opts.on("-p", "--port PORT", Integer, "Server port (default: 4567)") do |v|
2002
+ @options[:port] = v
2003
+ end
2004
+ end.parse!(@args)
2005
+ end
2006
+ # :nocov:
2007
+
2008
+ # Parse options for seed command
2009
+ #
2010
+ # @return [void]
2011
+ #
2012
+ def parse_seed_options
2013
+ @options[:schema_dir] = "schemas"
2014
+ @options[:count] = 10
2015
+ @options[:file] = nil
2016
+
2017
+ OptionParser.new do |opts|
2018
+ opts.banner = "Usage: synthra seed [options]"
2019
+
2020
+ opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
2021
+ @options[:schema_dir] = v
2022
+ end
2023
+
2024
+ opts.on("-c", "--count COUNT", Integer, "Records per schema (default: 10)") do |v|
2025
+ @options[:count] = v
2026
+ end
2027
+
2028
+ opts.on("-f", "--file FILE", "Seed file to execute") do |v|
2029
+ @options[:file] = v
2030
+ end
2031
+ end.parse!(@args)
2032
+ end
2033
+
2034
+ # Execute the server command
2035
+ #
2036
+ # Starts the production API server for data generation.
2037
+ #
2038
+ # @return [Integer] exit code
2039
+ #
2040
+ # :nocov:
2041
+ def server_command
2042
+ parse_server_options
2043
+ path = @args.shift || @options[:dir]
2044
+
2045
+ unless path && File.directory?(path)
2046
+ $stderr.puts "Usage: synthra server <schema-directory> [options]"
2047
+ return EXIT_PARSE_ERROR
2048
+ end
2049
+
2050
+ puts "Starting Synthra API Server..."
2051
+ Synthra::APIServer.start(
2052
+ schema_dir: path,
2053
+ port: @options[:port],
2054
+ host: @options[:host],
2055
+ environment: @options[:environment],
2056
+ cors: @options[:cors],
2057
+ rate_limit: @options[:rate_limit],
2058
+ auth: @options[:auth]
2059
+ )
2060
+ EXIT_SUCCESS
2061
+ rescue Interrupt
2062
+ puts "\nServer stopped."
2063
+ EXIT_SUCCESS
2064
+ end
2065
+ # :nocov:
2066
+
2067
+ # Execute the contracts command
2068
+ #
2069
+ # Manages data contracts registry.
2070
+ #
2071
+ # @return [Integer] exit code
2072
+ #
2073
+ def contracts_command
2074
+ parse_contracts_options
2075
+ subcommand = @args.shift
2076
+
2077
+ case subcommand
2078
+ when "publish"
2079
+ contracts_publish
2080
+ when "deprecate"
2081
+ contracts_deprecate
2082
+ when "list"
2083
+ contracts_list
2084
+ when "history"
2085
+ contracts_history
2086
+ when "diff"
2087
+ contracts_diff
2088
+ else
2089
+ puts "Usage: synthra contracts <subcommand> [options]"
2090
+ puts ""
2091
+ puts "Subcommands:"
2092
+ puts " publish <schema> -v VERSION Publish a schema version"
2093
+ puts " deprecate <schema> -v VERSION Deprecate a schema version"
2094
+ puts " list List all contracts"
2095
+ puts " history <schema> Show schema history"
2096
+ puts " diff <schema> <v1> <v2> Compare versions"
2097
+ EXIT_SUCCESS
2098
+ end
2099
+ end
2100
+
2101
+ # Execute the perf command
2102
+ #
2103
+ # High-performance generation for millions of records.
2104
+ #
2105
+ # @return [Integer] exit code
2106
+ #
2107
+ def perf_command
2108
+ parse_perf_options
2109
+ schema_name = @args.shift
2110
+
2111
+ unless schema_name
2112
+ $stderr.puts "Usage: synthra perf <SchemaName> [options]"
2113
+ return EXIT_PARSE_ERROR
2114
+ end
2115
+
2116
+ registry = load_registry(@options[:dir])
2117
+ unless registry.schema?(schema_name)
2118
+ $stderr.puts "Schema not found: #{schema_name}"
2119
+ return EXIT_PARSE_ERROR
2120
+ end
2121
+
2122
+ schema = registry.schema(schema_name)
2123
+ count = @options[:count]
2124
+
2125
+ if @options[:output]
2126
+ # Generate to file
2127
+ result = Synthra::PerformanceMode.to_file(
2128
+ schema,
2129
+ count: count,
2130
+ output: @options[:output],
2131
+ format: @options[:format],
2132
+ seed: @options[:seed],
2133
+ mode: @options[:mode]
2134
+ ) do |progress|
2135
+ print "\r#{progress[:current]} / #{progress[:total]} (#{progress[:percent]}%) - #{progress[:rate]} rec/s - ETA: #{progress[:eta]}s"
2136
+ end
2137
+ puts "\n\nGenerated #{result[:records]} records in #{result[:elapsed].round(2)}s (#{result[:rate].round(0)} rec/s)"
2138
+ elsif @options[:benchmark]
2139
+ # Benchmark mode
2140
+ puts "Benchmarking #{schema_name}..."
2141
+ results = Synthra::PerformanceMode.benchmark(schema, counts: [1000, 10_000, 100_000])
2142
+ results.each do |c, r|
2143
+ puts " #{c.to_s.rjust(7)} records: #{r[:elapsed]}s (#{r[:rate]} rec/s, #{r[:memory_mb]} MB)"
2144
+ end
2145
+ else
2146
+ # Stream to stdout
2147
+ Synthra::PerformanceMode.stream(schema, count: count, seed: @options[:seed]).each do |record|
2148
+ puts JSON.generate(record)
2149
+ end
2150
+ end
2151
+
2152
+ EXIT_SUCCESS
2153
+ end
2154
+
2155
+ # Parse options for server command
2156
+ #
2157
+ # @return [void]
2158
+ #
2159
+ # :nocov:
2160
+ def parse_server_options
2161
+ @options[:port] = 3000
2162
+ @options[:host] = "0.0.0.0"
2163
+ @options[:environment] = :development
2164
+ @options[:cors] = true
2165
+ @options[:rate_limit] = 0
2166
+ @options[:auth] = nil
2167
+
2168
+ OptionParser.new do |opts|
2169
+ opts.banner = "Usage: synthra server <directory> [options]"
2170
+
2171
+ opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
2172
+ @options[:dir] = v
2173
+ end
2174
+
2175
+ opts.on("-p", "--port PORT", Integer, "Server port (default: 3000)") do |v|
2176
+ @options[:port] = v
2177
+ end
2178
+
2179
+ opts.on("-h", "--host HOST", "Server host (default: 0.0.0.0)") do |v|
2180
+ @options[:host] = v
2181
+ end
2182
+
2183
+ opts.on("-e", "--env ENV", "Environment: development, production") do |v|
2184
+ @options[:environment] = v.to_sym
2185
+ end
2186
+
2187
+ opts.on("--no-cors", "Disable CORS headers") do
2188
+ @options[:cors] = false
2189
+ end
2190
+
2191
+ opts.on("--rate-limit LIMIT", Integer, "Rate limit per minute (0 = unlimited)") do |v|
2192
+ @options[:rate_limit] = v
2193
+ end
2194
+
2195
+ opts.on("--api-key KEY", "Require API key authentication") do |v|
2196
+ @options[:auth] = { type: :api_key, keys: [v] }
2197
+ end
2198
+ end.parse!(@args)
2199
+ end
2200
+ # :nocov:
2201
+
2202
+ # Parse options for contracts command
2203
+ #
2204
+ # @return [void]
2205
+ #
2206
+ def parse_contracts_options
2207
+ @options[:contracts_dir] = "contracts"
2208
+ @options[:version] = nil
2209
+ @options[:changelog] = nil
2210
+
2211
+ OptionParser.new do |opts|
2212
+ opts.banner = "Usage: synthra contracts <subcommand> [options]"
2213
+
2214
+ opts.on("-d", "--dir DIR", "Contracts directory (default: contracts)") do |v|
2215
+ @options[:contracts_dir] = v
2216
+ end
2217
+
2218
+ opts.on("-s", "--schema-dir DIR", "Schema directory") do |v|
2219
+ @options[:schema_dir] = v
2220
+ end
2221
+
2222
+ opts.on("-v", "--version VERSION", "Version (semver)") do |v|
2223
+ @options[:version] = v
2224
+ end
2225
+
2226
+ opts.on("-m", "--message MSG", "Changelog message") do |v|
2227
+ @options[:changelog] = v
2228
+ end
2229
+
2230
+ opts.on("--sunset DATE", "Sunset date for deprecation") do |v|
2231
+ @options[:sunset] = Date.parse(v)
2232
+ end
2233
+ end.parse!(@args)
2234
+ end
2235
+
2236
+ # Parse options for perf command
2237
+ #
2238
+ # @return [void]
2239
+ #
2240
+ def parse_perf_options
2241
+ @options[:count] = 100_000
2242
+ @options[:format] = :ndjson
2243
+ @options[:benchmark] = false
2244
+
2245
+ OptionParser.new do |opts|
2246
+ opts.banner = "Usage: synthra perf <SchemaName> [options]"
2247
+
2248
+ opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
2249
+ @options[:dir] = v
2250
+ end
2251
+
2252
+ opts.on("-c", "--count COUNT", Integer, "Number of records (default: 100000)") do |v|
2253
+ @options[:count] = v
2254
+ end
2255
+
2256
+ opts.on("-o", "--output FILE", "Output file") do |v|
2257
+ @options[:output] = v
2258
+ end
2259
+
2260
+ opts.on("-f", "--format FORMAT", "Output format: ndjson, json, csv (default: ndjson)") do |v|
2261
+ @options[:format] = v.to_sym
2262
+ end
2263
+
2264
+ opts.on("-s", "--seed SEED", Integer, "Random seed") do |v|
2265
+ @options[:seed] = v
2266
+ end
2267
+
2268
+ opts.on("-m", "--mode MODE", "Generation mode") do |v|
2269
+ @options[:mode] = v.to_sym
2270
+ end
2271
+
2272
+ opts.on("-b", "--benchmark", "Run benchmark instead of generating") do
2273
+ @options[:benchmark] = true
2274
+ end
2275
+ end.parse!(@args)
2276
+ end
2277
+
2278
+ # Contracts subcommand: publish
2279
+ #
2280
+ # @return [Integer] exit code
2281
+ #
2282
+ def contracts_publish
2283
+ schema_name = @args.shift
2284
+ version = @options[:version]
2285
+
2286
+ unless schema_name && version
2287
+ $stderr.puts "Usage: synthra contracts publish <SchemaName> -v <version>"
2288
+ return EXIT_PARSE_ERROR
2289
+ end
2290
+
2291
+ registry = load_registry(@options[:schema_dir] || "schemas")
2292
+ unless registry.schema?(schema_name)
2293
+ $stderr.puts "Schema not found: #{schema_name}"
2294
+ return EXIT_PARSE_ERROR
2295
+ end
2296
+
2297
+ contracts = ContractsRegistry.new(@options[:contracts_dir])
2298
+ contract = contracts.publish(
2299
+ schema_name,
2300
+ version: version,
2301
+ schema: registry.schema(schema_name),
2302
+ changelog: @options[:changelog]
2303
+ )
2304
+
2305
+ puts "Published #{schema_name}@#{version}"
2306
+ puts " Hash: #{contract[:schema_hash]}"
2307
+ puts " Fields: #{contract[:fields].count}"
2308
+ EXIT_SUCCESS
2309
+ end
2310
+
2311
+ # Contracts subcommand: deprecate
2312
+ #
2313
+ # @return [Integer] exit code
2314
+ #
2315
+ def contracts_deprecate
2316
+ schema_name = @args.shift
2317
+ version = @options[:version]
2318
+
2319
+ unless schema_name && version
2320
+ $stderr.puts "Usage: synthra contracts deprecate <SchemaName> -v <version>"
2321
+ return EXIT_PARSE_ERROR
2322
+ end
2323
+
2324
+ contracts = ContractsRegistry.new(@options[:contracts_dir])
2325
+ contracts.deprecate(
2326
+ schema_name,
2327
+ version: version,
2328
+ sunset_date: @options[:sunset],
2329
+ message: @options[:changelog]
2330
+ )
2331
+
2332
+ puts "Deprecated #{schema_name}@#{version}"
2333
+ puts " Sunset: #{@options[:sunset]}" if @options[:sunset]
2334
+ EXIT_SUCCESS
2335
+ end
2336
+
2337
+ # Contracts subcommand: list
2338
+ #
2339
+ # @return [Integer] exit code
2340
+ #
2341
+ def contracts_list
2342
+ contracts = ContractsRegistry.new(@options[:contracts_dir])
2343
+ all = contracts.list
2344
+
2345
+ if all.empty?
2346
+ puts "No contracts found."
2347
+ return EXIT_SUCCESS
2348
+ end
2349
+
2350
+ puts "Data Contracts:"
2351
+ puts ""
2352
+
2353
+ contracts.schema_names.each do |name|
2354
+ puts " #{name}:"
2355
+ contracts.versions_for(name).each do |v|
2356
+ contract = contracts.get(name, version: v)
2357
+ state_icon = case contract[:state]
2358
+ when :published then "✅"
2359
+ when :deprecated then "⚠️"
2360
+ when :retired then "❌"
2361
+ else "📝"
2362
+ end
2363
+ puts " #{state_icon} v#{v} (#{contract[:fields].count} fields)"
2364
+ end
2365
+ end
2366
+
2367
+ EXIT_SUCCESS
2368
+ end
2369
+
2370
+ # Contracts subcommand: history
2371
+ #
2372
+ # @return [Integer] exit code
2373
+ #
2374
+ def contracts_history
2375
+ schema_name = @args.shift
2376
+
2377
+ unless schema_name
2378
+ $stderr.puts "Usage: synthra contracts history <SchemaName>"
2379
+ return EXIT_PARSE_ERROR
2380
+ end
2381
+
2382
+ contracts = ContractsRegistry.new(@options[:contracts_dir])
2383
+ history = contracts.history(schema_name)
2384
+
2385
+ if history.empty?
2386
+ puts "No history found for #{schema_name}"
2387
+ return EXIT_SUCCESS
2388
+ end
2389
+
2390
+ puts "History for #{schema_name}:"
2391
+ puts ""
2392
+
2393
+ history.reverse.each do |entry|
2394
+ puts " #{entry[:timestamp]} - v#{entry[:version]} #{entry[:action]}"
2395
+ puts " #{entry[:message]}" if entry[:message]
2396
+ end
2397
+
2398
+ EXIT_SUCCESS
2399
+ end
2400
+
2401
+ # Contracts subcommand: diff
2402
+ #
2403
+ # @return [Integer] exit code
2404
+ #
2405
+ def contracts_diff
2406
+ schema_name = @args.shift
2407
+ v1 = @args.shift
2408
+ v2 = @args.shift
2409
+
2410
+ unless schema_name && v1 && v2
2411
+ $stderr.puts "Usage: synthra contracts diff <SchemaName> <v1> <v2>"
2412
+ return EXIT_PARSE_ERROR
2413
+ end
2414
+
2415
+ contracts = ContractsRegistry.new(@options[:contracts_dir])
2416
+ result = contracts.compatible?(schema_name, v1, v2)
2417
+
2418
+ puts "Comparing #{schema_name} v#{v1} → v#{v2}"
2419
+ puts ""
2420
+
2421
+ if result[:compatible]
2422
+ puts "✅ Compatible (no breaking changes)"
2423
+ else
2424
+ puts "❌ Breaking changes detected!"
2425
+ end
2426
+
2427
+ puts ""
2428
+ puts "Changes:"
2429
+ changes = result[:changes]
2430
+
2431
+ if changes[:removed_fields].any?
2432
+ puts " Removed: #{changes[:removed_fields].join(', ')}"
2433
+ end
2434
+
2435
+ if changes[:added_fields].any?
2436
+ puts " Added: #{changes[:added_fields].join(', ')}"
2437
+ end
2438
+
2439
+ changes[:type_changes].each do |c|
2440
+ puts " Type changed: #{c[:field]} (#{c[:from]} → #{c[:to]})"
2441
+ end
2442
+
2443
+ changes[:required_changes].each do |c|
2444
+ puts " Required changed: #{c[:field]} (#{c[:change]})"
2445
+ end
2446
+
2447
+ result[:compatible] ? EXIT_SUCCESS : EXIT_PARSE_ERROR
2448
+ end
2449
+
2450
+ # Load registry from directory
2451
+ #
2452
+ # @param dir [String] directory path
2453
+ # @return [Registry] loaded registry
2454
+ #
2455
+ def load_registry(dir)
2456
+ registry = Registry.new
2457
+ registry.load_dir(dir || ".")
2458
+ registry
2459
+ end
2460
+
2461
+ # Display version information
2462
+ #
2463
+ # @return [Integer] EXIT_SUCCESS
2464
+ #
2465
+
2466
+ def show_version
2467
+ puts "Synthra version #{Synthra::VERSION}"
2468
+ EXIT_SUCCESS
2469
+ end
2470
+ end
2471
+ end