brute 1.0.1 → 2.0.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +72 -6
  3. data/lib/brute/events/handler.rb +69 -0
  4. data/lib/brute/events/prefixed_terminal_output.rb +72 -0
  5. data/lib/brute/events/terminal_output_handler.rb +68 -0
  6. data/lib/brute/middleware/001_otel_span.rb +77 -0
  7. data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
  8. data/lib/brute/middleware/004_summarize.rb +139 -0
  9. data/lib/brute/middleware/005_tracing.rb +86 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +73 -0
  11. data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
  12. data/lib/brute/middleware/020_system_prompt.rb +128 -0
  13. data/lib/brute/middleware/040_compaction_check.rb +155 -0
  14. data/lib/brute/middleware/060_questions.rb +41 -0
  15. data/lib/brute/middleware/070_tool_call.rb +247 -0
  16. data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
  17. data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
  18. data/lib/brute/middleware/100_llm_call.rb +62 -0
  19. data/lib/brute/middleware/event_handler.rb +25 -0
  20. data/lib/brute/middleware/user_queue.rb +35 -0
  21. data/lib/brute/pipeline.rb +44 -107
  22. data/lib/brute/prompts/skills.rb +2 -2
  23. data/lib/brute/prompts.rb +23 -23
  24. data/lib/brute/providers/shell.rb +6 -19
  25. data/lib/brute/providers/shell_response.rb +22 -30
  26. data/lib/brute/session.rb +52 -0
  27. data/lib/brute/store/snapshot_store.rb +21 -37
  28. data/lib/brute/sub_agent.rb +106 -0
  29. data/lib/brute/system_prompt.rb +1 -83
  30. data/lib/brute/tool.rb +107 -0
  31. data/lib/brute/tools/delegate.rb +61 -70
  32. data/lib/brute/tools/fs_patch.rb +9 -7
  33. data/lib/brute/tools/fs_read.rb +233 -20
  34. data/lib/brute/tools/fs_remove.rb +8 -9
  35. data/lib/brute/tools/fs_search.rb +98 -16
  36. data/lib/brute/tools/fs_undo.rb +8 -8
  37. data/lib/brute/tools/fs_write.rb +7 -5
  38. data/lib/brute/tools/net_fetch.rb +8 -8
  39. data/lib/brute/tools/question.rb +36 -24
  40. data/lib/brute/tools/shell.rb +74 -16
  41. data/lib/brute/tools/todo_read.rb +8 -8
  42. data/lib/brute/tools/todo_write.rb +25 -18
  43. data/lib/brute/tools.rb +8 -12
  44. data/lib/brute/truncation.rb +219 -0
  45. data/lib/brute/version.rb +1 -1
  46. data/lib/brute.rb +82 -45
  47. metadata +59 -46
  48. data/lib/brute/loop/agent_stream.rb +0 -118
  49. data/lib/brute/loop/agent_turn.rb +0 -520
  50. data/lib/brute/loop/compactor.rb +0 -107
  51. data/lib/brute/loop/doom_loop.rb +0 -86
  52. data/lib/brute/loop/step.rb +0 -332
  53. data/lib/brute/loop/tool_call_step.rb +0 -90
  54. data/lib/brute/middleware/base.rb +0 -27
  55. data/lib/brute/middleware/compaction_check.rb +0 -106
  56. data/lib/brute/middleware/doom_loop_detection.rb +0 -136
  57. data/lib/brute/middleware/llm_call.rb +0 -128
  58. data/lib/brute/middleware/message_tracking.rb +0 -339
  59. data/lib/brute/middleware/otel/span.rb +0 -105
  60. data/lib/brute/middleware/otel/token_usage.rb +0 -68
  61. data/lib/brute/middleware/otel/tool_calls.rb +0 -68
  62. data/lib/brute/middleware/otel/tool_results.rb +0 -65
  63. data/lib/brute/middleware/otel.rb +0 -34
  64. data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
  65. data/lib/brute/middleware/retry.rb +0 -157
  66. data/lib/brute/middleware/session_persistence.rb +0 -72
  67. data/lib/brute/middleware/token_tracking.rb +0 -124
  68. data/lib/brute/middleware/tool_error_tracking.rb +0 -179
  69. data/lib/brute/middleware/tool_use_guard.rb +0 -133
  70. data/lib/brute/middleware/tracing.rb +0 -124
  71. data/lib/brute/middleware.rb +0 -18
  72. data/lib/brute/orchestrator/turn.rb +0 -105
  73. data/lib/brute/patches/anthropic_tool_role.rb +0 -35
  74. data/lib/brute/patches/buffer_nil_guard.rb +0 -26
  75. data/lib/brute/providers/models_dev.rb +0 -111
  76. data/lib/brute/providers/ollama.rb +0 -135
  77. data/lib/brute/providers/opencode_go.rb +0 -43
  78. data/lib/brute/providers/opencode_zen.rb +0 -87
  79. data/lib/brute/providers.rb +0 -62
  80. data/lib/brute/queue/base_queue.rb +0 -222
  81. data/lib/brute/queue/parallel_queue.rb +0 -66
  82. data/lib/brute/queue/sequential_queue.rb +0 -63
  83. data/lib/brute/store/message_store.rb +0 -362
  84. data/lib/brute/store/session.rb +0 -106
  85. /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
