rubino-agent 0.3.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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# Dates, times, time zones & text encoding
|
|
2
|
+
|
|
3
|
+
Ruby 3.2–3.4 / Rails 7.1–8.x. Two independent topics that share a theme: **the default is rarely what you want, and the bug is silent.** Always be explicit about zone and encoding.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## PART 1 — Dates, times & time zones
|
|
8
|
+
|
|
9
|
+
### `Time` vs `Date` vs `DateTime`
|
|
10
|
+
|
|
11
|
+
| Class | Use for | Notes |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `Time` | timestamps, instants, anything with hours/minutes | Nanosecond precision, zone-aware. **Default choice.** |
|
|
14
|
+
| `Date` | calendar dates with no time-of-day (birthdays, due dates) | No zone, no time. |
|
|
15
|
+
| `DateTime` | **legacy — avoid in new code** | Slower, subtle calendar-reform quirks, superseded by `Time`. |
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# DON'T introduce DateTime in new code
|
|
19
|
+
DateTime.now # WRONG: legacy class
|
|
20
|
+
# DO
|
|
21
|
+
Time.current # RIGHT (Rails, zone-aware)
|
|
22
|
+
Time.now # plain Ruby instant (system zone — see footgun below)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If a library hands you a `DateTime`, convert: `datetime.to_time`.
|
|
26
|
+
|
|
27
|
+
### The #1 Rails footgun: system zone vs app zone
|
|
28
|
+
|
|
29
|
+
`Time.now`, `Date.today`, `Time.at`, `Time.parse` use the **server's system zone** (`ENV["TZ"]`). In Rails you almost always want the **application zone** (`Time.zone`). They differ silently until production runs in a different zone than your laptop.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# WRONG — system zone, leaks server config into your data/logic
|
|
33
|
+
Time.now
|
|
34
|
+
Date.today
|
|
35
|
+
Time.now.beginning_of_day
|
|
36
|
+
|
|
37
|
+
# RIGHT — application zone (ActiveSupport::TimeWithZone)
|
|
38
|
+
Time.current # == Time.zone.now
|
|
39
|
+
Date.current # == Time.zone.today
|
|
40
|
+
Time.zone.now.beginning_of_day
|
|
41
|
+
Time.zone.local(2026, 6, 9, 14, 30)
|
|
42
|
+
Time.zone.at(epoch_seconds)
|
|
43
|
+
Time.zone.parse("2026-06-09 14:30")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Rule: in a Rails app, if you typed `Time.now` or `Date.today`, it's a bug. Use `Time.current` / `Date.current` / `Time.zone.*`. (The `rubocop-rails` cop `Rails/TimeZone` enforces this.)
|
|
47
|
+
|
|
48
|
+
`Time.current` returns an `ActiveSupport::TimeWithZone` — quacks like `Time` but carries the zone. Comparisons across zones work because they normalize to UTC internally.
|
|
49
|
+
|
|
50
|
+
### `config.time_zone` vs `config.active_record.default_timezone`
|
|
51
|
+
|
|
52
|
+
Two **different** settings, frequently confused:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# config/application.rb
|
|
56
|
+
config.time_zone = "America/Lima" # the app's DISPLAY zone (Time.zone)
|
|
57
|
+
config.active_record.default_timezone = :utc # how AR STORES/reads DB times
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- `config.time_zone` = what `Time.zone` / `Time.current` return; how times are presented to users.
|
|
61
|
+
- `config.active_record.default_timezone` = `:utc` (default & recommended) or `:local`; controls the zone AR assumes for DB columns.
|
|
62
|
+
|
|
63
|
+
**Store UTC, display local.** Keep DB in UTC (`:utc`), set `config.time_zone` to your users' zone, and let `TimeWithZone` convert at the edges. A user can also have a per-request zone:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
around_action :use_user_zone
|
|
67
|
+
def use_user_zone(&block)
|
|
68
|
+
Time.use_zone(current_user&.time_zone || "UTC", &block)
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Parsing safely
|
|
73
|
+
|
|
74
|
+
`Time.parse` is convenient and dangerous: it **raises** on garbage, is lenient/ambiguous, and is locale-influenced. Never feed it raw user input unguarded.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# WRONG — raises ArgumentError on bad input, ambiguous formats
|
|
78
|
+
Time.parse(params[:when])
|
|
79
|
+
|
|
80
|
+
# RIGHT — explicit format, fail closed
|
|
81
|
+
def parse_when(str)
|
|
82
|
+
Time.zone.strptime(str, "%Y-%m-%d %H:%M") # zone-aware, exact format
|
|
83
|
+
rescue ArgumentError, TypeError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Prefer strict parsers when the format is known:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Time.iso8601("2026-06-09T14:30:00-05:00") # strict ISO 8601, raises on non-ISO
|
|
92
|
+
Date.iso8601("2026-06-09")
|
|
93
|
+
Time.zone.iso8601("2026-06-09T14:30:00Z") # -> TimeWithZone (Rails)
|
|
94
|
+
Time.at(1_749_500_000) # epoch seconds -> system zone
|
|
95
|
+
Time.zone.at(1_749_500_000) # epoch -> app zone (preferred in Rails)
|
|
96
|
+
Time.at(1_749_500_000.123456, in: "+00:00")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Guidelines:
|
|
100
|
+
- Known machine format → `iso8601` / `strptime` with an explicit pattern.
|
|
101
|
+
- Epoch integer → `Time.at` (or `Time.zone.at`).
|
|
102
|
+
- Free-form human input → validate/normalize upstream; if you must use `Time.parse`, wrap in `rescue ArgumentError`.
|
|
103
|
+
|
|
104
|
+
### Formatting — `strftime` cheatsheet
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
t.strftime("%Y-%m-%d") # 2026-06-09 (year-month-day)
|
|
108
|
+
t.strftime("%H:%M:%S") # 14:30:05 (24h:min:sec)
|
|
109
|
+
t.strftime("%Y-%m-%dT%H:%M:%S%z") # 2026-06-09T14:30:05-0500
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
| Code | Means | Code | Means |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| `%Y` | 4-digit year | `%H` | hour 00–23 |
|
|
115
|
+
| `%m` | month 01–12 | `%M` | minute 00–59 |
|
|
116
|
+
| `%d` | day 01–31 | `%S` | second 00–59 |
|
|
117
|
+
| `%A` | weekday name (Monday) | `%z` | UTC offset `-0500` |
|
|
118
|
+
| `%B` | month name (June) | `%j` | day of year |
|
|
119
|
+
| `%p` | AM/PM | `%:z` | offset `-05:00` |
|
|
120
|
+
|
|
121
|
+
Don't hand-roll ISO strings — use the built-ins:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
t.iso8601 # "2026-06-09T14:30:05-05:00"
|
|
125
|
+
t.to_fs(:iso8601) # Rails: same, via to_formatted_string
|
|
126
|
+
t.utc.iso8601 # normalize to UTC first when serializing
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
In Rails, prefer **I18n** for human-facing output (locale-aware, not hardcoded):
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
I18n.l(Time.current, format: :short) # uses config/locales/*.yml :time formats
|
|
133
|
+
I18n.l(Date.current, format: :long)
|
|
134
|
+
# define :short/:long under time.formats / date.formats in your locale files
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Arithmetic & durations
|
|
138
|
+
|
|
139
|
+
Use ActiveSupport durations and calendar helpers — they're DST-aware:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
2.hours.ago # TimeWithZone, app zone
|
|
143
|
+
3.days.from_now
|
|
144
|
+
Time.current.beginning_of_day
|
|
145
|
+
Time.current.end_of_month
|
|
146
|
+
date + 1.day # calendar-correct
|
|
147
|
+
(start..finish).to_a # range of dates
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
# WRONG — treats a day as a fixed 86400s; breaks across DST
|
|
152
|
+
t + 86400
|
|
153
|
+
t + (24 * 60 * 60)
|
|
154
|
+
|
|
155
|
+
# RIGHT — calendar day, DST-aware
|
|
156
|
+
t + 1.day
|
|
157
|
+
t.tomorrow
|
|
158
|
+
t.advance(days: 1)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Plain Ruby arithmetic is in **seconds** and is fine for true elapsed seconds, but never use it to mean "the next calendar day."
|
|
162
|
+
|
|
163
|
+
### DST & ambiguity
|
|
164
|
+
|
|
165
|
+
Daylight Saving creates two hazards:
|
|
166
|
+
- **Spring-forward gap:** a wall-clock time that never existed (02:30 may not occur).
|
|
167
|
+
- **Fall-back overlap:** a wall-clock time that occurs twice (01:30 happens twice).
|
|
168
|
+
|
|
169
|
+
Defenses:
|
|
170
|
+
- **Compare and store in UTC.** Offsets and durations are unambiguous in UTC.
|
|
171
|
+
- Do arithmetic with ActiveSupport durations (`+ 1.day`), which respect DST.
|
|
172
|
+
- Convert at boundaries with `in_time_zone` / `change` carefully:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
t.utc # normalize before comparing/storing
|
|
176
|
+
t.in_time_zone("America/Lima") # reinterpret instant in another zone (same UTC instant)
|
|
177
|
+
Time.current.in_time_zone("UTC")
|
|
178
|
+
|
|
179
|
+
# WRONG: comparing two local times across a DST boundary
|
|
180
|
+
local_a < local_b
|
|
181
|
+
# RIGHT: compare the underlying instants
|
|
182
|
+
local_a.utc < local_b.utc # (TimeWithZone#<=> already does this; force_zone bugs don't)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`in_time_zone` keeps the same instant and changes the display zone. Don't confuse it with `change(...)` (which mutates fields and can land you in a DST gap).
|
|
186
|
+
|
|
187
|
+
### Monotonic clock for measuring elapsed time
|
|
188
|
+
|
|
189
|
+
Wall-clock time can jump (NTP, DST, manual change). To measure **durations**, use the monotonic clock — it only moves forward.
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# WRONG — wall clock can go backward; can yield negative/garbage durations
|
|
193
|
+
start = Time.now
|
|
194
|
+
do_work
|
|
195
|
+
elapsed = Time.now - start
|
|
196
|
+
|
|
197
|
+
# RIGHT — monotonic
|
|
198
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
199
|
+
do_work
|
|
200
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start # seconds (Float)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Use `Time`/`Time.current` for *what time it is*; use `CLOCK_MONOTONIC` for *how long something took*.
|
|
204
|
+
|
|
205
|
+
### Testing time
|
|
206
|
+
|
|
207
|
+
Freeze/travel with `ActiveSupport::Testing::TimeHelpers` (`travel_to`, `freeze_time`, `travel`) instead of stubbing `Time.now`. See `references/testing.md` for setup and patterns — not repeated here.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## PART 2 — String encoding & text
|
|
212
|
+
|
|
213
|
+
### UTF-8 is the default
|
|
214
|
+
|
|
215
|
+
Modern Ruby is UTF-8 end to end: source files, string literals, and `Encoding.default_external` are UTF-8 unless something overrides them. You rarely need a `# encoding:` magic comment anymore.
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
Encoding.default_external # usually #<Encoding:UTF-8> (from locale/ENV)
|
|
219
|
+
Encoding.default_internal # usually nil (no auto-transcode on read)
|
|
220
|
+
"café".encoding # #<Encoding:UTF-8>
|
|
221
|
+
"café".valid_encoding? # true
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### `force_encoding` vs `encode` — the classic bug
|
|
225
|
+
|
|
226
|
+
This is the single most common encoding mistake.
|
|
227
|
+
|
|
228
|
+
- **`encode`** = *transcode*: convert the actual bytes from one encoding to another. Bytes change, characters preserved.
|
|
229
|
+
- **`force_encoding`** = *relabel*: reinterpret the **same bytes** under a different encoding tag. Bytes unchanged; meaning may break.
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
# encode: real conversion (UTF-8 -> ISO-8859-1 bytes)
|
|
233
|
+
"café".encode("ISO-8859-1") # bytes re-encoded
|
|
234
|
+
|
|
235
|
+
# force_encoding: just changes the label, NO byte conversion
|
|
236
|
+
bytes = "café".dup.force_encoding("ASCII-8BIT") # now treated as raw bytes
|
|
237
|
+
bytes.force_encoding("UTF-8") # back to UTF-8 label, original bytes intact
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
# WRONG — "fixing" a mojibake by relabeling; produces invalid strings
|
|
242
|
+
response_body.force_encoding("UTF-8") # when bytes are actually Latin-1
|
|
243
|
+
# RIGHT — transcode from the real source encoding
|
|
244
|
+
response_body.encode("UTF-8", "ISO-8859-1")
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Use `force_encoding` only when you *know* the bytes are already in the target encoding but were mislabeled (e.g., a string read in binary mode that you know is UTF-8). Use `encode` to actually convert.
|
|
248
|
+
|
|
249
|
+
### Conversion errors & scrubbing
|
|
250
|
+
|
|
251
|
+
Two errors you'll hit:
|
|
252
|
+
- `Encoding::UndefinedConversionError` — a character has no representation in the target encoding.
|
|
253
|
+
- `Encoding::InvalidByteSequenceError` — bytes aren't valid in the source encoding.
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
# Replace un-mappable / invalid bytes instead of raising
|
|
257
|
+
clean = raw.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
258
|
+
clean = raw.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
259
|
+
|
|
260
|
+
# scrub: drop/replace invalid bytes, staying in the same encoding
|
|
261
|
+
"bad\xFFstring".scrub # "bad�string"
|
|
262
|
+
"bad\xFFstring".scrub("") # remove them
|
|
263
|
+
str.valid_encoding? # check before trusting
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`String#scrub` is the quickest way to make an untrusted UTF-8 string safe to log/store. For a transcode you also want `invalid:`/`undef:`.
|
|
267
|
+
|
|
268
|
+
### Bytes vs characters — `ASCII-8BIT` / BINARY
|
|
269
|
+
|
|
270
|
+
`ASCII-8BIT` (alias `BINARY`) means "this is raw bytes, not text." Use it for binary protocols, hashing, image data, etc.
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
"\xDE\xAD".b # ASCII-8BIT literal (raw bytes)
|
|
274
|
+
File.binread("logo.png") # ASCII-8BIT, no transcode
|
|
275
|
+
io = File.open(path, "rb") # binary mode
|
|
276
|
+
digest = Digest::SHA256.digest(bytes) # operate on bytes
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Work in **bytes** for I/O boundaries, crypto, and length-prefixed protocols; work in **characters** for anything user-facing text.
|
|
280
|
+
|
|
281
|
+
### Length, slicing, normalization
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
"café".length # 4 (characters / code points)
|
|
285
|
+
"café".size # 4 (alias)
|
|
286
|
+
"café".bytesize # 5 (UTF-8: é is 2 bytes)
|
|
287
|
+
"café".byteslice(0, 3) # slice by BYTES (can split a multibyte char!)
|
|
288
|
+
"café"[0, 3] # slice by CHARACTERS -> "caf"
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Don't assume one char == one byte. For truncating to a byte budget (DB column, network frame), use `byteslice` then `scrub` to repair a possibly-split tail character.
|
|
292
|
+
|
|
293
|
+
**Unicode normalization** — "é" can be one code point (NFC) or `e` + combining accent (NFD). They look identical but aren't `==`. Normalize before comparing user input, filenames (esp. macOS, which uses NFD), or building dedupe keys:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
a = "café" # could be NFC or NFD depending on source
|
|
297
|
+
a.unicode_normalize(:nfc) # canonical composed form (default; use for comparison/storage)
|
|
298
|
+
a.unicode_normalize(:nfd) # decomposed
|
|
299
|
+
a.unicode_normalized?(:nfc) # boolean check
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Pick **NFC** as your canonical form on the way in.
|
|
303
|
+
|
|
304
|
+
### Reading/writing files with explicit encoding
|
|
305
|
+
|
|
306
|
+
Be explicit at the I/O boundary; don't rely on the process default.
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
File.read(path, encoding: "UTF-8")
|
|
310
|
+
File.write(path, str) # writes in str's encoding
|
|
311
|
+
File.open(path, "r:UTF-8") { |f| f.read }
|
|
312
|
+
File.open(path, "r:BOM|UTF-8") { |f| ... } # strip a leading UTF-8 BOM
|
|
313
|
+
File.open(path, "rb") { |f| f.read } # raw bytes (ASCII-8BIT)
|
|
314
|
+
|
|
315
|
+
# external:internal — transcode on read
|
|
316
|
+
File.open(path, "r:ISO-8859-1:UTF-8") { |f| f.read } # read Latin-1, hand back UTF-8
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`"BOM|UTF-8"` handles the byte-order-mark that Windows/Excel exports often prepend — without it the BOM (``) sneaks into your first field/line.
|
|
320
|
+
|
|
321
|
+
### External data: CSV & HTTP
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
require "csv"
|
|
325
|
+
# Tell CSV the file's real encoding (Excel often emits Windows-1252 or BOM'd UTF-8)
|
|
326
|
+
CSV.foreach(path, encoding: "bom|utf-8", headers: true) { |row| ... }
|
|
327
|
+
CSV.read(path, encoding: "ISO-8859-1:UTF-8") # transcode while parsing
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
HTTP bodies arrive as bytes; the client may tag them `ASCII-8BIT` or guess wrong. Re-tag/transcode from the response's declared `charset`:
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
# If you KNOW the bytes are UTF-8 but they're labeled binary:
|
|
334
|
+
body = response.body.dup.force_encoding("UTF-8")
|
|
335
|
+
body = body.scrub unless body.valid_encoding?
|
|
336
|
+
# If charset says something else, TRANSCODE instead:
|
|
337
|
+
body = response.body.encode("UTF-8", "Shift_JIS")
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Decide: are the bytes already UTF-8 (relabel with `force_encoding`) or in another charset (convert with `encode`)? Getting this wrong is the mojibake bug.
|
|
341
|
+
|
|
342
|
+
### Symbols & frozen strings
|
|
343
|
+
|
|
344
|
+
Encoding interacts with frozen-string literals and symbol interning, but those topics live elsewhere: see `references/language-idioms.md` (string/symbol idioms, `frozen_string_literal`) and `references/performance.md` (allocation/interning cost, parse cost of repeated `Time.parse`/`encode`). For Active Record column storage and zone config in the DB, see `references/rails.md`.
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Quick checklist
|
|
349
|
+
|
|
350
|
+
- Rails: use `Time.current` / `Date.current` / `Time.zone.*` — never `Time.now` / `Date.today`.
|
|
351
|
+
- New code: use `Time` or `Date`; never `DateTime`.
|
|
352
|
+
- Store times in **UTC** (`default_timezone = :utc`), display in `config.time_zone`. "Store UTC, display local."
|
|
353
|
+
- Parse with explicit formats: `iso8601` / `strptime`; wrap `Time.parse` in `rescue ArgumentError`, never trust raw input.
|
|
354
|
+
- Add `1.day`, not `86400` — durations are DST-aware; raw seconds are not.
|
|
355
|
+
- Compare/serialize across zones in **UTC**; convert display with `in_time_zone`.
|
|
356
|
+
- Measure elapsed time with `Process.clock_gettime(Process::CLOCK_MONOTONIC)`, never `Time.now - Time.now`.
|
|
357
|
+
- Test time with `travel_to` / `freeze_time` (see `references/testing.md`).
|
|
358
|
+
- `encode` = convert bytes; `force_encoding` = relabel bytes. Don't relabel to "fix" mojibake.
|
|
359
|
+
- Scrub untrusted text: `encode("UTF-8", invalid: :replace, undef: :replace)` or `String#scrub`; check `valid_encoding?`.
|
|
360
|
+
- Use `bytesize`/`byteslice` for byte budgets, `length`/`[]` for characters — one char ≠ one byte.
|
|
361
|
+
- Normalize user input/filenames to **NFC** with `unicode_normalize(:nfc)` before comparing.
|
|
362
|
+
- At I/O boundaries be explicit: `File.read(path, encoding: "UTF-8")`, `"r:BOM|UTF-8"`, `"rb"` for raw bytes.
|
|
363
|
+
- CSV/HTTP: know the source charset; `bom|utf-8` for Excel exports; transcode (`encode`) when charset ≠ UTF-8.
|