Checkpoint commit.
authorMichael Granger <mgranger@laika.com>
Wed, 06 Aug 2008 17:24:00 +0000
changeset 6 66beb495a861
parent 5 804e1c2b9a40
child 7 c0498b8148c5
Checkpoint commit.
.irbrc
bin/ezmlm-listd
experiments/announcepost.rb
lib/ezmlm.rb
lib/ezmlm/listdaemon.rb
spec/ezmlm/list_spec.rb
spec/ezmlm/listdaemon_spec.rb
spec/ezmlm_spec.rb
utils.rb
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.irbrc	Wed Aug 06 17:24:00 2008 +0000
@@ -0,0 +1,23 @@
+#!/usr/bin/ruby -*- ruby -*-
+
+BEGIN {
+	require 'pathname'
+	basedir = Pathname.new( __FILE__ ).dirname.expand_path
+	libdir = basedir + "lib"
+
+	puts ">>> Adding #{libdir} to load path..."
+	$LOAD_PATH.unshift( libdir.to_s )
+
+	require basedir + 'utils'
+	include UtilityFunctions
+}
+
+
+# Try to require the 'thingfish' library
+begin
+	require 'ezmlm'
+rescue => e
+	$stderr.puts "Ack! Ezmlm library failed to load: #{e.message}\n\t" +
+		e.backtrace.join( "\n\t" )
+end
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/ezmlm-listd	Wed Aug 06 17:24:00 2008 +0000
@@ -0,0 +1,83 @@
+#!/usr/bin/env ruby
+# 
+# A startup script for an instance of Ezmlm::ListDaemon.
+# $Id$
+# 
+# Authors:
+# * Michael Granger <mgranger@laika.com>
+# * Jeremiah Jordan <jjordan@laika.com>
+# 
+
+BEGIN {
+	require 'pathname'
+	basedir = Pathname.new( __FILE__ ).dirname.parent
+	libdir = basedir + 'lib'
+	
+	$LOAD_PATH.unshift( libdir.to_s ) unless $LOAD_PATH.include?( libdir.to_s )
+}
+
+begin
+	require 'rubygems'
+	require 'optparse'
+	require 'ezmlm/listdaemon'
+rescue LoadError
+	unless Object.const_defined?( :Gem )
+		require 'rubygems'
+		retry
+	end
+	raise
+end
+
+
+progname = Pathname.new( $0 ).basename
+opts = Ezmlm::ListDaemon.default_options
+
+oparser = OptionParser.new do |oparser|
+	oparser.accept( Pathname ) {|path| Pathname.new(path) }
+
+	oparser.banner = "Usage: #{progname.basename} OPTIONS LISTSDIRECTORY"
+	oparser.separator 'Version ' + Ezmlm::VERSION
+
+	oparser.separator ''
+	oparser.separator 'Options:'
+	oparser.on( '--bind ADDRESS', '-b', String, 
+		"Specify the address to bind to. Defaults to '#{opts.bind_addr}'" ) do |bindaddr|
+		opts.bind_addr = bindaddr
+	end
+
+	oparser.on( '--port PORTNUMBER', '-p', Integer,
+		"Specify the port to connect to. Defaults to '#{opts.bind_port}" ) do |portnumber|
+		opts.bind_port = portnumber
+	end
+
+
+	oparser.separator ''
+	oparser.separator 'Other Options:'
+	oparser.on( '--debug', '-d', FalseClass, "Turn on debugging" ) do
+		$DEBUG = true
+		$stderr.puts "Debugging enabled."
+		opts.debugmode = true
+	end
+
+	oparser.on_tail( '--help', '-h', FalseClass, "Display help for the given command." ) do
+		$stderr.puts( oparser )
+		exit!( 0 )
+	end
+
+	oparser.on_tail( '--version', '-V', "Print Ezmlm library version on STDOUT and quit." ) do
+		$stdout.puts Ezmlm::VERSION
+		exit!( 0 )
+	end
+end
+
+
+# Parse command-line flags and display the help if there isn't exactly one argument
+remaining_args = oparser.parse( ARGV )
+if remaining_args.nitems != 1
+	$stderr.puts( oparser )
+	exit( 64 ) # EX_USAGE
+end
+
+listsdir = remaining_args.shift
+daemon = Ezmlm::ListDaemon.new( listsdir, opts )
+daemon.start.join
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/experiments/announcepost.rb	Wed Aug 06 17:24:00 2008 +0000
@@ -0,0 +1,6 @@
+require 'drb'
+DRb.start_service
+lists = DRbObject.new( nil, 'druby://localhost:32315' )
+l = lists.get_list( 'announce' )
+l.last_post
+
--- a/lib/ezmlm.rb	Sat May 10 01:52:42 2008 +0000
+++ b/lib/ezmlm.rb	Wed Aug 06 17:24:00 2008 +0000
@@ -42,15 +42,20 @@
 	module_function
 	###############
 
+	### Find all directories that look like an Ezmlm list directory under the specified +listsdir+
+	### and return Pathname objects for each.
+	def find_directories( listsdir )
+		listsdir = Pathname.new( listsdir )
+		return Pathname.glob( listsdir + '*' ).select do |entry|
+			entry.directory? && ( entry + 'mailinglist' ).exist?
+		end
+	end
+	
 
 	### 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?
