brute 3.2.2 → 4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd242366b30cf384cb0326b8fa006f43883f1cf117d410b830288164a4d13582
4
- data.tar.gz: 3d4973d9a6fa69e6fd04d464343d8d2d85fb8455236e9d03f32df17888182e1f
3
+ metadata.gz: 428a37a7f0f2e0930d1b0c901fc75606686c25e07046b54c6dc4964fbbb88433
4
+ data.tar.gz: 33f0b4a2956004b7e4d26790f981718fea46d2ae7cf1f54deaea250a7951edaf
5
5
  SHA512:
6
- metadata.gz: f0659b341410c0cb87db3cefe95e3c8c99bbf5ea9c88ba974d6b8f1adc5835919e256a577435748d652e1e229b30fb6cbaac321d297688fc9fac0812a028e369
7
- data.tar.gz: 5f693c1c49b4bd74ce901e78bcd459f04c6316fe044bb871a04142b09154cb04767a1166dd64d02d2332fb9fac63572a6d13b28fbef40520c18737b1a11f98f4
6
+ metadata.gz: 7c382ae2d9229bdd7524cbdb87614f94b8c54e25ae2f76ee68892198bcf8d901d35398b3555f712b07253f17c3dddd1d8d66907c44ff9eaa28dd5adca3441582
7
+ data.tar.gz: 1c3816fff0d6664a25a9bfcb0c44df62c6833c2ba5066b3cbec0772fd24ea1ab4e078b7e1faca3f577297842fdd92c3aca59368f376993ba88956aaf3c02d500
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ require_relative "deprecate"
6
+
7
+ module Brute
8
+ # A parser and linter for CHANGELOG.md, which follows
9
+ # [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
+ #
11
+ # The changelog is the one release artefact nothing else can regenerate, so
12
+ # it is the one most easily forgotten. Making it machine-checkable turns
13
+ # "did anyone write the changelog?" into a gate: `bin/lint-changelog`
14
+ # validates the format, and `bin/release-gem` refuses to push a version that
15
+ # has no section of its own.
16
+ #
17
+ # changelog = Brute::Changelog.load
18
+ # changelog.problems # => [] when the format is clean
19
+ # changelog.release_problems("4.1.0")
20
+ #
21
+ # The shape it expects:
22
+ #
23
+ # # Changelog
24
+ #
25
+ # ## [Unreleased]
26
+ #
27
+ # ## [4.1.0] - 2026-08-20
28
+ #
29
+ # ### Added
30
+ #
31
+ # - Something that happened.
32
+ #
33
+ class Changelog
34
+ # Deprecated. This moved out to the gem_kit-release gem, where the rest of
35
+ # the release toolchain now lives — `gem kit changelog` is the CLI over it.
36
+ # The implementation stays here, working, until the deadline.
37
+ extend Brute::Deprecate
38
+ brute_deprecate_constant "GemKit::Release::Changelog", "5.0"
39
+
40
+ # The six change types Keep a Changelog defines. Anything else under a
41
+ # version is a typo or an invention, and both are worth catching.
42
+ SECTIONS = %w[Added Changed Deprecated Removed Fixed Security].freeze
43
+
44
+ UNRELEASED = "Unreleased"
45
+ HEADING = /\A##\s+\[([^\]]+)\](?:\s+-\s+(.*))?\s*\z/
46
+ SUBHEADING = /\A###\s+(.*?)\s*\z/
47
+ BULLET = /\A[-*]\s+\S/
48
+ DATE = /\A\d{4}-\d{2}-\d{2}\z/
49
+
50
+ # One `## [...]` section of the file.
51
+ Release = Struct.new(:version, :date, :line, :subsections, keyword_init: true) do
52
+ def unreleased? = version == UNRELEASED
53
+ def empty? = subsections.empty? || subsections.values.all?(&:empty?)
54
+ def to_s = unreleased? ? "[#{version}]" : "[#{version}] - #{date}"
55
+ end
56
+
57
+ # Load the changelog sitting beside the gem (../../CHANGELOG.md).
58
+ def self.load(path = File.expand_path("../../CHANGELOG.md", __dir__))
59
+ new(File.exist?(path) ? File.read(path) : nil, path: path)
60
+ end
61
+
62
+ attr_reader :path, :releases
63
+
64
+ # @parameter text [String, nil] the file's contents; nil means "no file".
65
+ def initialize(text, path: "CHANGELOG.md")
66
+ @path = path
67
+ @text = text
68
+ @releases = text ? parse(text) : []
69
+ end
70
+
71
+ def missing? = @text.nil?
72
+
73
+ def unreleased = releases.find(&:unreleased?)
74
+
75
+ def released = releases.reject(&:unreleased?)
76
+
77
+ def find(version)
78
+ target = Gem::Version.new(version.to_s)
79
+ released.find { |release| Gem::Version.new(release.version) == target rescue false }
80
+ end
81
+
82
+ # Everything wrong with the file's *format*, as a list of human-readable
83
+ # problems. Empty means it lints clean.
84
+ def problems
85
+ return ["#{path} does not exist"] if missing?
86
+
87
+ [*header_problems, *heading_problems, *ordering_problems, *content_problems]
88
+ end
89
+
90
+ # Everything standing between this changelog and releasing `version`.
91
+ # Format problems count: a file nobody can parse is not documentation.
92
+ def release_problems(version)
93
+ return problems unless problems.empty?
94
+
95
+ release = find(version)
96
+ return ["#{path} has no section for #{version} — run bin/update-changelog"] if release.nil?
97
+ return ["#{path} section for #{version} is empty"] if release.empty?
98
+
99
+ newest = released.first
100
+ if newest && newest.version != release.version
101
+ return ["#{path} lists #{newest.version} above #{version}; the release being cut must come first"]
102
+ end
103
+
104
+ []
105
+ end
106
+
107
+ private
108
+
109
+ # Split the file into `## [...]` sections, recording each one's
110
+ # `### Type` subsections and their top-level bullets.
111
+ def parse(text)
112
+ found = []
113
+ current = nil
114
+ heading = nil
115
+
116
+ text.each_line.with_index(1) do |line, number|
117
+ case line
118
+ when HEADING
119
+ heading = nil
120
+ current = Release.new(version: $1, date: $2&.strip, line: number, subsections: {})
121
+ found << current
122
+ when SUBHEADING
123
+ next unless current
124
+
125
+ heading = $1
126
+ (current.subsections[heading] ||= [])
127
+ when BULLET
128
+ current.subsections[heading] << line.strip if current && heading
129
+ end
130
+ end
131
+
132
+ found
133
+ end
134
+
135
+ def header_problems
136
+ first = @text.each_line.find { |line| !line.strip.empty? }
137
+ return [] if first&.strip == "# Changelog"
138
+
139
+ ["#{path}:1 must start with the title `# Changelog`"]
140
+ end
141
+
142
+ def heading_problems
143
+ problems = []
144
+
145
+ @text.each_line.with_index(1) do |line, number|
146
+ next unless line.start_with?("## ") && !line.start_with?("###")
147
+
148
+ unless line =~ HEADING
149
+ problems << "#{path}:#{number} malformed version heading: #{line.strip.inspect} " \
150
+ "(expected `## [Unreleased]` or `## [1.2.3] - YYYY-MM-DD`)"
151
+ next
152
+ end
153
+
154
+ version, date = $1, $2&.strip
155
+ next if version == UNRELEASED
156
+
157
+ problems << "#{path}:#{number} #{version} has no date (expected `## [#{version}] - YYYY-MM-DD`)" if date.nil? || date.empty?
158
+ problems << "#{path}:#{number} #{version} has a malformed date: #{date.inspect}" if date && !date.empty? && date !~ DATE
159
+ problems << "#{path}:#{number} #{version} is not a valid version number" unless valid_version?(version)
160
+ end
161
+
162
+ problems + subheading_problems
163
+ end
164
+
165
+ def subheading_problems
166
+ @text.each_line.with_index(1).filter_map do |line, number|
167
+ next unless line.start_with?("### ")
168
+ next if line =~ SUBHEADING && SECTIONS.include?($1)
169
+
170
+ "#{path}:#{number} unknown section #{line.sub("###", "").strip.inspect} " \
171
+ "(expected one of: #{SECTIONS.join(", ")})"
172
+ end
173
+ end
174
+
175
+ def ordering_problems
176
+ problems = []
177
+
178
+ if unreleased && releases.first&.unreleased? == false
179
+ problems << "#{path}:#{unreleased.line} [Unreleased] must be the first section"
180
+ end
181
+
182
+ seen = {}
183
+ released.each do |release|
184
+ if (first = seen[release.version])
185
+ problems << "#{path}:#{release.line} duplicate section for #{release.version} (also at line #{first})"
186
+ end
187
+ seen[release.version] ||= release.line
188
+ end
189
+
190
+ versions = released.select { |release| valid_version?(release.version) }
191
+ versions.each_cons(2) do |newer, older|
192
+ next if Gem::Version.new(newer.version) > Gem::Version.new(older.version)
193
+
194
+ problems << "#{path}:#{older.line} #{older.version} is listed below #{newer.version}; " \
195
+ "releases must run newest to oldest"
196
+ end
197
+
198
+ problems
199
+ end
200
+
201
+ def content_problems
202
+ released.flat_map do |release|
203
+ if release.subsections.empty?
204
+ ["#{path}:#{release.line} #{release} has no #{SECTIONS.join("/")} section"]
205
+ else
206
+ release.subsections.filter_map do |heading, bullets|
207
+ "#{path}:#{release.line} #{release} has an empty `### #{heading}` section" if bullets.empty?
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ def valid_version?(version)
214
+ Gem::Version.correct?(version)
215
+ end
216
+ end
217
+ end
218
+
219
+ __END__
220
+
221
+ describe "brute/changelog" do
222
+ # These specs exercise a deprecated class on purpose; skip_during keeps the
223
+ # warning out of the suite's output (see DEPRECATIONS.md).
224
+ changelog = lambda do |body|
225
+ Gem::Deprecate.skip_during { Brute::Changelog.new("# Changelog\n\n#{body}") }
226
+ end
227
+
228
+ good = <<~MD
229
+ ## [Unreleased]
230
+
231
+ ## [4.1.0] - 2026-08-20
232
+
233
+ ### Added
234
+
235
+ - A thing.
236
+
237
+ ## [4.0.0] - 2026-08-01
238
+
239
+ ### Removed
240
+
241
+ - An older thing.
242
+ MD
243
+
244
+ it "parses sections, dates and bullets" do
245
+ log = changelog.call(good)
246
+
247
+ log.releases.map(&:version).should == ["Unreleased", "4.1.0", "4.0.0"]
248
+ log.unreleased.should.be.kind_of?(Brute::Changelog::Release)
249
+ log.released.first.date.should == "2026-08-20"
250
+ log.released.first.subsections["Added"].should == ["- A thing."]
251
+ log.find("4.0.0").subsections["Removed"].size.should == 1
252
+ end
253
+
254
+ it "lints a well-formed file clean and clears it for release" do
255
+ log = changelog.call(good)
256
+
257
+ log.problems.should == []
258
+ log.release_problems("4.1.0").should == []
259
+ end
260
+
261
+ it "requires the `# Changelog` title" do
262
+ Gem::Deprecate.skip_during do
263
+ Brute::Changelog.new("## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n")
264
+ .problems.first.should.match(/must start with the title/)
265
+ end
266
+ end
267
+
268
+ it "reports a missing file" do
269
+ log = Gem::Deprecate.skip_during { Brute::Changelog.new(nil, path: "nope.md") }
270
+ log.missing?.should.be.true
271
+ log.problems.first.should.match(/does not exist/)
272
+ end
273
+
274
+ it "rejects a malformed version heading" do
275
+ changelog.call("## 1.0.0\n").problems.first.should.match(/malformed version heading/)
276
+ end
277
+
278
+ it "rejects an undated or badly dated release" do
279
+ changelog.call("## [1.0.0]\n\n### Added\n\n- x\n").problems.first.should.match(/has no date/)
280
+ changelog.call("## [1.0.0] - 20260101\n\n### Added\n\n- x\n").problems.first.should.match(/malformed date/)
281
+ end
282
+
283
+ it "rejects an unknown section type" do
284
+ changelog.call("## [1.0.0] - 2026-01-01\n\n### Improved\n\n- x\n")
285
+ .problems.first.should.match(/unknown section "Improved"/)
286
+ end
287
+
288
+ it "rejects an empty release and an empty subsection" do
289
+ changelog.call("## [1.0.0] - 2026-01-01\n").problems.first.should.match(/has no Added/)
290
+ changelog.call("## [1.0.0] - 2026-01-01\n\n### Added\n\n## [0.9.0] - 2026-01-01\n\n### Added\n\n- x\n")
291
+ .problems.first.should.match(/empty `### Added` section/)
292
+ end
293
+
294
+ it "rejects duplicates and out-of-order releases" do
295
+ body = "## [1.0.0] - 2026-01-02\n\n### Added\n\n- x\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n- y\n"
296
+ changelog.call(body).problems.first.should.match(/duplicate section for 1\.0\.0/)
297
+
298
+ body = "## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n\n## [2.0.0] - 2026-01-02\n\n### Added\n\n- y\n"
299
+ changelog.call(body).problems.first.should.match(/must run newest to oldest/)
300
+ end
301
+
302
+ it "requires [Unreleased] to come first" do
303
+ body = "## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n\n## [Unreleased]\n"
304
+ changelog.call(body).problems.first.should.match(/\[Unreleased\] must be the first section/)
305
+ end
306
+
307
+ it "blocks a release with no section of its own" do
308
+ changelog.call(good).release_problems("9.9.9").first.should.match(/no section for 9\.9\.9/)
309
+ end
310
+
311
+ it "blocks a release that is not the newest section" do
312
+ changelog.call(good).release_problems("4.0.0").first.should.match(/lists 4\.1\.0 above 4\.0\.0/)
313
+ end
314
+
315
+ it "reports format problems ahead of release problems" do
316
+ changelog.call("## nonsense\n").release_problems("4.1.0").first.should.match(/malformed version heading/)
317
+ end
318
+
319
+ it "loads the repo's own CHANGELOG.md" do
320
+ Gem::Deprecate.skip_during { Brute::Changelog.load.missing?.should.be.false }
321
+ end
322
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ # Completion middlewares — the terminal step of an agent turn. Each one is a
5
+ # ready-made replacement for the hand-written `run` proc: it takes
6
+ # env[:messages], calls one provider, and appends the reply back onto the log.
7
+ #
8
+ # Brute.agent
9
+ # .use(Brute::Middleware::SystemPrompt)
10
+ # .run(Brute::Completion::OpenRouter.new(model: "anthropic/claude-sonnet-4"))
11
+ #
12
+ # Brute still owns no LLM library: each class here requires only the gem for
13
+ # its own provider, and only when you use it.
14
+ module Completion
15
+ class OpenRouter
16
+ # config: keyword arguments for OpenRouter::Client.new
17
+ # (access_token:, request_timeout:, uri_base:, extra_headers:).
18
+ # Defaults to OpenRouter.configuration's global settings.
19
+ # options: keyword arguments for OpenRouter::CompletionOptions.new
20
+ # (model:, temperature:, tools:, ...).
21
+ def initialize(app, config: {}, **options)
22
+ @app = app
23
+ @config = config
24
+ @options = ::OpenRouter::CompletionOptions.new(**options)
25
+ end
26
+
27
+ def call(env)
28
+ env[:hooks]&.emit(:before_llm, env)
29
+
30
+ messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
31
+
32
+ ::OpenRouter::Client.new(**@config).then do |client|
33
+ client.complete(messages, @options).then do |response|
34
+
35
+ # Expose the provider's usage for downstream accounting
36
+ # middleware (goal budgets, autonomous limits, compaction
37
+ # thresholds, usage attribution) — additive metadata only.
38
+ if response.respond_to?(:usage) && response.usage
39
+ (env[:metadata] ||= {})[:last_llm_usage] = response.usage
40
+ end
41
+
42
+ # OpenRouter in fact only returns a single message...
43
+ # https://github.com/estiens/open_router_enhanced/blob/main/lib/open_router/response.rb
44
+ Brute::MessageTransport::OpenRouter.wrap_each(response) do |message|
45
+ env[:messages] << message
46
+ end
47
+ end
48
+ end
49
+
50
+ env[:hooks]&.emit(:after_llm, env)
51
+ env
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ __END__
58
+
59
+ describe "brute/completion/open_router" do
60
+ require "brute/messages"
61
+
62
+ # The repo suite has no open_router gem; stub the two constants the
63
+ # middleware touches (the transport wraps duck-typed responses fine).
64
+ begin
65
+ require "open_router"
66
+ rescue LoadError
67
+ module OpenRouter
68
+ CompletionOptions = Class.new { def initialize(**_opts); end }
69
+ Client = Class.new
70
+ end
71
+ end
72
+
73
+ FakeUsageResponse = Struct.new(:usage) do
74
+ def choices
75
+ [{ "message" => { "role" => "assistant", "content" => "hello" } }]
76
+ end
77
+ end unless defined?(FakeUsageResponse)
78
+
79
+ # Run one turn against a stubbed OpenRouter::Client and hand back the env.
80
+ with_fake_client = lambda do |middleware, response|
81
+ fake_client = Object.new
82
+ fake_client.define_singleton_method(:complete) { |_messages, _options| response }
83
+ original = OpenRouter::Client.method(:new)
84
+ OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
85
+ begin
86
+ env = { messages: Brute.log }
87
+ env[:messages].user("hi")
88
+ middleware.call(env)
89
+ env
90
+ ensure
91
+ OpenRouter::Client.define_singleton_method(:new, original)
92
+ end
93
+ end
94
+
95
+ it "records the provider usage into env metadata and appends the message" do
96
+ response = FakeUsageResponse.new({ "prompt_tokens" => 10, "completion_tokens" => 5, "total_tokens" => 15 })
97
+ env = with_fake_client.call(Brute::Completion::OpenRouter.new(->(e) { e }), response)
98
+
99
+ env[:messages].last.role.should == :assistant
100
+ env[:metadata][:last_llm_usage]["total_tokens"].should == 15
101
+ end
102
+
103
+ it "leaves metadata alone when the response has no usage" do
104
+ env = with_fake_client.call(Brute::Completion::OpenRouter.new(->(e) { e }), FakeUsageResponse.new(nil))
105
+
106
+ env.key?(:metadata).should.be.false
107
+ end
108
+
109
+ it "still answers to the deprecated Middleware::OpenRouter::Completion name" do
110
+ deprecated = Brute::Middleware::OpenRouter::Completion.new(->(e) { e })
111
+ deprecated.should.be.kind_of?(Brute::Completion::OpenRouter)
112
+
113
+ env = with_fake_client.call(deprecated, FakeUsageResponse.new(nil))
114
+ env[:messages].last.content.should == "hello"
115
+ end
116
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "file/tail"
4
+ require "fileutils"
5
+
6
+ module Brute
7
+ module Contrib
8
+ # A line-oriented, append-only log file that doubles as a work queue.
9
+ #
10
+ # Every entry is exactly one line — newlines in the input are folded to
11
+ # spaces on the way in — so a line is the unit of both storage and
12
+ # retrieval. Reads are destructive: `pop` takes the newest line off the
13
+ # end, `drain` yields every line oldest-first and empties the file.
14
+ #
15
+ # log = Brute::Contrib::LogFile.new("tmp/queue.log")
16
+ # log.append("something happened")
17
+ # log.pop # => "something happened"
18
+ # log.drain { |line| handle(line) }
19
+ #
20
+ # Safe across both threads (a mutex) and processes (an exclusive flock),
21
+ # so several agents can share one file without losing lines.
22
+ class LogFile < File
23
+ include File::Tail
24
+
25
+ def initialize(path)
26
+ FileUtils.mkdir_p(File.dirname(path))
27
+ super(path, File::RDWR | File::CREAT | File::APPEND)
28
+ @mutex = Mutex.new
29
+ end
30
+
31
+ # Append one line. Blank (or whitespace-only) input is a no-op and
32
+ # returns nil; otherwise returns the stripped line that was written.
33
+ def append(line)
34
+ strip_whitespace(line).then do |stripped_text|
35
+ unless stripped_text.empty?
36
+ locked do
37
+ puts(stripped_text)
38
+ flush
39
+ stripped_text
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ # Remove and return the newest line, or nil when the file is empty.
46
+ def pop
47
+ locked do
48
+ backward(1)
49
+ offset = tell
50
+ gets&.chomp.tap { truncate(offset) }
51
+ end
52
+ end
53
+
54
+ # Yield every line oldest-first, then empty the file. Requires a block —
55
+ # without one there is nowhere for the lines to go, so it raises rather
56
+ # than discarding them.
57
+ def drain
58
+ locked do
59
+ if block_given?
60
+ backward(line_count)
61
+ each_line { |x| yield x.chomp }
62
+ truncate(0)
63
+ else
64
+ raise "No block given..."
65
+ end
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def line_count
72
+ rewind
73
+ each_line.count
74
+ end
75
+
76
+ def strip_whitespace(line)
77
+ line.to_s.gsub(/\r?\n/, " ").strip
78
+ end
79
+
80
+ def locked
81
+ @mutex.synchronize do
82
+ flock(File::LOCK_EX)
83
+ yield
84
+ ensure
85
+ flock(File::LOCK_UN)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ __END__
93
+
94
+ require "tmpdir"
95
+
96
+ describe "brute/contrib/log_file" do
97
+ it "pops the last line, then nothing" do
98
+ Dir.mktmpdir do |dir|
99
+ log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
100
+ log.append("first")
101
+ log.append("second")
102
+
103
+ log.pop.should == "second"
104
+ log.pop.should == "first"
105
+ log.pop.should.be.nil
106
+ end
107
+ end
108
+
109
+ it "keeps one entry on one line" do
110
+ Dir.mktmpdir do |dir|
111
+ log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
112
+ log.append("two\nlines")
113
+ log.append(" ")
114
+
115
+ log.pop.should == "two lines"
116
+ log.pop.should.be.nil
117
+ end
118
+ end
119
+
120
+ it "loses no line to concurrent threads" do
121
+ Dir.mktmpdir do |dir|
122
+ log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
123
+ 4.times.map { |i| Thread.new { 50.times { |j| log.append("p#{i}-#{j}") } } }.each(&:join)
124
+
125
+ popped = []
126
+ 4.times.map { Thread.new { while (line = log.pop) do popped << line end } }.each(&:join)
127
+
128
+ popped.uniq.size.should == 200
129
+ File.size(log.path).should == 0
130
+ end
131
+ end
132
+
133
+ it "drains every line, oldest first, and empties the file" do
134
+ Dir.mktmpdir do |dir|
135
+ log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
136
+ log.append("first")
137
+ log.append("second")
138
+ log.append("third")
139
+
140
+ drained = []
141
+ log.drain { |line| drained << line }
142
+
143
+ drained.should == ["first", "second", "third"]
144
+ File.size(log.path).should == 0
145
+ log.pop.should.be.nil
146
+ end
147
+ end
148
+
149
+ it "refuses to drain without a block, leaving the lines alone" do
150
+ Dir.mktmpdir do |dir|
151
+ log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
152
+ log.append("first")
153
+ log.append("second")
154
+
155
+ lambda { log.drain }.should.raise(RuntimeError)
156
+
157
+ File.read(log.path).should == "first\nsecond\n"
158
+ log.pop.should == "second"
159
+ end
160
+ end
161
+
162
+ it "keeps appending after a drain" do
163
+ Dir.mktmpdir do |dir|
164
+ log = Brute::Contrib::LogFile.new(File.join(dir, "log"))
165
+ log.append("before")
166
+ log.drain { |line| line }
167
+ log.append("after")
168
+
169
+ log.pop.should == "after"
170
+ log.pop.should.be.nil
171
+ end
172
+ end
173
+ end