@@ -2,19 +2,21 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "brute"
5
+ require "brute/tools"
5
6
  require 'fileutils'
6
7
 
7
8
  module Brute
8
9
  module Tools
9
- class FSWrite < LLM::Tool
10
- name 'write'
10
+ class FSWrite < RubyLLM::Tool
11
11
  description "Write content to a file. Creates parent directories if they don't exist. " \
12
12
  'Use this for creating new files or completely replacing file contents.'
13
13
 
14
- param :file_path, String, 'Path to the file to write', required: true
15
- param :content, String, 'The full content to write to the file', required: true
14
+ param :file_path, type: 'string', desc: 'Path to the file to write', required: true
15
+ param :content, type: 'string', desc: 'The full content to write to the file', required: true
16
16
 
17
- def call(file_path:, content:)
17
+ def name; "write"; end
18
+
19
+ def execute(file_path:, content:)
18
20
  path = File.expand_path(file_path)
19
21
  Brute::Queue::FileMutationQueue.serialize(path) do
20
22
  old_content = File.exist?(path) ? File.read(path) : ''
@@ -1,25 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/tools"
7
6
 
8
7
  require "net/http"
9
8
  require "uri"
10
9
 
11
10
  module Brute
12
11
  module Tools
13
- class NetFetch < LLM::Tool
14
- name "fetch"
12
+ class NetFetch < RubyLLM::Tool
15
13
  description "Fetch content from a URL. Returns the response body as text."
16
14
 
17
- param :url, String, "The URL to fetch", required: true
15
+ param :url, type: 'string', desc: "The URL to fetch", required: true
16
+
17
+ def name; "fetch"; end
18
18
 
19
19
  MAX_BODY = 50_000
20
20
  TIMEOUT = 30
21
21
 
22
- def call(url:)
22
+ def execute(url:)
23
23
  uri = URI.parse(url)
24
24
  raise "Invalid URL scheme: #{uri.scheme}" unless %w[http https].include?(uri.scheme)
25
25
 
@@ -1,14 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/tools"
7
6
 
8
7
  module Brute
9
8
  module Tools
10
- class Question < LLM::Tool
11
- name "question"
9
+ class Question < RubyLLM::Tool
12
10
  description "Ask the user questions during execution. Use this to gather preferences, " \
13
11
  "clarify ambiguous instructions, get decisions on implementation choices, or " \
14
12
  "offer choices about direction. Users can always select \"Other\" to provide " \
@@ -16,25 +14,39 @@ module Brute
16
14
  "to allow selecting more than one. If you recommend a specific option, make it " \
17
15
  "the first option and add \"(Recommended)\" at the end of the label."
18
16
 
19
- params do |s|
20
- s.object(
21
- questions: s.array(
22
- s.object(
23
- question: s.string.required,
24
- header: s.string.required,
25
- options: s.array(
26
- s.object(
27
- label: s.string.required,
28
- description: s.string.required,
29
- )
30
- ).required,
31
- multiple: s.boolean,
32
- )
33
- ).required
34
- )
35
- end
17
+ params({
18
+ type: 'object',
19
+ properties: {
20
+ questions: {
21
+ type: 'array',
22
+ items: {
23
+ type: 'object',
24
+ properties: {
25
+ question: { type: 'string' },
26
+ header: { type: 'string' },
27
+ options: {
28
+ type: 'array',
29
+ items: {
30
+ type: 'object',
31
+ properties: {
32
+ label: { type: 'string' },
33
+ description: { type: 'string' },
34
+ },
35
+ required: %w[label description],
36
+ },
37
+ },
38
+ multiple: { type: 'boolean' },
39
+ },
40
+ required: %w[question header options],
41
+ },
42
+ },
43
+ },
44
+ required: %w[questions],
45
+ })
46
+
47
+ def name; "question"; end
36
48
 