-
+		find_directories( listsdir ).each do |entry|
 			yield( Ezmlm::List.new(entry) )
 		end
 	end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/ezmlm/listdaemon.rb	Wed Aug 06 17:24:00 2008 +0000
@@ -0,0 +1,153 @@
+#!/usr/bin/ruby
+#
+# A DRb interface to one or more ezmlm-idx mailing lists.
+#
+# == 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'
+require 'ezmlm/list'
+require 'drb'
+require 'ostruct'
+
+
+### A DRb interface to one or more ezmlm-idx mailing lists
+class Ezmlm::ListDaemon
+	
+	# The default port to listen on
+	DEFAULT_PORT = 32315
+	
+	# The default address to bind to
+	DEFAULT_ADDRESS = '127.0.0.1'
+	
+	
+	### The interface that is presented to DRb
+	class Service
+		include Enumerable
+		
+		### Create a new service endpoint for the specified +listsdir+, which is a directory
+		### which contains ezmlm-idx list directories.
+		def initialize( listsdir )
+			listsdir = Pathname.new( listsdir )
+			@listsdir = listsdir
+		end
+
+
+		######
+		public
+		######
+
+		# The directory which contains the list directories that should be served.
+		attr_reader :listsdir
+		
+		
+		### Create a new Ezmlm::List object for the list directory with the specified +name+.
+		def get_list( name )
+			name = validate_listdir_name( name )
+			return Ezmlm::List.new( self.listsdir + name )
+		end
+
+
+		### Iterate over each current list in the Service's listsdir, yielding an Ezmlm::List object
+		### for each one.
+		def each_list( &block ) # :yields: list_object
+			Ezmlm.each_list( self.listsdir, &block )
+		end
+		alias_method :each, :each_list
+		
+
+		#######
+		private
+		#######
+
+		VALID_LISTNAME_PATTERN = /^[a-z0-9.-]+$/i
+
+		### Ensure that the given +name+ is a valid list name, raising an exception if not. Returns
+		### an untainted copy of +name+.
+		def validate_listdir_name( name )
+			unless match = VALID_LISTNAME_PATTERN.match( name )
+				raise ArgumentError, "invalid list name %p" % [ name ]
+			end
+			
+			return match[0].untaint
+		end
+		
+	end # class Service
+	
+	
+
+	### Return an OpenStruct that contains the default options
+	def self::default_options
+		opts = OpenStruct.new
+
+		opts.bind_addr  = DEFAULT_ADDRESS
+		opts.bind_port  = DEFAULT_PORT
+		opts.debugmode  = false
+		opts.helpmode   = false
+		opts.foreground = false
+
+		return opts
+	end
+	
+	
+	#################################################################
+	###	I N S T A N C E   M E T H O D S
+	#################################################################
+
+	### Create a new Ezmlm::ListDaemon that will serve objects for the list directories
+	### contained in +listsdir+. The +options+ argument, if given, is an object (such as the one
+	### returned from ::default_options) that contains values for the following methods:
+	### 
+	### bind_addr::
+	###   The address to bind to. Defaults to DEFAULT_ADDRESS.
+	### bind_port::
+	###   The port to listen on. Defaults to DEFAULT_PORT.
+	### debugmode:: 
+	###   Whether to run in debugging mode, which causes the daemon to run in the foreground
+	###   and send any output to STDERR. Defaults to +false+.
+	### foreground::
+	###   Don't go into the background.
+	def initialize( listsdir, options=nil )
+		@service = Service.new( listsdir )
+		@options = options || self.class.default_options
+	end
+
+
+	######
+	public
+	######
+
+	# The daemon's configuration options
+	attr_reader :options
+	
+	# The Ezmlm::ListDaemon::Service object that serves as the DRb interface
+	attr_reader :service
+
+
+	### Daemonize unless configured otherwise, start the DRb service and return the listening 
+	### Thread object
+	def start
+		uri = "druby://%s:%d" % [ self.options.bind_addr, self.options.bind_port ]
+		DRb.start_service( uri, @service )
+		
+		return DRb.thread
+	end
+	
+
+end # class Ezmlm::ListDaemon
+
+# vim: set nosta noet ts=4 sw=4:
--- a/spec/ezmlm/list_spec.rb	Sat May 10 01:52:42 2008 +0000
+++ b/spec/ezmlm/list_spec.rb	Wed Aug 06 17:24:00 2008 +0000
@@ -28,19 +28,23 @@
 	include Ezmlm::SpecHelpers
 
 
-	LISTDIR = Pathname.new( 'list' )
+	# Testing constants
+	TEST_LISTDIR               = Pathname.new( 'list' )
+	TEST_LIST_NAME             = 'waffle-lovers'
+	TEST_LIST_HOST             = 'lists.syrup.info'
+	TEST_OWNER                 = 'listowner@rumpus-the-whale.info'
+	TEST_CUSTOM_MODERATORS_DIR = '/foo/bar/clowns'
+	
 	TEST_SUBSCRIBERS = %w[
 		pete.chaffee@toadsmackers.com
 		dolphinzombie@alahalohamorra.com
 		piratebanker@yahoo.com
 	  ]
+
 	TEST_MODERATORS = %w[
 		dolphinzombie@alahalohamorra.com
 	  ]
