Filled out the project, added Ezmlm module + spec.
authorMichael Granger <mgranger@laika.com>
Wed, 07 May 2008 18:22:04 +0000
changeset 1 1d3cfd4837a8
parent 0 1b096869b568
child 2 7b5a0131d5cd
Filled out the project, added Ezmlm module + spec.
LICENSE
README
Rakefile
lib/ezmlm.rb
lib/ezmlm/list.rb
rake/helpers.rb
rake/packaging.rb
rake/rdoc.rb
rake/style.rb
rake/svn.rb
rake/testing.rb
rake/verifytask.rb
spec/ezmlm/list_spec.rb
spec/ezmlm_spec.rb
spec/lib/helpers.rb
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/LICENSE	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,29 @@
+Copyright (c) 2008, LAIKA, Inc.
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are
+permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright notice, this
+      list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above copyright notice, this
+      list of conditions and the following disclaimer in the documentation and/or
+      other materials provided with the distribution.
+
+    * Neither the name of LAIKA, nor the names of its contributors may be used to
+      endorse or promote products derived from this software without specific prior
+      written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/README	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,45 @@
+= ThingFish
+
+== Authors
+
+* Michael Granger <mgranger@laika.com>
+* Jeremiah Jordan <jjordan@laika.com>
+
+== Installation
+
+=== Requirements
+
+Ruby-Ezmlm is tested using Ruby 1.8.6:
+
+  * Ruby (>= 1.8.6): http://www.ruby-lang.org/en/downloads/
+
+Other versions may work, but are not tested.
+
+=== Ruby Modules
+
+There are two ways you can install Ruby-Ezmlm. The easiest is to use RubyGems:
+
+  $ sudo gem install -y ruby-ezmlm
+
+If you'd rather install from source, you'll need these:
+
+  * Tmail (>= 1.2.3.1): http://tmail.rubyforge.org/
+
+Once these are installed:
+
+  $ tar -zxvf ezmlm-0.0.1.tgz
+  $ cd thingfish-0.0.1
+  $ su -
+  # rake install
+
+If you want to help out with development, run tests, or generate documentation, 
+you'll need a few more things:
+
+  * RSpec (>= 1.0.5): http://rspec.rubyforge.org/
+  * rcov (>= 0.7.0): http://eigenclass.org/hiki.rb?rcov
+
+If you have RubyGems installed, you can install these automatically via the 
++install_dependencies+ task of the Rakefile:
+
+  $ sudo rake install_dependencies
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Rakefile	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,146 @@
+#!rake -*- ruby -*-
+#
+# Ruby-Ezmlm rakefile
+#
+# Based on Ben Bleything's Rakefile for Linen (URL?)
+#
+# Copyright (c) 2007 LAIKA, Inc.
+#
+# Mistakes:
+#  * Michael Granger <mgranger@laika.com>
+#
+
+BEGIN {
+	require 'pathname'
+	basedir = Pathname.new( __FILE__ ).dirname
+	libdir = basedir + 'lib'
+
+	$LOAD_PATH.unshift( libdir.to_s ) unless $LOAD_PATH.include?( libdir.to_s )
+}
+
+
+require 'rubygems'
+
+require 'rake'
+require 'tmpdir'
+require 'pathname'
+
+$dryrun = false
+
+# Pathname constants
+BASEDIR       = Pathname.new( __FILE__ ).expand_path.dirname.relative_path_from( Pathname.getwd )
+BINDIR        = BASEDIR + 'bin'
+LIBDIR        = BASEDIR + 'lib'
+DOCSDIR       = BASEDIR + 'docs'
+VARDIR        = BASEDIR + 'var'
+WWWDIR        = VARDIR  + 'www'
+MANUALDIR     = DOCSDIR + 'manual'
+RDOCDIR       = DOCSDIR + 'rdoc'
+STATICWWWDIR  = WWWDIR  + 'static'
+PKGDIR        = BASEDIR + 'pkg'
+ARTIFACTS_DIR = Pathname.new( ENV['CC_BUILD_ARTIFACTS'] || '' )
+RAKE_TASKDIR  = BASEDIR + 'rake'
+
+TEXT_FILES    = %w( Rakefile README LICENSE ).
+	collect {|filename| BASEDIR + filename }
+
+SPECDIR       = BASEDIR + 'spec'
+SPEC_FILES    = Pathname.glob( SPECDIR + '**/*_spec.rb' ).
+	delete_if {|item| item =~ /\.svn/ }
+# Ideally, this should be automatically generated.
+SPEC_EXCLUDES = 'spec,monkeypatches,/Library/Ruby,/var/lib,/usr/local/lib'
+
+BIN_FILES     = Pathname.glob( BINDIR + '*').
+	delete_if {|item| item =~ /\.svn/ }
+LIB_FILES     = Pathname.glob( LIBDIR + '**/*.rb').
+	delete_if {|item| item =~ /\.svn/ }
+
+RELEASE_FILES = BIN_FILES + TEXT_FILES + LIB_FILES + SPEC_FILES
+
+require RAKE_TASKDIR + 'helpers.rb'
+
+### Package constants
+PKG_NAME      = 'ruby-ezmlm'
+PKG_VERSION_FROM = LIBDIR + 'ezmlm.rb'
+PKG_VERSION   = find_pattern_in_file( /VERSION = '(\d+\.\d+\.\d+)'/, PKG_VERSION_FROM ).first
+PKG_FILE_NAME = "#{PKG_NAME}-#{PKG_VERSION}"
+
+RELEASE_NAME  = "REL #{PKG_VERSION}"
+
+require RAKE_TASKDIR + 'svn.rb'
+require RAKE_TASKDIR + 'verifytask.rb'
+
+if Rake.application.options.trace
+	$trace = true
+	log "$trace is enabled"
+end
+
+if Rake.application.options.dryrun
+	$dryrun = true
+	log "$dryrun is enabled"
+	Rake.application.options.dryrun = false
+end
+
+### Project Gemspec
+GEMSPEC = Gem::Specification.new do |gem|
+	pkg_build = get_svn_rev( BASEDIR ) || 0
+	
+	gem.name    	= PKG_NAME
+	gem.version 	= "%s.%s" % [ PKG_VERSION, pkg_build ]
+
+	gem.summary     = "A Ruby programmatic interface to ezmlm-idx"
+	gem.description = "Ruby-Ezmlm is a Ruby programmatic interface to ezmlm-idx " +
+		"mailing lists, message archives, and command-line tools."
+
+	gem.authors  	= "Michael Granger, Jeremiah Jordan"
+	gem.email  		= "opensource@laika.com"
+	gem.homepage 	= "http://opensource.laika.com/wiki/ruby-ezmlm"
+
+	gem.rubyforge_project = 'laika'
+
+	gem.has_rdoc 	= true
+
+	gem.files      	= RELEASE_FILES.
+		collect {|f| f.relative_path_from(BASEDIR).to_s }
+	gem.test_files 	= SPEC_FILES.
+		collect {|f| f.relative_path_from(BASEDIR).to_s }
+
+  	gem.add_dependency( 'tmail', '>= 1.2.3.1' )
+end
+
+
+# Load task plugins
+Pathname.glob( RAKE_TASKDIR + '*.rb' ).each do |tasklib|
+	trace "Loading task lib #{tasklib}"
+	require tasklib
+end
+
+
+### Default task
+task :default  => [:clean, :spec, 'coverage:verify', :package]
+
+
+### Task: clean
+desc "Clean pkg, coverage, and rdoc; remove .bak files"
+task :clean => [ :clobber_rdoc, :clobber_package, :clobber_coverage ] do
+	files = FileList['**/*.bak']
+	files.clear_exclude
+	File.rm( files ) unless files.empty?
+	FileUtils.rm_rf( 'artifacts' )
+end
+
+
+### Cruisecontrol task
+desc "Cruisecontrol build"
+task :cruise => [:clean, :coverage, :package] do |task|
+	raise "Artifacts dir not set." if ARTIFACTS_DIR.to_s.empty?
+	artifact_dir = ARTIFACTS_DIR.cleanpath
+	artifact_dir.mkpath
+	
+	$stderr.puts "Copying coverage stats..."
+	FileUtils.cp_r( 'coverage', artifact_dir )
+	
+	$stderr.puts "Copying packages..."
+	FileUtils.cp_r( FileList['pkg/*'].to_a, artifact_dir )
+end
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/ezmlm.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,61 @@
+#!/usr/bin/ruby
+#
+# A Ruby programmatic interface to the ezmlm-idx mailing list system
+#
+# == Version
+#
+#  $Id$
+#
+# == Authors
+#
+# * Michael Granger <mgranger@laika.com>
+# * Jeremiah Jordan <jjordan@laika.com>
+#
+# :include: LICENSE
+# 
+#---
+#
+# Please see the file LICENSE in the base directory for licensing details.
+#
+
+require 'pathname'
+
+
+### Toplevel namespace module
+module Ezmlm
+
+	# SVN Revision
+	SVNRev = %q$Rev$
+
+	# SVN Id
+	SVNId = %q$Id$
+
+	# Package version
+	VERSION = '0.0.1'
+
+
+	require 'ezmlm/list'
+	
+	
+
+	###############
+	module_function
+	###############
+
+
+	### Iterate over each directory that looks like an Ezmlm list in the specified +listsdir+ and
+	### yield it as an Ezmlm::List object.
+	def each_list( listsdir )
+		listsdir = Pathname.new( listsdir )
+		Pathname.glob( listsdir + '*' ) do |entry|
+			next unless entry.directory?
+			next unless ( entry + 'mailinglist' ).exist?
+
+			yield( Ezmlm::List.new(entry) )
+		end
+	end
+	
+
+end # module Ezmlm
+
+# vim: set nosta noet ts=4 sw=4:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/ezmlm/list.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,30 @@
+#!/usr/bin/ruby
+#
+# A Ruby interface to a single Ezmlm-idx mailing list directory
+#
+# == Version
+#
+#  $Id$
+#
+# == Authors
+#
+# * Michael Granger <mgranger@laika.com>
+# * Jeremiah Jordan <jjordan@laika.com>
+#
+# :include: LICENSE
+# 
+#---
+#
+# Please see the file LICENSE in the base directory for licensing details.
+#
+
+require 'pathname'
+require 'ezmlm'
+
+
+### A Ruby interface to an ezmlm-idx mailing list directory
+class Ezmlm::List
+	
+end
+
+# vim: set nosta noet ts=4 sw=4:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rake/helpers.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,353 @@
+#####################################################################
+###	G L O B A L   H E L P E R   F U N C T I O N S
+#####################################################################
+
+require 'pathname'
+require 'readline'
+require 'open3'
+
+# Set some ANSI escape code constants (Shamelessly stolen from Perl's
+# Term::ANSIColor by Russ Allbery <rra@stanford.edu> and Zenin <zenin@best.com>
+ANSI_ATTRIBUTES = {
+	'clear'      => 0,
+	'reset'      => 0,
+	'bold'       => 1,
+	'dark'       => 2,
+	'underline'  => 4,
+	'underscore' => 4,
+	'blink'      => 5,
+	'reverse'    => 7,
+	'concealed'  => 8,
+
+	'black'      => 30,   'on_black'   => 40, 
+	'red'        => 31,   'on_red'     => 41, 
+	'green'      => 32,   'on_green'   => 42, 
+	'yellow'     => 33,   'on_yellow'  => 43, 
+	'blue'       => 34,   'on_blue'    => 44, 
+	'magenta'    => 35,   'on_magenta' => 45, 
+	'cyan'       => 36,   'on_cyan'    => 46, 
+	'white'      => 37,   'on_white'   => 47
+}
+
+
+CLEAR_TO_EOL       = "\e[K"
+CLEAR_CURRENT_LINE = "\e[2K"
+
+
+### Output a logging message
+def log( *msg )
+	output = colorize( msg.flatten.join(' '), 'cyan' )
+	$deferr.puts( output )
+end
+
+
+### Output a logging message if tracing is on
+def trace( *msg )
+	return unless $trace
+	output = colorize( msg.flatten.join(' '), 'yellow' )
+	$deferr.puts( output )
+end
+
+
+### Run the specified command +cmd+ with system(), failing if the execution
+### fails.
+def run( *cmd )
+	cmd.flatten!
+
+	log( cmd.collect {|part| part =~ /\s/ ? part.inspect : part} ) 
+	if $dryrun
+		$deferr.puts "(dry run mode)"
+	else
+		system( *cmd )
+		unless $?.success?
+			fail "Command failed: [%s]" % [cmd.join(' ')]
+		end
+	end
+end
+
+
+### Open a pipe to a process running the given +cmd+ and call the given block with it.
+def pipeto( *cmd )
+	$DEBUG = true
+
+	cmd.flatten!
+	log( "Opening a pipe to: ", cmd.collect {|part| part =~ /\s/ ? part.inspect : part} ) 
+	if $dryrun
+		$deferr.puts "(dry run mode)"
+	else
+		open( '|-', 'w+' ) do |io|
+		
+			# Parent
+			if io
+				yield( io )
+
+			# Child
+			else
+				exec( *cmd )
+				fail "Command failed: [%s]" % [cmd.join(' ')]
+			end
+		end
+	end
+end
+
+
+### Download the file at +sourceuri+ via HTTP and write it to +targetfile+.
+def download( sourceuri, targetfile )
+	oldsync = $defout.sync
+	$defout.sync = true
+	require 'net/http'
+	require 'uri'
+
+	targetpath = Pathname.new( targetfile )
+
+	log "Downloading %s to %s" % [sourceuri, targetfile]
+	targetpath.open( File::WRONLY|File::TRUNC|File::CREAT, 0644 ) do |ofh|
+	
+		url = URI.parse( sourceuri )
+		downloaded = false
+		limit = 5
+		
+		until downloaded or limit.zero?
+			Net::HTTP.start( url.host, url.port ) do |http|
+				req = Net::HTTP::Get.new( url.path )
+
+				http.request( req ) do |res|
+					if res.is_a?( Net::HTTPSuccess )
+						log "Downloading..."
+						res.read_body do |buf|
+							ofh.print( buf )
+						end
+						downloaded = true
+						puts "done."
+		
+					elsif res.is_a?( Net::HTTPRedirection )
+						url = URI.parse( res['location'] )
+						log "...following redirection to: %s" % [ url ]
+						limit -= 1
+						sleep 0.2
+						next
+				
+					else
+						res.error!
+					end
+				end
+			end
+		end
+	end
+	
+	return targetpath
+ensure
+	$defout.sync = oldsync
+end
+
+
+### Return the fully-qualified path to the specified +program+ in the PATH.
+def which( program )
+	ENV['PATH'].split(/:/).
+		collect {|dir| Pathname.new(dir) + program }.
+		find {|path| path.exist? && path.executable? }
+end
+
+
+### Create a string that contains the ANSI codes specified and return it
+def ansi_code( *attributes )
+	attributes.flatten!
+	attributes.collect! {|at| at.to_s }
+	# $deferr.puts "Returning ansicode for TERM = %p: %p" %
+	# 	[ ENV['TERM'], attributes ]
+	return '' unless /(?:vt10[03]|xterm(?:-color)?|linux|screen)/i =~ ENV['TERM']
+	attributes = ANSI_ATTRIBUTES.values_at( *attributes ).compact.join(';')
+
+	# $deferr.puts "  attr is: %p" % [attributes]
+	if attributes.empty? 
+		return ''
+	else
+		return "\e[%sm" % attributes
+	end
+end
+
+
+### Colorize the given +string+ with the specified +attributes+ and return it, handling 
+### line-endings, color reset, etc.
+def colorize( *args )
+	string = ''
+	
+	if block_given?
+		string = yield
+	else
+		string = args.shift
+	end
+	
+	ending = string[/(\s)$/] || ''
+	string = string.rstrip
+
+	return ansi_code( args.flatten ) + string + ansi_code( 'reset' ) + ending
+end
+
+
+### Output the specified <tt>msg</tt> as an ANSI-colored error message
+### (white on red).
+def error_message( msg, details='' )
+	$deferr.puts colorize( 'bold', 'white', 'on_red' ) { msg } + details
+end
+alias :error :error_message
+
+
+### Highlight and embed a prompt control character in the given +string+ and return it.
+def make_prompt_string( string )
+	return CLEAR_CURRENT_LINE + colorize( 'bold', 'green' ) { string + ' ' }
+end
+
+
+### Output the specified <tt>prompt_string</tt> as a prompt (in green) and
+### return the user's input with leading and trailing spaces removed.  If a
+### test is provided, the prompt will repeat until the test returns true.
+### An optional failure message can also be passed in.
+def prompt( prompt_string, failure_msg="Try again." ) # :yields: response
+	prompt_string.chomp!
+	prompt_string << ":" unless /\W$/.match( prompt_string )
+	response = nil
+
+	begin
+		prompt = make_prompt_string( prompt_string )
+		response = Readline.readline( prompt ) || ''
+		response.strip!
+		if block_given? && ! yield( response ) 
+			error_message( failure_msg + "\n\n" )
+			response = nil
+		end
+	end while response.nil?
+
+	return response
+end
+
+
+### Prompt the user with the given <tt>prompt_string</tt> via #prompt,
+### substituting the given <tt>default</tt> if the user doesn't input
+### anything.  If a test is provided, the prompt will repeat until the test
+### returns true.  An optional failure message can also be passed in.
+def prompt_with_default( prompt_string, default, failure_msg="Try again." )
+	response = nil
+
+	begin
+		response = prompt( "%s [%s]" % [ prompt_string, default ] )
+		response = default if response.empty?
+
+		if block_given? && ! yield( response ) 
+			error_message( failure_msg + "\n\n" )
+			response = nil
+		end
+	end while response.nil?
+
+	return response
+end
+
+
+### Display a description of a potentially-dangerous task, and prompt
+### for confirmation. If the user answers with anything that begins
+### with 'y', yield to the block, else raise with an error.
+def ask_for_confirmation( description )
+	puts description
+
+	answer = prompt_with_default( "Continue?", 'n' ) do |input|
+		input =~ /^[yn]/i
+	end
+
+	case answer
+	when /^y/i
+		yield
+	else
+		error "Aborted."
+		fail
+	end
+end
+
+
+### Search line-by-line in the specified +file+ for the given +regexp+, returning the
+### first match, or nil if no match was found. If the +regexp+ has any capture groups,
+### those will be returned in an Array, else the whole matching line is returned.
+def find_pattern_in_file( regexp, file )
+	rval = nil
+	
+	File.open( file, 'r' ).each do |line|
+		if (( match = regexp.match(line) ))
+			rval = match.captures.empty? ? match[0] : match.captures
+			break
+		end
+	end
+
+	return rval
+end
+
+
+### Search line-by-line in the output of the specified +cmd+ for the given +regexp+,
+### returning the first match, or nil if no match was found. If the +regexp+ has any 
+### capture groups, those will be returned in an Array, else the whole matching line
+### is returned.
+def find_pattern_in_pipe( regexp, *cmd )
+	output = []
+	
+	Open3.popen3( *cmd ) do |stdin, stdout, stderr|
+		stdin.close
+
+		output << stdout.gets until stdout.eof?
+		output << stderr.gets until stderr.eof?
+	end
+	
+	result = output.find { |line| regexp.match(line) } 
+	return $1 || result
+end
+
+
+### Extract all the non Rake-target arguments from ARGV and return them.
+def get_target_args
+	args = ARGV.reject {|arg| Rake::Task.task_defined?(arg) }
+	return args
+end
+
+
+
+require 'rubygems/dependency_installer'
+require 'rubygems/source_index'
+require 'rubygems/requirement'
+require 'rubygems/doc_manager'
+
+### Install the specified +gems+ if they aren't already installed.
+def install_gems( *gems )
+	gems.flatten!
+	
+	defaults = Gem::DependencyInstaller::DEFAULT_OPTIONS.merge({
+		:generate_rdoc     => true,
+		:generate_ri       => true,
+		:install_dir       => Gem.dir,
+		:format_executable => false,
+		:test              => false,
+		:version           => Gem::Requirement.default,
+	  })
+    
+	# Check for root
+	if Process.euid != 0
+		$stderr.puts "This probably won't work, as you aren't root, but I'll try anyway"
+	end
+
+	gemindex = Gem::SourceIndex.from_installed_gems
+
+	gems.each do |gemname|
+		if (( specs = gemindex.search(gemname) )) && ! specs.empty?
+			log "Version %s of %s is already installed; skipping..." % 
+				[ specs.first.version, specs.first.name ]
+			next
+		end
+
+		log "Trying to install #{gemname.inspect}..."
+		installer = Gem::DependencyInstaller.new
+		installer.install( gemname )
+
+		installer.installed_gems.each do |spec|
+			log "Installed: %s" % [ spec.full_name ]
+		end
+
+	end
+end
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rake/packaging.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,32 @@
+# 
+# Packaging Rake Tasks
+# 
+# 
+
+require 'rake/packagetask'
+require 'rake/gempackagetask'
+
+Rake::GemPackageTask.new( GEMSPEC ) do |task|
+	task.gem_spec = GEMSPEC
+	task.need_tar = false
+	task.need_tar_gz = true
+	task.need_tar_bz2 = true
+	task.need_zip = true
+end
+
+
+### Task: install
+task :install_gem => [:package] do
+	$stderr.puts 
+	installer = Gem::Installer.new( %{pkg/#{PKG_FILE_NAME}.gem} )
+	installer.install
+end
+
+### Task: uninstall
+task :uninstall_gem => [:clean] do
+	uninstaller = Gem::Uninstaller.new( PKG_FILE_NAME )
+	uninstaller.uninstall
+end
+
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rake/rdoc.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,25 @@
+# 
+# RDoc Rake tasks
+# $Id$
+# 
+
+require 'rake/rdoctask'
+
+### Task: rdoc
+Rake::RDocTask.new do |rdoc|
+	rdoc.rdoc_dir = 'docs/api'
+	rdoc.title    = "%s -- %s" % [ GEMSPEC.name, GEMSPEC.summary ]
+
+	rdoc.options += [
+		'-w', '4',
+		'-SHN',
+		'-i', BASEDIR.to_s,
+		'-f', 'darkfish',
+		'-m', 'README',
+		'-W', 'http://opensource.laika.com/browser/ruby-ezmlm/trunk/'
+	  ]
+	
+	rdoc.rdoc_files.include 'README'
+	rdoc.rdoc_files.include LIB_FILES.collect {|f| f.relative_path_from(BASEDIR).to_s }
+end
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rake/style.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,59 @@
+# 
+# Style Fixup Rake Tasks
+# 
+# 
+# 
+
+### Coding style checks and fixes
+namespace :style do
+	
+	BLANK_LINE = /^\s*$/
+	GOOD_INDENT = /^(\t\s*)?\S/
+
+	# A list of the files that have legitimate leading whitespace, etc.
+	PROBLEM_FILES = [ SPECDIR + 'config_spec.rb' ]
+	
+	desc "Check source files for inconsistent indent and fix them"
+	task :fix_indent do
+		files = LIB_FILES + SPEC_FILES
+
+		badfiles = Hash.new {|h,k| h[k] = [] }
+		
+		trace "Checking files for indentation"
+		files.each do |file|
+			if PROBLEM_FILES.include?( file )
+				trace "  skipping problem file #{file}..."
+				next
+			end
+			
+			trace "  #{file}"
+			linecount = 0
+			file.each_line do |line|
+				linecount += 1
+				
+				# Skip blank lines
+				next if line =~ BLANK_LINE
+				
+				# If there's a line with incorrect indent, note it and skip to the 
+				# next file
+				if line !~ GOOD_INDENT
+					trace "    Bad line %d: %p" % [ linecount, line ]
+					badfiles[file] << [ linecount, line ]
+				end
+			end
+		end
+
+		if badfiles.empty?
+			log "No indentation problems found."
+		else
+			log "Found incorrect indent in #{badfiles.length} files:\n  "
+			badfiles.each do |file, badlines|
+				log "  #{file}:\n" +
+					"    " + badlines.collect {|badline| "%5d: %p" % badline }.join( "\n    " )
+			end
+		end
+	end
+
+end
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rake/svn.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,328 @@
+#####################################################################
+###	S U B V E R S I O N   T A S K S   A N D   H E L P E R S
+#####################################################################
+
+require 'pp'
+require 'yaml'
+require 'English'
+
+# Strftime format for tags/releases
+TAG_TIMESTAMP_FORMAT = '%Y%m%d-%H%M%S'
+TAG_TIMESTAMP_PATTERN = /\d{4}\d{2}\d{2}-\d{6}/
+
+RELEASE_VERSION_PATTERN = /\d+\.\d+\.\d+/
+
+DEFAULT_EDITOR = 'vi'
+DEFAULT_KEYWORDS = %w[Date Rev Author URL Id]
+KEYWORDED_FILEDIRS = %w[applets bin etc lib misc]
+KEYWORDED_FILEPATTERN = /^(?:Rakefile|.*\.(?:rb|js|html|template))$/i
+
+COMMIT_MSG_FILE = 'commit-msg.txt'
+
+###
+### Subversion-specific Helpers
+###
+
+### Return a new tag for the given time
+def make_new_tag( time=Time.now )
+	return time.strftime( TAG_TIMESTAMP_FORMAT )
+end
+
+
+### Get the subversion information for the current working directory as
+### a hash.
+def get_svn_info( dir='.' )
+	info = IO.read( '|-' ) or exec 'svn', 'info', dir
+	return YAML.load( info ) # 'svn info' outputs valid YAML! Yay!
+end
+
+
+### Get a list of the objects registered with subversion under the specified directory and
+### return them as an Array of Pathame objects.
+def get_svn_filelist( dir='.' )
+	list = IO.read( '|-' ) or exec 'svn', 'st', '-v', '--ignore-externals', dir
+
+	# Split into lines, filter out the unknowns, and grab the filenames as Pathnames
+	# :FIXME: This will break if we ever put in a file with spaces in its name. This
+	# will likely be the least of our worries if we do so, however, so it's not worth
+	# the additional complexity to make it handle that case. If we do need that, there's
+	# always the --xml output for 'svn st'...
+	return list.split( $/ ).
+		reject {|line| line =~ /^\?/ }.
+		collect {|fn| Pathname(fn[/\S+$/]) }
+end
+
+### Return the URL to the repository root for the specified +dir+.
+def get_svn_repo_root( dir='.' )
+	info = get_svn_info( dir )
+	return info['URL'].sub( %r{/trunk$}, '' )
+end
+
+
+### Return the Subversion URL to the given +dir+.
+def get_svn_url( dir='.' )
+	info = get_svn_info( dir )
+	return info['URL']
+end
+
+
+### Return the path of the specified +dir+ under the svn root of the 
+### checkout.
+def get_svn_path( dir='.' )
+	root = get_svn_repo_root( dir )
+	url = get_svn_url( dir )
+	
+	return url.sub( root + '/', '' )
+end
+
+
+### Return the keywords for the specified array of +files+ as a Hash keyed by filename.
+def get_svn_keyword_map( files )
+	cmd = ['svn', 'pg', 'svn:keywords', *files]
+
+	# trace "Executing: svn pg svn:keywords " + files.join(' ')
+	output = IO.read( '|-' ) or exec( 'svn', 'pg', 'svn:keywords', *files )
+	
+	kwmap = {}
+	output.split( "\n" ).each do |line|
+		next if line !~ /\s+-\s+/
+		path, keywords = line.split( /\s+-\s+/, 2 )
+		kwmap[ path ] = keywords.split
+	end
+	
+	return kwmap
+end
+
+
+### Return the latest revision number of the specified +dir+ as an Integer.
+def get_svn_rev( dir='.' )
+	info = get_svn_info( dir )
+	return info['Revision']
+end
+
+
+### Return a list of the entries at the specified Subversion url. If
+### no +url+ is specified, it will default to the list in the URL
+### corresponding to the current working directory.
+def svn_ls( url=nil )
+	url ||= get_svn_url()
+	list = IO.read( '|-' ) or exec 'svn', 'ls', url
+
+	trace 'svn ls of %s: %p' % [url, list] if $trace
+	
+	return [] if list.nil? || list.empty?
+	return list.split( $INPUT_RECORD_SEPARATOR )
+end
+
+
+### Return the URL of the latest timestamp in the tags directory.
+def get_latest_svn_timestamp_tag
+	rooturl = get_svn_repo_root()
+	tagsurl = rooturl + '/tags'
+	
+	tags = svn_ls( tagsurl ).grep( TAG_TIMESTAMP_PATTERN ).sort
+	return nil if tags.nil? || tags.empty?
+	return tagsurl + '/' + tags.last
+end
+
+
+### Get a subversion diff of the specified targets and return it. If no targets are
+### specified, the current directory will be diffed instead.
+def get_svn_diff( *targets )
+	targets << BASEDIR if targets.empty?
+	trace "Getting svn diff for targets: %p" % [targets]
+	log = IO.read( '|-' ) or exec 'svn', 'diff', *(targets.flatten)
+
+	return log
+end
+
+
+### Return the URL of the latest timestamp in the tags directory.
+def get_latest_release_tag
+	rooturl    = get_svn_repo_root()
+	releaseurl = rooturl + '/releases'
+	
+	tags = svn_ls( releaseurl ).grep( RELEASE_VERSION_PATTERN ).sort_by do |tag|
+		tag.split('.').collect {|i| Integer(i) }
+	end
+	return nil if tags.empty?
+
+	return releaseurl + '/' + tags.last
+end
+
+
+### Extract a diff from the specified subversion working +dir+, rewrite its
+### file lines as Trac links, and return it.
+def make_svn_commit_log( dir='.' )
+	editor_prog = ENV['EDITOR'] || ENV['VISUAL'] || DEFAULT_EDITOR
+	
+	diff = IO.read( '|-' ) or exec 'svn', 'diff'
+	fail "No differences." if diff.empty?
+
+	return diff
+end
+
+
+
+###
+### Tasks
+###
+
+desc "Subversion tasks"
+namespace :svn do
+
+	desc "Copy the HEAD revision of the current trunk/ to tags/ with a " +
+		 "current timestamp."
+	task :tag do
+		svninfo   = get_svn_info()
+		tag       = make_new_tag()
+		svntrunk  = svninfo['URL']
+		svntagdir = svninfo['URL'].sub( %r{trunk$}, 'tags' )
+		svntag    = svntagdir + '/' + tag
+
+		desc = "Tagging trunk as #{svntag}"
+		ask_for_confirmation( desc ) do
+			msg = prompt_with_default( "Commit log: ", "Tagging for code push" )
+			run 'svn', 'cp', '-m', msg, svntrunk, svntag
+		end
+	end
+
+
+	desc "Copy the most recent tag to releases/#{PKG_VERSION}"
+	task :release do
+		last_tag    = get_latest_svn_timestamp_tag()
+		svninfo     = get_svn_info()
+		release     = PKG_VERSION
+		svnrel      = svninfo['URL'] + '/releases'
+		svnrelease  = svnrel + '/' + release
+
+		if last_tag.nil?
+			error "There are no tags in the repository"
+			fail
+		end
+
+		releases = svn_ls( svnrel )
+		trace "Releases: %p" % [releases]
+		if releases.include?( release )
+			error "Version #{release} already has a branch (#{svnrelease}). Did you mean" +
+				"to increment the version in #{PKG_VERSION_FROM}?"
+			fail
+		else
+			trace "No #{svnrel} version currently exists"
+		end
+		
+		desc = "Release tag\n  #{last_tag}\nto\n  #{svnrelease}"
+		ask_for_confirmation( desc ) do
+			msg = prompt_with_default( "Commit log: ", "Branching for release" )
+			run 'svn', 'cp', '-m', msg, last_tag, svnrelease
+		end
+	end
+
+	### Task for debugging the #get_target_args helper
+	task :show_targets do
+		$stdout.puts "Targets from ARGV (%p): %p" % [ARGV, get_target_args()]
+	end
+
+
+	desc "Generate a commit log"
+	task :commitlog => [COMMIT_MSG_FILE]
+	
+	desc "Show the (pre-edited) commit log for the current directory"
+	task :show_commitlog => [COMMIT_MSG_FILE] do
+		ask_for_confirmation( "Confirm? " ) do
+			args = get_target_args()
+			puts get_svn_diff( *args )
+		end
+	end
+	
+
+	file COMMIT_MSG_FILE do
+		args = get_target_args()
+		diff = get_svn_diff( *args )
+		
+		File.open( COMMIT_MSG_FILE, File::WRONLY|File::EXCL|File::CREAT ) do |fh|
+			fh.print( diff )
+		end
+
+		editor = ENV['EDITOR'] || ENV['VISUAL'] || DEFAULT_EDITOR
+		system editor, COMMIT_MSG_FILE
+		unless $?.success?
+			fail "Editor exited uncleanly."
+		end
+	end
+
+
+	desc "Update from Subversion"
+	task :update do
+		run 'svn', 'up', '--ignore-externals'
+	end
+
+
+	desc "Check in all the changes in your current working copy"
+	task :checkin => ['svn:update', 'coverage:verify', 'svn:fix_keywords', COMMIT_MSG_FILE] do
+		targets = get_target_args()
+		$deferr.puts '---', File.read( COMMIT_MSG_FILE ), '---'
+		ask_for_confirmation( "Continue with checkin?" ) do
+			run 'svn', 'ci', '-F', COMMIT_MSG_FILE, targets
+			rm_f COMMIT_MSG_FILE
+		end
+	end
+	task :commit => :checkin
+	task :ci => :checkin
+		
+	
+	task :clean do
+		rm_f COMMIT_MSG_FILE
+	end
+
+
+	desc "Check and fix any missing keywords for any files in the project which need them"
+	task :fix_keywords do
+		log "Checking subversion keywords..."
+		paths = get_svn_filelist( BASEDIR ).
+			select {|path| path.file? && path.to_s =~ KEYWORDED_FILEPATTERN }
+
+		trace "Looking at %d paths for keywords:\n  %p" % [paths.length, paths]
+		kwmap = get_svn_keyword_map( paths )
+
+		buf = ''
+		PP.pp( kwmap, buf, 132 )
+		trace "keyword map is: %s" % [buf]
+		
+		files_needing_fixups = paths.find_all do |path|
+			(kwmap[path.to_s] & DEFAULT_KEYWORDS) != DEFAULT_KEYWORDS
+		end
+		
+		unless files_needing_fixups.empty?
+			$deferr.puts "Files needing keyword fixes: ",
+				files_needing_fixups.collect {|f|
+					"  %s: %s" % [f, kwmap[f] ? kwmap[f].join(' ') : "(no keywords)"]
+				}
+			ask_for_confirmation( "Will add default keywords to these files." ) do
+				run 'svn', 'ps', 'svn:keywords', DEFAULT_KEYWORDS.join(' '), *files_needing_fixups
+			end
+		else
+			log "Keywords are all up to date."
+		end
+	end
+
+	
+	task :debug_helpers do
+		methods = [
+			:make_new_tag,
+			:get_svn_info,
+			:get_svn_repo_root,
+			:get_svn_url,
+			:get_svn_path,
+			:svn_ls,
+			:get_latest_svn_timestamp_tag,
+		]
+		maxlen = methods.collect {|sym| sym.to_s.length }.max
+		
+		methods.each do |meth|
+			res = send( meth )
+			puts "%*s => %p" % [ maxlen, colorize(meth.to_s, :cyan), res ]
+		end
+	end
+end
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rake/testing.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,127 @@
+# 
+# Testing Rake Tasks
+# $Id$
+# 
+# 
+
+
+# Keep these tasks optional by handling LoadErrors with stub task
+# replacements.
+begin
+	gem 'rspec', '>= 1.1.1'
+	require 'spec/rake/spectask'
+
+	COMMON_SPEC_OPTS = ['-c', '-f', 's']
+
+	### Task: spec
+	Spec::Rake::SpecTask.new( :spec ) do |task|
+		task.spec_files = SPEC_FILES
+		task.libs += [LIBDIR]
+		task.spec_opts = COMMON_SPEC_OPTS
+	end
+	task :test => [:spec]
+
+
+	namespace :spec do
+		desc "Generate HTML output for a spec run"
+		Spec::Rake::SpecTask.new( :html ) do |task|
+			task.spec_files = SPEC_FILES
+			task.spec_opts = ['-f','h', '-D']
+		end
+
+		desc "Generate plain-text output for a CruiseControl.rb build"
+		Spec::Rake::SpecTask.new( :text ) do |task|
+			task.spec_files = SPEC_FILES
+			task.spec_opts = ['-f','p']
+		end
+	end
+rescue LoadError => err
+	task :no_rspec do
+		$stderr.puts "Testing tasks not defined: RSpec rake tasklib not available: %s" %
+			[ err.message ]
+	end
+	
+	task :spec => :no_rspec
+	namespace :spec do
+		task :autotest => :no_rspec
+		task :html => :no_rspec
+		task :text => :no_rspec
+	end
+end
+
+
+
+### RCov (via RSpec) tasks
+begin
+	gem 'rcov'
+	gem 'rspec', '>= 1.1.1'
+
+	COVERAGE_TARGETDIR = STATICWWWDIR + 'coverage'
+
+	RCOV_OPTS = ['--exclude', SPEC_EXCLUDES, '--xrefs', '--save']
+
+	### Task: coverage (via RCov)
+	### Task: spec
+	desc "Build test coverage reports"
+	Spec::Rake::SpecTask.new( :coverage ) do |task|
+		task.spec_files = SPEC_FILES
+		task.libs += [LIBDIR]
+		task.spec_opts = ['-f', 'p', '-b']
+		task.rcov_opts = RCOV_OPTS
+		task.rcov = true
+	end
+
+
+	task :rcov => [:coverage] do; end
+	
+	### Other coverage tasks
+	namespace :coverage do
+		desc "Generate a detailed text coverage report"
+		Spec::Rake::SpecTask.new( :text ) do |task|
+			task.spec_files = SPEC_FILES
+			task.rcov_opts = RCOV_OPTS + ['--text-report']
+			task.rcov = true
+		end
+
+		desc "Show differences in coverage from last run"
+		Spec::Rake::SpecTask.new( :diff ) do |task|
+			task.spec_files = SPEC_FILES
+			task.rcov_opts = ['--text-coverage-diff']
+			task.rcov = true
+		end
+
+		### Task: verify coverage
+		desc "Build coverage statistics"
+		VerifyTask.new( :verify => :rcov ) do |task|
+			task.threshold = 85.0
+		end
+		
+		desc "Run RCov in 'spec-only' mode to check coverage from specs"
+		Spec::Rake::SpecTask.new( :speconly ) do |task|
+			task.spec_files = SPEC_FILES
+			task.rcov_opts = ['--exclude', SPEC_EXCLUDES, '--text-report', '--save']
+			task.rcov = true
+		end
+	end
+
+	task :clobber_coverage do
+		rmtree( COVERAGE_TARGETDIR )
+	end
+
+rescue LoadError => err
+	task :no_rcov do
+		$stderr.puts "Coverage tasks not defined: RSpec+RCov tasklib not available: %s" %
+			[ err.message ]
+	end
+
+	task :coverage => :no_rcov
+	task :clobber_coverage
+	task :rcov => :no_rcov
+	namespace :coverage do
+		task :text => :no_rcov
+		task :diff => :no_rcov
+	end
+	task :verify => :no_rcov
+end
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rake/verifytask.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,64 @@
+#####################################################################
+###	S U B V E R S I O N   T A S K S   A N D   H E L P E R S
+#####################################################################
+
+require 'rake/tasklib'
+
+#
+# Work around the inexplicable behaviour of the original RDoc::VerifyTask, which 
+# errors if your coverage isn't *exactly* the threshold.
+# 
+
+# A task that can verify that the RCov coverage doesn't
+# drop below a certain threshold. It should be run after
+# running Spec::Rake::SpecTask.
+class VerifyTask < Rake::TaskLib
+	
+	COVERAGE_PERCENTAGE_PATTERN = 
+		%r{<tt class='coverage_code'>(\d+\.\d+)%</tt>}
+	
+	# Name of the task. Defaults to :verify_rcov
+	attr_accessor :name
+
+	# Path to the index.html file generated by RCov, which
+	# is the file containing the total coverage.
+	# Defaults to 'coverage/index.html'
+	attr_accessor :index_html
+
+	# Whether or not to output details. Defaults to true.
+	attr_accessor :verbose
+
+	# The threshold value (in percent) for coverage. If the 
+	# actual coverage is not equal to this value, the task will raise an 
+	# exception. 
+	attr_accessor :threshold
+
+	def initialize( name=:verify )
+		@name = name
+		@index_html = 'coverage/index.html'
+		@verbose = true
+		yield self if block_given?
+		raise "Threshold must be set" if @threshold.nil?
+		define
+	end
+
+	def define
+		desc "Verify that rcov coverage is at least #{threshold}%"
+
+		task @name do
+			total_coverage = nil
+			if match = File.read( index_html ).match( COVERAGE_PERCENTAGE_PATTERN )
+				total_coverage = Float( match[1] )
+			else
+				raise "Couldn't find the coverage percentage in #{index_html}"
+			end
+
+			puts "Coverage: #{total_coverage}% (threshold: #{threshold}%)" if verbose
+			if total_coverage < threshold
+				raise "Coverage must be at least #{threshold}% but was #{total_coverage}%"
+			end
+		end
+	end
+end
+
+# vim: set nosta noet ts=4 sw=4:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/ezmlm/list_spec.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,33 @@
+#!/usr/bin/env ruby
+
+BEGIN {
+	require 'pathname'
+	basedir = Pathname.new( __FILE__ ).dirname.parent.parent
+	
+	libdir = basedir + "lib"
+	
+	$LOAD_PATH.unshift( libdir ) unless $LOAD_PATH.include?( libdir )
+}
+
+begin
+	require 'spec/runner'
+	require 'spec/lib/helpers'
+	require 'ezmlm/list'
+rescue LoadError
+	unless Object.const_defined?( :Gem )
+		require 'rubygems'
+		retry
+	end
+	raise
+end
+
+
+describe Ezmlm::List do
+	include Ezmlm::SpecHelpers
+
+
+	it "is well-tested"
+	
+end
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/ezmlm_spec.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,59 @@
+#!/usr/bin/env ruby
+
+BEGIN {
+	require 'pathname'
+	basedir = Pathname.new( __FILE__ ).dirname.parent
+	
+	libdir = basedir + "lib"
+	
+	$LOAD_PATH.unshift( libdir ) unless $LOAD_PATH.include?( libdir )
+}
+
+begin
+	require 'spec/runner'
+	require 'spec/lib/helpers'
+	require 'ezmlm'
+rescue LoadError
+	unless Object.const_defined?( :Gem )
+		require 'rubygems'
+		retry
+	end
+	raise
+end
+
+
+describe Ezmlm do
+	include Ezmlm::SpecHelpers
+
+	LISTSDIR = '/tmp/lists'
+
+	it "can iterate over all mailing lists in a specified directory" do
+		file_entry = mock( "plain file" )
+		file_entry.should_receive( :directory? ).and_return( false )
+
+		nonexistant_mlentry = stub( "mailinglist path that doesn't exist", :exist? => false )
+		nonml_dir_entry = stub( "directory with no mailinglist file",
+		 	:directory? => true, :+ => nonexistant_mlentry )
+
+		existant_mlentry = stub( "mailinglist path that does exist", :exist? => true )
+		ml_dir_entry = stub( "directory with a mailinglist file", :directory? => true, :+ => existant_mlentry )
+		
+		Pathname.should_receive( :glob ).with( an_instance_of(Pathname) ).
+			and_yield( file_entry ).
+			and_yield( nonml_dir_entry ).
+			and_yield( ml_dir_entry )
+
+		Ezmlm::List.should_receive( :new ).with( ml_dir_entry ).and_return( :listobject )
+		
+		lists = []
+		Ezmlm.each_list( LISTSDIR ) do |list|
+			lists << list
+		end
+		
+		lists.should have(1).member
+		lists.should include( :listobject )
+	end
+	
+end
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/lib/helpers.rb	Wed May 07 18:22:04 2008 +0000
@@ -0,0 +1,157 @@
+#!/usr/bin/ruby
+
+begin
+	require 'ezmlm'
+rescue LoadError
+	unless Object.const_defined?( :Gem )
+		require 'rubygems'
+		retry
+	end
+	raise
+end
+
+module Ezmlm::SpecHelpers
+
+	###############
+	module_function
+	###############
+
+	### Create a temporary working directory and return
+	### a Pathname object for it.
+	###
+	def make_tempdir
+		dirname = "%s.%d.%0.4f" % [
+			'ezmlm_spec',
+			Process.pid,
+			(Time.now.to_f % 3600),
+		  ]
+		tempdir = Pathname.new( Dir.tmpdir ) + dirname
+		tempdir.mkpath
+		
+		return tempdir
+	end
+	
+
+end
+
+# Override the badly-formatted output of the RSpec HTML formatter
+require 'spec/runner/formatter/html_formatter'
+
+class Spec::Runner::Formatter::HtmlFormatter
+	def example_failed( example, counter, failure )
+		failure_style = failure.pending_fixed? ? 'pending_fixed' : 'failed'
+		
+		unless @header_red
+			@output.puts "    <script type=\"text/javascript\">makeRed('rspec-header');</script>"
+			@header_red = true
+		end
+		
+		unless @example_group_red
+			css_class = 'example_group_%d' % [current_example_group_number]
+			@output.puts "    <script type=\"text/javascript\">makeRed('#{css_class}');</script>"
+			@example_group_red = true
+		end
+		
+		move_progress()
+		
+		@output.puts "    <dd class=\"spec #{failure_style}\">",
+		             "      <span class=\"failed_spec_name\">#{h(example.description)}</span>",
+		             "      <div class=\"failure\" id=\"failure_#{counter}\">"
+		if failure.exception
+			backtrace = format_backtrace( failure.exception.backtrace )
+			message = failure.exception.message
+			
+			@output.puts "        <div class=\"message\"><code>#{h message}</code></div>",
+			             "        <div class=\"backtrace\"><pre>#{backtrace}</pre></div>"
+		end
+
+		if extra = extra_failure_content( failure )
+			@output.puts( extra )
+		end
+		
+		@output.puts "      </div>",
+		             "    </dd>"
+		@output.flush
+	end
+
+
+	alias_method :default_global_styles, :global_styles
+	
+	def global_styles
+		css = default_global_styles()
+		css << %Q{
+			/* Stuff added by #{__FILE__} */
+
+			/* Overrides */
+			#rspec-header {
+				-webkit-box-shadow: #333 0 2px 5px;
+				margin-bottom: 1em;
+			}
+
+			.example_group dt {
+				-webkit-box-shadow: #333 0 2px 3px;
+			}
+
+			/* Style for log output */
+			dd.log-message {
+				background: #eee;
+				padding: 0 2em;
+				margin: 0.2em 1em;
+				border-bottom: 1px dotted #999;
+				border-top: 1px dotted #999;
+				text-indent: -1em;
+			}
+
+			/* Parts of the message */
+			dd.log-message .log-time {
+				font-weight: bold;
+			}
+			dd.log-message .log-time:after {
+				content: ": ";
+			}
+			dd.log-message .log-level {
+				font-variant: small-caps;
+				border: 1px solid #ccc;
+				padding: 1px 2px;
+			}
+			dd.log-message .log-name {
+				font-size: 1.2em;
+				color: #1e51b2;
+			}
+			dd.log-message .log-name:before { content: "«"; }
+			dd.log-message .log-name:after { content:  "»"; }
+
+			dd.log-message .log-message-text {
+				padding-left: 4px;
+				font-family: Monaco, "Andale Mono", "Vera Sans Mono", mono;
+			}
+
+
+			/* Distinguish levels */
+			dd.log-message.debug { color: #666; }
+			dd.log-message.info {}
+
+			dd.log-message.warn,
+			dd.log-message.error {
+				background: #ff9;
+			}
+			dd.log-message.error .log-level,
+			dd.log-message.error .log-message-text {
+				color: #900;
+			}
+			dd.log-message.fatal {
+				background: #900;
+				color: white;
+				font-weight: bold;
+				border: 0;
+			}
+			dd.log-message.fatal .log-name {
+				color:  white;
+			}
+		}
+		
+		return css
+	end
+end
+
+