37
- def call(questions:)
49
+ def execute(questions:)
38
50
  handler = Thread.current[:on_question]
39
51
  unless handler
40
52
  return { error: true, message: "Cannot ask questions in non-interactive mode" }
@@ -1,43 +1,101 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/tools"
6
+ require "brute/truncation"
7
7
 
8
8
  require "open3"
9
9
 
10
10
  module Brute
11
11
  module Tools
12
- class Shell < LLM::Tool
13
- name "shell"
12
+ # Existing features (ref: opencode bash tool):
13
+ #
14
+ # 1. Tail-mode truncation — when output exceeds limits, keep the LAST
15
+ # N lines / bytes instead of the first. Command output typically has
16
+ # the important info (errors, summaries) at the end.
17
+ # 2. Save full output to disk — when truncating, write the complete
18
+ # output to a temp file and include the path in the truncated result
19
+ # so the LLM can use Read with offset/limit to inspect it.
20
+ # 3. Align limits with universal truncation (2000 lines / 50 KB).
21
+ # 4. Configurable per-call timeout — accept a timeout parameter from
22
+ # the LLM (defaults to 5 minutes).
23
+ # 5. Return a plain string instead of a Hash.
24
+ #
25
+ class Shell < RubyLLM::Tool
14
26
  description "Execute a shell command and return stdout, stderr, and exit code. " \
15
27
  "Use for git operations, running tests, installing packages, etc."
16
28
 
17
- param :command, String, "The shell command to execute", required: true
18
- param :cwd, String, "Working directory for the command (defaults to project root)"
29
+ param :command, type: 'string', desc: "The shell command to execute", required: true
30
+ param :cwd, type: 'string', desc: "Working directory for the command (defaults to project root)", required: false
31
+ param :timeout, type: 'integer', desc: "Timeout in seconds (defaults to 300)", required: false
32
+
33
+ def name; "shell"; end
19
34
 
20
- TIMEOUT = 300 # 5 minutes
21
- MAX_OUTPUT = 50_000
35
+ DEFAULT_TIMEOUT = 300 # 5 minutes
22
36
 
23
- def call(command:, cwd: nil)
37
+ def execute(command:, cwd: nil, timeout: nil)
24
38
  dir = cwd ? File.expand_path(cwd) : Dir.pwd
25
39
  raise "Directory not found: #{dir}" unless File.directory?(dir)
26
40
 
41
+ timeout_secs = timeout || DEFAULT_TIMEOUT
27
42
  stdout, stderr, status = nil
28
- Timeout.timeout(TIMEOUT) do
43
+ Timeout.timeout(timeout_secs) do
29
44
  stdout, stderr, status = Open3.capture3("bash", "-c", command, chdir: dir)
30
45
  end
31
46
 
32
47
  out = stdout.to_s
33
48
  err = stderr.to_s
34
- out = out[0...MAX_OUTPUT] + "\n...(truncated)" if out.size > MAX_OUTPUT
35
- err = err[0...MAX_OUTPUT] + "\n...(truncated)" if err.size > MAX_OUTPUT
36
49
 
37
- {stdout: out, stderr: err, exit_code: status.exitstatus}
50
+ # Combine output, preferring tail-mode truncation so errors/summaries at end are preserved
51
+ combined = out
52
+ combined += "\nSTDERR:\n#{err}" unless err.empty?
53
+ combined += "\n[exit code: #{status.exitstatus}]" if status.exitstatus != 0
54
+
55
+ Brute::Truncation.truncate(combined, direction: :tail)
38
56
  rescue Timeout::Error
39
- {error: "Command timed out after #{TIMEOUT}s", command: command}
57
+ "Command timed out after #{timeout_secs}s: #{command}"
40
58
  end
41
59
  end
42
60
  end
43
61
  end