-	TEST_LIST_NAME = 'waffle-lovers'
-	TEST_LIST_HOST = 'lists.syrup.info'
-	TEST_OWNER = 'listowner@rumpus-the-whale.info'
-	TEST_CUSTOM_MODERATORS_DIR = '/foo/bar/clowns'
+	
 	TEST_CONFIG = <<-"EOF".gsub( /^\t+/, '' )
 		F:-aBCDeFGHijKlMnOpQrStUVWXYZ
 		X:
@@ -60,7 +64,7 @@
 	EOF
 	
 
-	it "can create a new list"
+	it "can create a list"
 	it "can add a new subscriber"
 	it "can remove a current subscriber"
 	it "can edit the list's text files"
@@ -72,7 +76,7 @@
 	describe "list manager functions" do
 		
 		before( :each ) do
-			@listpath = LISTDIR.dup
+			@listpath = TEST_LISTDIR.dup
 			@list = Ezmlm::List.new( @listpath )
 		end
 		
@@ -121,7 +125,7 @@
 
 		
 		it "can return a list of subscribers' email addresses" do
-			subscribers_dir = LISTDIR + 'subscribers'
+			subscribers_dir = TEST_LISTDIR + 'subscribers'
 			
 			expectation = Pathname.should_receive( :glob ).with( subscribers_dir + '*' )
 
@@ -393,7 +397,7 @@
 	describe "archive functions" do
 	
 		before( :each ) do
-			@listpath = LISTDIR.dup
+			@listpath = TEST_LISTDIR.dup
 			@list = Ezmlm::List.new( @listpath )
 		end
 		
@@ -419,7 +423,7 @@
 		
 
 		
-		TEST_ARCHIVE_DIR = LISTDIR + 'archive'
+		TEST_ARCHIVE_DIR = TEST_LISTDIR + 'archive'
 		TEST_ARCHIVE_SUBDIRS = %w[ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 ]
 		TEST_POST_FILES = %w[ 00 01 02 03 04 05 06 07 08 09 10 11 12 13 ]
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/ezmlm/listdaemon_spec.rb	Wed Aug 06 17:24:00 2008 +0000
@@ -0,0 +1,129 @@
+#!/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 'ostruct'
+	require 'spec/runner'
+	require 'spec/lib/helpers'
+	require 'ezmlm/listdaemon'
+rescue LoadError
+	unless Object.const_defined?( :Gem )
+		require 'rubygems'
+		retry
+	end
+	raise
+end
+
+
+describe Ezmlm::ListDaemon do
+	include Ezmlm::SpecHelpers
+
+
+	DEFAULT_ADDRESS = Ezmlm::ListDaemon::DEFAULT_ADDRESS
+	DEFAULT_PORT = Ezmlm::ListDaemon::DEFAULT_PORT
+
+
+	it "can return a struct that contains its default options" do
+		opts = Ezmlm::ListDaemon.default_options
+		
+		opts.should be_an_instance_of( OpenStruct )
+		opts.bind_addr.should == DEFAULT_ADDRESS
+		opts.bind_port.should == DEFAULT_PORT
+		opts.debugmode.should == false
+		opts.helpmode.should == false
+	end
+
+	describe "created with defaults" do
+
+		DEFAULT_URL = "druby://%s:%d" % [ DEFAULT_ADDRESS, DEFAULT_PORT ]
+
+		before( :each ) do
+			@test_list_dir = Pathname.new( 'lists' )
+			@daemon = Ezmlm::ListDaemon.new( @test_list_dir )
+		end
+		
+
+		it "can be started and will return a thread" do
+			mock_drb_thread = mock( "drb thread" )
+			
+			DRb.should_receive( :start_service ).with( DEFAULT_URL, @daemon.service )
+			DRb.should_receive( :thread ).and_return( mock_drb_thread )
+
+			@daemon.start.should == mock_drb_thread
+		end
+	end
+
+
+	describe "created with an options struct" do
+
+		TEST_ADDRESS = '0.0.0.0'
+		TEST_PORT = 17771
+		TEST_URL = "druby://%s:%d" % [ TEST_ADDRESS, TEST_PORT ]
+
+		before( :each ) do
+			@test_list_dir = Pathname.new( 'lists' )
+
+			@opts = Ezmlm::ListDaemon.default_options
+			@opts.bind_addr = TEST_ADDRESS
+			@opts.bind_port = TEST_PORT
+
+			@daemon = Ezmlm::ListDaemon.new( @test_list_dir, @opts )
+		end
+		
+
+		it "can be started and will return a thread" do
+			mock_drb_thread = mock( "drb thread" )
+			
+			DRb.should_receive( :start_service ).with( TEST_URL, @daemon.service )
+			DRb.should_receive( :thread ).and_return( mock_drb_thread )
+
+			@daemon.start.should == mock_drb_thread
+		end
+	end
+
+end
+
+
+describe Ezmlm::ListDaemon::Service do
+	
+	before( :each ) do
+		@dummydir = 'lists'
+		@service = Ezmlm::ListDaemon::Service.new( @dummydir )
+	end
+
+	
+	it "can return a list object by name if there is a corresponding listdir" do
+		@service.get_list( 'announce' ).should be_an_instance_of( Ezmlm::List )
+	end
+	
+	it "raises an exception when asked for a list whose name contains invalid characters" do
+		lambda {
+			@service.get_list( 'glarg beegun' )
+		}.should raise_error( ArgumentError )
+	end
+	
+	it "can iterate over listdirs, yielding each as a Ezmlm::List object" do
+		Ezmlm.should_receive( :each_list ).with( Pathname.new(@dummydir) ).and_yield( :a_list )
+		@service.each_list {|l| l.should == :a_list }
+	end
+	
+end
+
+# listservice = DRbObject.new( nil, 'druby://lists.laika.com:23431' )
+# announce = listservice.each_list do |list|
+# 	last_posts << list.last_post
+# end
+# announce = listservice.get_list( 'announce' )
+# 
+# announce.last_post
+# 
+# 
--- a/spec/ezmlm_spec.rb	Sat May 10 01:52:42 2008 +0000
+++ b/spec/ezmlm_spec.rb	Wed Aug 06 17:24:00 2008 +0000
@@ -25,9 +25,9 @@
 describe Ezmlm do
 	include Ezmlm::SpecHelpers
 