62
+
63
+ test do
64
+ it "runs a command without error" do
65
+ result = Brute::Tools::Shell.new.call(command: "echo hello")
66
+ result.strip.should =~ /hello/
67
+ end
68
+
69
+ it "returns exit code" do
70
+ result = Brute::Tools::Shell.new.call(command: "false")
71
+ result.should =~ /exit code: 1/
72
+ end
73
+
74
+ it "returns a String, not a Hash" do
75
+ Brute::Tools::Shell.new.call(command: "echo hello").should.be.kind_of(String)
76
+ end
77
+
78
+ it "preserves the end of output when truncating (tail mode)" do
79
+ result = Brute::Tools::Shell.new.call(command: "seq 1 100000")
80
+ result.should =~ /100000/
81
+ end
82
+
83
+ # --- Save full output to disk ---
84
+
85
+ it "saves full output to disk when truncated" do
86
+ result = Brute::Tools::Shell.new.call(command: "seq 1 100000")
87
+ result.should =~ /Full output saved to:/
88
+ end
89
+
90
+ # --- Configurable timeout ---
91
+
92
+ it "accepts a timeout parameter" do
93
+ result = Brute::Tools::Shell.new.call(command: "sleep 0.1 && echo done", timeout: 10)
94
+ result.should =~ /done/
95
+ end
96
+
97
+ it "times out with a short timeout" do
98
+ result = Brute::Tools::Shell.new.call(command: "sleep 10", timeout: 1)
99
+ result.should =~ /timed out/i
100
+ end
101
+ end
@@ -1,18 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/tools"
7
6
 
8
7
  module Brute
9
8
  module Tools
10
- class TodoRead < LLM::Tool
11
- name "todo_read"
9
+ class TodoRead < RubyLLM::Tool
12
10
  description "Read the current todo list to check task status and progress."
13
- param :_placeholder, String, "Unused, pass any value"
11
+ param :_placeholder, type: 'string', desc: "Unused, pass any value", required: false
12
+
13
+ def name; "todo_read"; end
14
14
 
15
- def call(_placeholder: nil)
15
+ def execute(_placeholder: nil)
16
16
  {todos: Brute::Store::TodoStore.all}
17
17
  end
18
18
  end
@@ -1,30 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/tools"
7
6
 
8
7
  module Brute
9
8
  module Tools
10
- class TodoWrite < LLM::Tool
11
- name "todo_write"
9
+ class TodoWrite < RubyLLM::Tool
12
10
  description "Create or update the todo list. Send the complete list each time — " \
13
11
  "this replaces the existing list entirely."
14
12
 
15
- params do |s|
16
- s.object(
17
- todos: s.array(
18
- s.object(
19
- id: s.string.required,
20
- content: s.string.required,
21
- status: s.string.enum("pending", "in_progress", "completed", "cancelled").required
22
- )
23
- ).required
24
- )
25
- end
13
+ params({
14
+ type: 'object',
15
+ properties: {
16
+ todos: {
17
+ type: 'array',
18
+ items: {
19
+ type: 'object',
20
+ properties: {
21
+ id: { type: 'string' },
22
+ content: { type: 'string' },
23
+ status: { type: 'string', enum: %w[pending in_progress completed cancelled] },
24
+ },
25
+ required: %w[id content status],
26
+ },
27
+ },
28
+ },
29
+ required: %w[todos],
30
+ })
31
+
32
+ def name; "todo_write"; end
26
33
 
27
- def call(todos:)
34
+ def execute(todos:)
28
35
  items = todos.map do |t|
29
36
  t = t.transform_keys(&:to_sym) if t.is_a?(Hash)
30
37
  {id: t[:id], content: t[:content], status: t[:status]}
data/lib/brute/tools.rb CHANGED
@@ -1,15 +1,11 @@
1
- require_relative 'tools/fs_read'
2
- require_relative 'tools/fs_write'
3
- require_relative 'tools/fs_patch'
4
- require_relative 'tools/fs_remove'
5
- require_relative 'tools/fs_search'
6
- require_relative 'tools/fs_undo'
7
- require_relative 'tools/shell'
8
- require_relative 'tools/net_fetch'
9
- require_relative 'tools/todo_write'
10
- require_relative 'tools/todo_read'
11
- require_relative 'tools/delegate'
12
- require_relative 'tools/question'
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ Dir.glob("#{__dir__}/tools/**/*.rb").sort.each do |path|
7
+ require path
8
+ end
13
9
 
14
10
  module Brute
15
11
  module Tools
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "fileutils"
6
+ require "securerandom"
7
+
8
+ module Brute
9
+ # Universal tool output truncation.
10
+ #
11
+ # Every tool result passes through Truncation.truncate() before entering
12
+ # the LLM context. This is the primary guard against context window
13
+ # explosion — even if a tool has no internal limits, this module caps
14
+ # the output to a safe size.
15
+ #
16
+ # Existing features (ref: opencode truncate.ts):
17
+ #
18
+ # 1. Line + byte dual cap — truncate when output exceeds MAX_LINES
19
+ # (2000) or MAX_BYTES (50 KB), whichever is hit first.
20
+ # 2. Head mode (default) — keep the first N lines / bytes. Used for
21
+ # most tool output where the beginning is most relevant.
22
+ # 3. Tail mode — keep the last N lines / bytes. Used for shell output
23
+ # where errors and summaries appear at the end.
24
+ # 4. Overflow to disk — when truncating, write the full text to a file
25
+ # under TRUNCATION_DIR (e.g. ~/.local/share/brute/tool-output/).
26
+ # Return a preview + hint pointing to the saved file.
27
+ # 5. Hint message — when truncated, append a contextual hint:
28
+ # "Full output saved to: <path>. Use Read with offset/limit to
29
+ # view specific sections."
30
+ # 6. Configurable limits — allow overriding MAX_LINES / MAX_BYTES
31
+ # via per-call options.
32
+ # 7. Retention cleanup — purge saved output files older than a
33
+ # configurable retention period from a truncation directory.
34
+ # 8. Per-line truncation — truncate individual lines longer than
35
+ # MAX_LINE_LENGTH (2000 chars) with a suffix.
36
+ #
37
+ module Truncation
38
+ MAX_LINES = 2000
39
+ MAX_BYTES = 50 * 1024 # 50 KB
40
+ MAX_LINE_LENGTH = 2000
41
+ TRUNCATION_MARKER = "[Output truncated:"
42
+
43
+ TRUNCATION_DIR = File.join(Dir.home, ".local", "share", "brute", "tool-output")
44
+
45
+ # Truncate text to fit within line and byte limits.
46
+ #
47
+ # Returns the text unchanged if it fits. Otherwise returns a
48
+ # truncated preview with a hint message.
49
+ #
50
+ # @param text [String] the tool output to truncate
51
+ # @param max_lines [Integer] maximum number of lines to keep
52
+ # @param max_bytes [Integer] maximum byte size to keep
53
+ # @param direction [:head, :tail] which end to keep
54
+ # @param truncation_dir [String, nil] directory to save full output when truncating
55
+ # @return [String] the (possibly truncated) text
56
+ #
57
+ def self.truncate(text, max_lines: MAX_LINES, max_bytes: MAX_BYTES, direction: :head, truncation_dir: nil)
58
+ return text if text.nil? || text.empty?
59
+
60
+ # Per-line truncation first — cap individual lines
61
+ lines = text.lines.map { |line| truncate_line(line) }
62
+ text = lines.join
63
+
64
+ return text if lines.size <= max_lines && text.bytesize <= max_bytes
65
+
66
+ # Determine how many lines we can keep within both caps
67
+ kept = direction == :tail ? lines.last(max_lines) : lines.first(max_lines)
68
+
69
+ # Enforce byte cap
70
+ result_lines = []
71
+ bytes = 0
72
+ kept.each do |line|
73
+ break if bytes + line.bytesize > max_bytes
74
+ result_lines << line
75
+ bytes += line.bytesize
76
+ end
77
+
78
+ result = result_lines.join
79
+ total = lines.size
80
+ shown = result_lines.size
81
+
82
+ # Overflow to disk — save the full output so it can be inspected later
83
+ saved_path = save_to_disk(text, truncation_dir)
84
+
85
+ hint = "\n#{TRUNCATION_MARKER} showing #{shown} of #{total} lines]"
86
+ if saved_path
87
+ hint += "\nFull output saved to: #{saved_path}. Use Read with offset/limit to view specific sections."
88
+ end
89
+ result + hint
90
+ end
91
+
92
+ # Check whether text already contains a truncation marker.
93
+ def self.already_truncated?(text)
94
+ text.include?(TRUNCATION_MARKER)
95
+ end
96
+
97
+ # Truncate a single line if it exceeds MAX_LINE_LENGTH.
98
+ def self.truncate_line(line, max: MAX_LINE_LENGTH)
99
+ return line if line.length <= max
100
+ line[0, max] + "... (line truncated to #{max} chars)\n"
101
+ end
102
+
103
+ # Purge files older than retention_days from the given directory.
104
+ def self.cleanup!(dir, retention_days: 7)
105
+ return unless File.directory?(dir)
106
+ cutoff = Time.now - (retention_days * 86400)
107
+ Dir.glob(File.join(dir, "*")).each do |path|
108
+ File.delete(path) if File.file?(path) && File.mtime(path) < cutoff
109
+ end
110
+ end
111
+
112
+ # Save text to a file in truncation_dir. Returns the file path, or nil.
113
+ def self.save_to_disk(text, truncation_dir)
114
+ dir = truncation_dir || TRUNCATION_DIR
115
+ return nil unless dir
116
+ FileUtils.mkdir_p(dir)
117
+ path = File.join(dir, "tool_#{SecureRandom.hex(8)}.txt")
118
+ File.write(path, text)
119
+ path
120
+ rescue => _e
121
+ nil
122
+ end
123
+ private_class_method :save_to_disk
124
+ end
125
+ end
126
+
127
+ test do
128
+ require "tmpdir"
129
+ require "fileutils"
130
+
131
+ it "returns short text unchanged" do
132
+ Brute::Truncation.truncate("hello world").should == "hello world"
133
+ end
134
+
135
+ it "truncates text exceeding 2000 lines" do
136
+ big = "line\n" * 3000
137
+ result = Brute::Truncation.truncate(big)
138
+ result.lines.size.should.be < 2100
139
+ end
140
+
141
+ it "includes a hint when truncated" do
142
+ big = "line\n" * 3000
143
+ Brute::Truncation.truncate(big).should =~ /truncated/i
144
+ end
145
+
146
+ # --- Per-line truncation ---
147
+
148
+ it "truncates individual lines longer than MAX_LINE_LENGTH" do
149
+ long_line = "x" * 3000 + "\n"
150
+ result = Brute::Truncation.truncate(long_line)
151
+ result.lines.first.size.should.be < 2100
152
+ end
153
+
154
+ it "adds a suffix to truncated lines" do
155
+ long_line = "x" * 3000 + "\n"
156
+ result = Brute::Truncation.truncate(long_line)
157
+ result.should =~ /truncated/i
158
+ end
159
+
160
+ # --- Overflow to disk ---
161
+
162
+ it "saves full output to disk when truncating" do
163
+ Dir.mktmpdir do |dir|
164
+ big = "line\n" * 3000
165
+ result = Brute::Truncation.truncate(big, truncation_dir: dir)
166
+ files = Dir.glob(File.join(dir, "*"))
167
+ files.size.should == 1
168
+ File.read(files.first).should == big
169
+ end
170
+ end
171
+
172
+ it "includes saved file path in hint" do
173
+ Dir.mktmpdir do |dir|
174
+ big = "line\n" * 3000
175
+ result = Brute::Truncation.truncate(big, truncation_dir: dir)
176
+ result.should =~ /Full output saved to:/
177
+ end
178
+ end
179
+
180
+ it "does not save to disk when not truncated" do
181
+ Dir.mktmpdir do |dir|
182
+ result = Brute::Truncation.truncate("short\n", truncation_dir: dir)
183
+ Dir.glob(File.join(dir, "*")).size.should == 0
184
+ end
185
+ end
186
+
187
+ # --- Configurable limits ---
188
+
189
+ it "accepts custom max_lines" do
190
+ text = "line\n" * 50
191
+ result = Brute::Truncation.truncate(text, max_lines: 10)
192
+ result.lines.count { |l| l.strip == "line" }.should.be <= 10
193
+ end
194
+
195
+ it "accepts custom max_bytes" do
196
+ text = "line\n" * 50
197
+ result = Brute::Truncation.truncate(text, max_bytes: 20)
198
+ result.should =~ /truncated/i
199
+ end
200
+
201
+ # --- Retention cleanup ---
202
+
203
+ it "purges files older than retention period" do
204
+ Dir.mktmpdir do |dir|
205
+ old_file = File.join(dir, "old_output.txt")
206
+ File.write(old_file, "old content")
207
+ # Backdate the file to 8 days ago
208
+ old_time = Time.now - (8 * 86400)
209
+ File.utime(old_time, old_time, old_file)
210
+
211
+ new_file = File.join(dir, "new_output.txt")
212
+ File.write(new_file, "new content")
213
+
214
+ Brute::Truncation.cleanup!(dir, retention_days: 7)
215
+ File.exist?(old_file).should == false
216
+ File.exist?(new_file).should == true
217
+ end
218
+ end
219
+ end
data/lib/brute/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "1.0.1"
4
+ VERSION = "2.0.0"
5
5
  end