-	LISTSDIR = '/tmp/lists'
+	TEST_LISTSDIR = '/tmp/lists'
 
-	it "can iterate over all mailing lists in a specified directory" do
+	it "can fetch a list of all mailing list subdirectories beneath a given directory" do
 		file_entry = mock( "plain file" )
 		file_entry.should_receive( :directory? ).and_return( false )
 
@@ -39,19 +39,28 @@
 		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 )
+			and_return([ file_entry, nonml_dir_entry, ml_dir_entry ])
 
-		Ezmlm::List.should_receive( :new ).with( ml_dir_entry ).and_return( :listobject )
+		dirs = Ezmlm.find_directories( TEST_LISTSDIR )
+		
+		dirs.should have(1).member
+		dirs.should include( ml_dir_entry )
+	end
+	
+
+	it "can iterate over all mailing lists in a specified directory" do
+		Ezmlm.should_receive( :find_directories ).with( TEST_LISTSDIR ).and_return([ :listdir1, :listdir2 ])
+
+		Ezmlm::List.should_receive( :new ).with( :listdir1 ).and_return( :listobject1 )
+		Ezmlm::List.should_receive( :new ).with( :listdir2 ).and_return( :listobject2 )
 		
 		lists = []
-		Ezmlm.each_list( LISTSDIR ) do |list|
+		Ezmlm.each_list( TEST_LISTSDIR ) do |list|
 			lists << list
 		end
 		
-		lists.should have(1).member
-		lists.should include( :listobject )
+		lists.should have(2).members
+		lists.should include( :listobject1, :listobject2 )
 	end
 	
 end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/utils.rb	Wed Aug 06 17:24:00 2008 +0000
@@ -0,0 +1,800 @@
+#
+#	Install/distribution utility functions
+#	$Id$
+#
+#	Copyright (c) 2001-2008, The FaerieMUD Consortium.
+#
+#   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.
+# 
+
+BEGIN {
+	require 'rbconfig'
+	require 'uri'
+	require 'find'
+	require 'pp'
+	require 'irb'
+
+	begin
+		require 'readline'
+		include Readline
+	rescue LoadError => e
+		$stderr.puts "Faking readline..."
+		def readline( prompt )
+			$stderr.print prompt.chomp
+			return $stdin.gets.chomp
+		end
+	end
+
+}
+
+
+### Command-line utility functions
+module UtilityFunctions
+	include Config
+
+	# The list of regexen that eliminate files from the MANIFEST
+	ANTIMANIFEST = [
+		/makedist\.rb/,
+		/\bCVS\b/,
+		/~$/,
+		/^#/,
+		%r{docs/html},
+		%r{docs/man},
+		/\bTEMPLATE\.\w+\.tpl\b/,
+		/\.cvsignore/,
+		/\.s?o$/,
+	]
+
+	# Set some ANSI escape code constants (Shamelessly stolen from Perl's
+	# Term::ANSIColor by Russ Allbery <rra@stanford.edu> and Zenin <zenin@best.com>
+	AnsiAttributes = {
+		'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
+	}
+
+	ErasePreviousLine = "\033[A\033[K"
+
+	ManifestHeader = (<<-"EOF").gsub( /^\t+/, '' )
+		#
+		# Distribution Manifest
+		# Created: #{Time::now.to_s}
+		# 
+
+	EOF
+
+	###############
+	module_function
+	###############
+
+	# Create a string that contains the ANSI codes specified and return it
+	def ansi_code( *attributes )
+		attributes.flatten!
+		# $stderr.puts "Returning ansicode for TERM = %p: %p" %
+		# 	[ ENV['TERM'], attributes ]
+		return '' unless /(?:vt10[03]|xterm(?:-color)?|linux|screen)/i =~ ENV['TERM']
+		attributes = AnsiAttributes.values_at( *attributes ).compact.join(';')
+
+		# $stderr.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, etc.
+	def colorize( string, *attributes )
+		ending = string[/(\s)$/] || ''
+		string = string.rstrip
+		return ansi_code( attributes.flatten ) + string + ansi_code( 'reset' ) + ending
+	end
+
+
+	# Test for the presence of the specified <tt>library</tt>, and output a
+	# message describing the test using <tt>nicename</tt>. If <tt>nicename</tt>
+	# is <tt>nil</tt>, the value in <tt>library</tt> is used to build a default.
+	def test_for_library( library, nicename=nil, progress=false )
+		nicename ||= library
+		message( "Testing for the #{nicename} library..." ) if progress
+		if $LOAD_PATH.detect {|dir|
+				File.exists?(File.join(dir,"#{library}.rb")) ||
+				File.exists?(File.join(dir,"#{library}.#{CONFIG['DLEXT']}"))
+			}
+			message( "found.\n" ) if progress
+			return true
+		else
+			message( "not found.\n" ) if progress
+			return false
+		end
+	end
+
+	# Test for the presence of the specified <tt>library</tt>, and output a
+	# message describing the problem using <tt>nicename</tt>. If
+	# <tt>nicename</tt> is <tt>nil</tt>, the value in <tt>library</tt> is used
+	# to build a default. If <tt>raaUrl</tt> and/or <tt>downloadUrl</tt> are
+	# specified, they are also use to build a message describing how to find the
+	# required library. If <tt>fatal</tt> is <tt>true</tt>, a missing library
+	# will cause the program to abort.
+	def test_for_required_library( library, nicename=nil, raaUrl=nil, downloadUrl=nil, fatal=true )
+		nicename ||= library
+		unless test_for_library( library, nicename )
+			msgs = [ "You are missing the required #{nicename} library.\n" ]
+			msgs << "RAA: #{raaUrl}\n" if raaUrl
+			msgs << "Download: #{downloadUrl}\n" if downloadUrl
+			if fatal
+				abort msgs.join('')
+			else
+				error_message msgs.join('')
+			end
+		end
+		return true
+	end
+
+	### Output <tt>msg</tt> as a ANSI-colored program/section header (white on
+	### blue).
+	def header( msg )
+		msg.chomp!
+		$stderr.puts ansi_code( 'bold', 'white', 'on_blue' ) + msg + ansi_code( 'reset' )
+		$stderr.flush
+	end
+
+	### Output <tt>msg</tt> to STDERR and flush it.
+	def message( *msgs )
+		$stderr.print( msgs.join("\n") )
+		$stderr.flush
+	end
+
+	### Output +msg+ to STDERR and flush it if $VERBOSE is true.
+	def verbose_msg( msg )
+		msg.chomp!
+		message( msg + "\n" ) if $VERBOSE
+	end
+
+	### Output the specified <tt>msg</tt> as an ANSI-colored error message
+	### (white on red).
+	def error_msg( msg )
+		message ansi_code( 'bold', 'white', 'on_red' ) + msg + ansi_code( 'reset' )
+	end
+	alias :error_message :error_msg
+
+	### Output the specified <tt>msg</tt> as an ANSI-colored debugging message
+	### (yellow on blue).
+	def debug_msg( msg )
+		return unless $DEBUG
+		msg.chomp!
+		$stderr.puts ansi_code( 'yellow' ) + ">>> #{msg}" + ansi_code( 'reset' )
+		$stderr.flush
+	end
+
+	### Erase the previous line (if supported by your terminal) and output the
+	### specified <tt>msg</tt> instead.
+	def replace_msg( msg )
+		$stderr.puts
+		$stderr.print ErasePreviousLine
+		message( msg )
+	end
+	alias :replace_message :replace_msg
+
+	### Output a divider made up of <tt>length</tt> hyphen characters.
+	def divider( length=75 )
+		$stderr.puts "\r" + ("-" * length )
+	end
+	alias :writeLine :divider
+
+
+	### Output the specified <tt>msg</tt> colored in ANSI red and exit with a
+	### status of 1.
+	def abort( msg )
+		print ansi_code( 'bold', 'red' ) + "Aborted: " + msg.chomp + ansi_code( 'reset' ) + "\n\n"
+		Kernel.exit!( 1 )
+	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
+			response = readline( ansi_code('bold', 'green') +
+				"#{prompt_string} " + ansi_code('reset') ) || ''
+			response.strip!
+			if block_given? && ! yield( response ) 
+				error_message( failure_msg + "\n\n" )
+				response = nil
+			end
+		end until response
+
+		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 until response
+
+		return response
+	end
+
+
+	$programs = {}
+
+	### Search for the program specified by the given <tt>progname</tt> in the
+	### user's <tt>PATH</tt>, and return the full path to it, or <tt>nil</tt> if
+	### no such program is in the path.
+	def find_program( progname )
+		unless $programs.key?( progname )
+			ENV['PATH'].split(File::PATH_SEPARATOR).each {|d|
+				file = File.join( d, progname )
+				if File.executable?( file )
+					$programs[ progname ] = file 
+					break
+				end
+			}
+		end
+
+		return $programs[ progname ]
+	end
+
+
+	### Search for the release version for the project in the specified
+	### +directory+.
+	def extract_version( directory='.' )
+		release = nil
+
+		Dir::chdir( directory ) do
+			if File::directory?( "CVS" )
+				verbose_msg( "Project is versioned via CVS. Searching for RELEASE_*_* tags..." )
+
+				if (( cvs = find_program('cvs') ))
+					revs = []
+					output = %x{cvs log}
+					output.scan( /RELEASE_(\d+(?:_\d\w+)*)/ ) {|match|
+						rev = $1.split(/_/).collect {|s| Integer(s) rescue 0}
+						verbose_msg( "Found %s...\n" % rev.join('.') )
+						revs << rev
+					}
+
+					release = revs.sort.last
+				end
+
+			elsif File::directory?( '.svn' )
+				verbose_msg( "Project is versioned via Subversion" )
+
+				if (( svn = find_program('svn') ))
+					output = %x{svn pg project-version}.chomp
+					unless output.empty?
+						verbose_msg( "Using 'project-version' property: %p" % output )
+						release = output.split( /[._]/ ).collect {|s| Integer(s) rescue 0}
+					end
+				end
+			end
+		end
+
+		return release
+	end
+
+
+	### Find the current release version for the project in the specified
+	### +directory+ and return its successor.
+	def extract_next_version( directory='.' )
+		version = extract_version( directory ) || [0,0,0]
+		version.compact!
+		version[-1] += 1
+
+		return version
+	end
+
+
+	# Pattern for extracting the name of the project from a Subversion URL
+	SVNUrlPath = %r{
+		.*/						# Skip all but the last bit
+		([^/]+)					# $1 = project name
+		/						# Followed by / +
+		(?:
+			trunk |				# 'trunk'
+			(
+				branches |		# ...or branches/branch-name
+				tags			# ...or tags/tag-name
+			)/\w	
+		)
+		$						# bound to the end
+	}ix
+
+	### Extract the project name (CVS Repository name) for the given +directory+.
+	def extract_project_name( directory='.' )
+		name = nil
+
+		Dir::chdir( directory ) do
+
+			# CVS-controlled
+			if File::directory?( "CVS" )
+				verbose_msg( "Project is versioned via CVS. Using repository name." )
+				name = File.open( "CVS/Repository", "r").readline.chomp
+				name.sub!( %r{.*/}, '' )
+
+			# Subversion-controlled
+			elsif File::directory?( '.svn' )
+				verbose_msg( "Project is versioned via Subversion" )
+
+				# If the machine has the svn tool, try to get the project name
+				if (( svn = find_program( 'svn' ) ))
+
+					# First try an explicit property
+					output = shell_command( svn, 'pg', 'project-name' )
+					if !output.empty?
+						verbose_msg( "Using 'project-name' property: %p" % output )
+						name = output.first.chomp
+
+					# If that doesn't work, try to figure it out from the URL
+					elsif (( uri = get_svn_uri() ))
+						name = uri.path.sub( SVNUrlPath ) { $1 }
+					end
+				end
+			end
+
+			# Fall back to guessing based on the directory name
+			unless name
+				name = File::basename(File::dirname( File::expand_path(__FILE__) ))
+			end
+		end
+
+		return name
+	end
+
+
+	### Extract the Subversion URL from the specified directory and return it as
+	### a URI object.
+	def get_svn_uri( directory='.' )
+		uri = nil
+
+		Dir::chdir( directory ) do
+			output = %x{svn info}
+			debug_msg( "Using info: %p" % output )
+
+			if /^URL: \s* ( .* )/xi.match( output )
+				uri = URI::parse( $1 )
+			end
+		end
+
+		return uri
+	end
+
+
+	### (Re)make a manifest file in the specified +path+.
+	def make_manifest( path="MANIFEST" )
+		if File::exists?( path )
+			reply = prompt_with_default( "Replace current '#{path}'? [yN]", "n" )
+			return false unless /^y/i.match( reply )
+
+			verbose_msg "Replacing manifest at '#{path}'"
+		else
+			verbose_msg "Creating new manifest at '#{path}'"
+		end
+
+		files = []
+		verbose_msg( "Finding files...\n" )
+		Find::find( Dir::pwd ) do |f|
+			Find::prune if File::directory?( f ) &&
+				/^\./.match( File::basename(f) )
+			verbose_msg( "  found: #{f}\n" )
+			files << f.sub( %r{^#{Dir::pwd}/?}, '' )
+		end
+		files = vet_manifest( files )
+
+		verbose_msg( "Writing new manifest to #{path}..." )
+		File::open( path, File::WRONLY|File::CREAT|File::TRUNC ) do |ofh|
+			ofh.puts( ManifestHeader )
+			ofh.puts( files )
+		end
+		verbose_msg( "done." )
+	end
+
+
+	### Read the specified <tt>manifestFile</tt>, which is a text file
+	### describing which files to package up for a distribution. The manifest
+	### should consist of one or more lines, each containing one filename or
+	### shell glob pattern.
+	def read_manifest( manifestFile="MANIFEST" )
+		verbose_msg "Building manifest..."
+		raise "Missing #{manifestFile}, please remake it" unless File.exists? manifestFile
+
+		manifest = IO::readlines( manifestFile ).collect {|line|
+			line.chomp
+		}.select {|line|
+			line !~ /^(\s*(#.*)?)?$/
+		}
+
+		filelist = []
+		for pat in manifest
+			verbose_msg "Adding files that match '#{pat}' to the file list"
+			filelist |= Dir.glob( pat ).find_all {|f| FileTest.file?(f)}
+		end
+
+		verbose_msg "found #{filelist.length} files.\n"
+		return filelist
+	end
+
+
+	### Given a <tt>filelist</tt> like that returned by #read_manifest, remove
+	### the entries therein which match the Regexp objects in the given
+	### <tt>antimanifest</tt> and return the resultant Array.
+	def vet_manifest( filelist, antimanifest=ANTIMANIFEST )
+		origLength = filelist.length
+		verbose_msg "Vetting manifest..."
+
+		for regex in antimanifest
+			verbose_msg "\n\tPattern /#{regex.source}/ removed: " +
+				filelist.find_all {|file| regex.match(file)}.join(', ')
+			filelist.delete_if {|file| regex.match(file)}
+		end
+
+		verbose_msg "removed #{origLength - filelist.length} files from the list.\n"
+		return filelist
+	end
+
+
+	### Combine a call to #read_manifest with one to #vet_manifest.
+	def get_vetted_manifest( manifestFile="MANIFEST", antimanifest=ANTIMANIFEST )
+		vet_manifest( read_manifest(manifestFile), antimanifest )
+	end
+
+
+	### Given a documentation <tt>catalogFile</tt>, extract the title, if
+	### available, and return it. Otherwise generate a title from the name of
+	### the CVS module.
+	def find_rdoc_title( catalogFile="docs/CATALOG" )
+
+		# Try extracting it from the CATALOG file from a line that looks like:
+		# Title: Foo Bar Module
+		title = find_catalog_keyword( 'title', catalogFile )
+
+		# If that doesn't work for some reason, use the name of the project.
+		title = extract_project_name()
+
+		return title
+	end
+
+
+	### Given a documentation <tt>catalogFile</tt>, extract the name of the file
+	### to use as the initally displayed page. If extraction fails, the
+	### +default+ will be used if it exists. Returns +nil+ if there is no main
+	### file to be found.
+	def find_rdoc_main( catalogFile="docs/CATALOG", default="README" )
+
+		# Try extracting it from the CATALOG file from a line that looks like:
+		# Main: Foo Bar Module
+		main = find_catalog_keyword( 'main', catalogFile )
+
+		# Try to make some educated guesses if that doesn't work
+		if main.nil?
+			basedir = File::dirname( __FILE__ )
+			basedir = File::dirname( basedir ) if /docs$/ =~ basedir
+
+			if File::exists?( File::join(basedir, default) )
+				main = default
+			end
+		end
+
+		return main
+	end
+
+
+	### Given a documentation <tt>catalogFile</tt>, extract an upload URL for
+	### RDoc.
+	def find_rdoc_upload( catalogFile="docs/CATALOG" )
+		find_catalog_keyword( 'upload', catalogFile )
+	end
+
+
+	### Given a documentation <tt>catalogFile</tt>, extract a CVS web frontend
+	### URL for RDoc.
+	def find_rdoc_cvs_url( catalogFile="docs/CATALOG" )
+		find_catalog_keyword( 'webcvs', catalogFile )
+	end
+
+
+	### Find one or more 'accessor' directives in the catalog if they exist and
+	### return an Array of them.
+	def find_rdoc_accessors( catalogFile="docs/CATALOG" )
+		accessors = []
+		in_attr_section = false
+		indent = ''
+
+		if File::exists?( catalogFile )
+			verbose_msg "Extracting accessors from CATALOG file (%s).\n" % catalogFile
+
+			# Read lines from the catalog
+			File::foreach( catalogFile ) do |line|
+				debug_msg( "  Examining line #{line.inspect}..." )
+
+				# Multi-line accessors
+				if in_attr_section
+					if /^#\s+([a-z0-9_]+(?:\s*=\s*.*)?)$/i.match( line )
+						debug_msg( "    Found accessor: #$1" )
+						accessors << $1
+						next
+					end
+
+					debug_msg( "  End of accessors section." )
+					in_attr_section = false
+
+				# Single-line accessor
+				elsif /^#\s*Accessors:\s*(\S+)$/i.match( line )
+					debug_msg( "  Found single accessors line: #$1" )
+					vals = $1.split(/,/).collect {|val| val.strip }
+					accessors.replace( vals )
+
+				# Multi-line accessor header
+				elsif /^#\s*Accessors:\s*$/i.match( line )
+					debug_msg( "  Start of accessors section." )
+					in_attr_section = true
+				end
+
+			end
+		end
+
+		debug_msg( "Found accessors: %s" % accessors.join(",") )
+		return accessors
+	end
+
+
+	### Given a documentation <tt>catalogFile</tt>, try extracting the given
+	### +keyword+'s value from it. Keywords are lines that look like:
+	###   # <keyword>: <value>
+	### Returns +nil+ if the catalog file was unreadable or didn't contain the
+	### specified +keyword+.
+	def find_catalog_keyword( keyword, catalogFile="docs/CATALOG" )
+		val = nil
+
+		if File::exists? catalogFile
+			verbose_msg "Extracting '#{keyword}' from CATALOG file (%s).\n" % catalogFile
+			File::foreach( catalogFile ) do |line|
+				debug_msg( "Examining line #{line.inspect}..." )
+				val = $1.strip and break if /^#\s*#{keyword}:\s*(.*)$/i.match( line )
+			end
+		end
+
+		return val
+	end
+
+
+	### Given a documentation <tt>catalogFile</tt>, which is in the same format
+	### as that described by #read_manifest, read and expand it, and then return
+	### a list of those files which appear to have RDoc documentation in
+	### them. If <tt>catalogFile</tt> is nil or does not exist, the MANIFEST
+	### file is used instead.
+	def find_rdocable_files( catalogFile="docs/CATALOG" )
+		startlist = []
+		if File.exists? catalogFile
+			verbose_msg "Using CATALOG file (%s).\n" % catalogFile
+			startlist = get_vetted_manifest( catalogFile )
+		else
+			verbose_msg "Using default MANIFEST\n"
+			startlist = get_vetted_manifest()
+		end
+
+		verbose_msg "Looking for RDoc comments in:\n"
+		startlist.select {|fn|
+			verbose_msg "  #{fn}: "
+			found = false
+			File::open( fn, "r" ) {|fh|
+				fh.each {|line|
+					if line =~ /^(\s*#)?\s*=/ || line =~ /:\w+:/ || line =~ %r{/\*}
+						found = true
+						break
+					end
+				}
+			}
+
+			verbose_msg( (found ? "yes" : "no") + "\n" )
+			found
+		}
+	end
+
+
+	### Open a file and filter each of its lines through the given block a
+	### <tt>line</tt> at a time. The return value of the block is used as the
+	### new line, or omitted if the block returns <tt>nil</tt> or
+	### <tt>false</tt>.
+	def edit_in_place( file, testMode=false ) # :yields: line
+		raise "No block specified for editing operation" unless block_given?
+
+		tempName = "#{file}.#{$$}"
+		File::open( tempName, File::RDWR|File::CREAT, 0600 ) {|tempfile|
+			File::open( file, File::RDONLY ) {|fh|
+				fh.each {|line|
+					newline = yield( line ) or next
+					tempfile.print( newline )
+					$stderr.puts "%p -> %p" % [ line, newline ] if
+						line != newline
+				}
+			}
+		}
+
+		if testMode
+			File::unlink( tempName )
+		else
+			File::rename( tempName, file )
+		end
+	end
+
+
+	### Execute the specified shell <tt>command</tt>, read the results, and
+	### return them. Like a %x{} that returns an Array instead of a String.
+	def shell_command( *command )
+		raise "Empty command" if command.empty?
+
+		cmdpipe = IO::popen( command.join(' '), 'r' )
+		return cmdpipe.readlines
+	end
+
+
+	### Execute a block with $VERBOSE set to +false+, restoring it to its
+	### previous value before returning.
+	def verbose_off
+		raise LocalJumpError, "No block given" unless block_given?
+
+		thrcrit = Thread.critical
+		oldverbose = $VERBOSE
+		begin
+			Thread.critical = true
+			$VERBOSE = false
+			yield
+		ensure
+			$VERBOSE = oldverbose
+			Thread.critical = false
+		end
+	end
+
+
+	### Try the specified code block, printing the given 
+	def try( msg, bind=TOPLEVEL_BINDING )
+		result = ''
+		if msg =~ /^to\s/
+			message "Trying #{msg}...\n"
+		else
+			message msg + "\n"
+		end
+
+		begin
+			rval = nil
+			if block_given?
+				rval = yield
+			else
+				file, line = caller(1)[0].split(/:/,2)
+				rval = eval( msg, bind, file, line.to_i )
+			end
+
+			PP.pp( rval, result )
+
+		rescue Exception => err
+			if err.backtrace
+				nicetrace = err.backtrace.delete_if {|frame|
+					/in `(try|eval)'/ =~ frame
+				}.join("\n\t")
+			else
+				nicetrace = "Exception had no backtrace"
+			end
+
+			result = err.message + "\n\t" + nicetrace
+
+		ensure
+			divider
+			message result.chomp + "\n"
+			divider
+			$stderr.puts
+		end
+	end
+
+
+	### Start an IRB session with the specified binding +b+ as the current scope.
+	def start_irb_session( b )
+		IRB.setup(nil)
+
+		workspace = IRB::WorkSpace.new( b )
+
+		if IRB.conf[:SCRIPT]
+			irb = IRB::Irb.new( workspace, IRB.conf[:SCRIPT] )
+		else
+			irb = IRB::Irb.new( workspace )
+		end
+
+		IRB.conf[:IRB_RC].call( irb.context ) if IRB.conf[:IRB_RC]
+		IRB.conf[:MAIN_CONTEXT] = irb.context
+
+		trap("SIGINT") do
+			irb.signal_handle
+		end
+
+		catch(:IRB_EXIT) do
+			irb.eval_input
+		end
+	end
+
+end # module UtilityFunctions
+
+
+
+if __FILE__ == $0
+	# $DEBUG = true
+	include UtilityFunctions
+
+	projname = extract_project_name()
+	header "Project: #{projname}"
+
+	ver = extract_version() || [0,0,1]
+	puts "Version: %s\n" % ver.join('.')
+
+	if File::directory?( "docs" )
+		puts "Rdoc:",
+			"  Title: " + find_rdoc_title(),
+			"  Main: " + find_rdoc_main(),
+			"  Upload: " + find_rdoc_upload(),
+			"  SCCS URL: " + find_rdoc_cvs_url(),
+			"  Accessors: " + find_rdoc_accessors().join(",")
+	end
+
+	puts "Manifest:",
+		"  " + get_vetted_manifest().join("\n  ")
+end