-- $Id: cmdchain.lua,v 1.22 2005/01/28 00:46:06 cpressey Exp $

require "app"
require "expander"

--[[-----------]]--
--[[ CmdChains ]]--
--[[-----------]]--

-- Global "class" variable:
CmdChain = {}

-- Some 'symbolic constants':
CmdChain.LOG_SILENT		= {}
CmdChain.LOG_QUIET		= {}
CmdChain.LOG_VERBOSE		= {}

CmdChain.FAILURE_IGNORE		= {}
CmdChain.FAILURE_WARN		= {}
CmdChain.FAILURE_ABORT		= {}

CmdChain.RESULT_NEVER_EXECUTED	= {}
CmdChain.RESULT_POPEN_ERROR	= {}
CmdChain.RESULT_SELECT_ERROR	= {}
CmdChain.RESULT_CANCELLED	= {}
CmdChain.RESULT_SKIPPED		= {}

-- Create a new command chain object instance.
CmdChain.new = function(...)
	local chain = {}
	local cmds = {}
	local capture = {}
	local expander = App.expander
	local replacements = {}

	-- Fix up a command descriptor.  If it is just a string, turn
	-- it into a table; fill out any missing default values in the
	-- table; and expand the string as appropriate.
	local fix_cmd = function(cmd)
		if type(cmd) == "string" then
			cmd = { cmdline = cmd }
		end
		assert(type(cmd) == "table")

		if cmd.cmdline == nil then
			cmd.cmdline = ""
		end
		assert(type(cmd.cmdline) == "string")

		if cmd.log_mode == nil then
			cmd.log_mode = CmdChain.LOG_VERBOSE
		end
		assert(cmd.log_mode == CmdChain.LOG_SILENT or
		       cmd.log_mode == CmdChain.LOG_QUIET or
		       cmd.log_mode == CmdChain.LOG_VERBOSE)

		if cmd.failure_mode == nil then
			cmd.failure_mode = CmdChain.FAILURE_ABORT
		end
		assert(cmd.failure_mode == CmdChain.FAILURE_IGNORE or
		       cmd.failure_mode == CmdChain.FAILURE_WARN or
		       cmd.failure_mode == CmdChain.FAILURE_ABORT)

		if cmd.replacements == nil then
			cmd.replacements = {}
		end
		assert(type(cmd.replacements) == "table")

		cmd.cmdline = expander:expand(cmd.cmdline,
		    cmd.replacements, replacements)

		cmd.display_cmdline = cmd.cmdline
		if cmd.sensitive ~= nil then
			assert(type(cmd.sensitive) == "string")
			cmd.display_cmdline = string.gsub(
			    cmd.cmdline, cmd.sensitive, "***not*shown***"
			)
		end

		if cmd.on_executed ~= nil then
			assert(type(cmd.on_executed) == "function")
		end

		-- cmd.desc, cmd.tag, cmd.sensitive, and cmd.on_executed
		-- are left as nil if not specified.

		return cmd
	end

	--
	-- Set the global replacements for this command chain.
	-- These will be applied to each command that is
	-- subsequently added (although local replacements will
	-- be applied first.)
	--
	chain.set_replacements = function(cmd, new_replacements)
		Kernel.merge_tables(replacements, new_replacements,
		    function(key, dest_val, src_val)
			return src_val
		    end)
	end

	--
	-- Get captured output by its id.
	--
	chain.get_output = function(cmd, cap_id)
		return capture[cap_id]
	end

	--
	-- Add one or more commands to this command chain.
	--
	chain.add = function(chain, ...)
		local cmd_no, cmd

		if table.getn(arg) == 0 then
			return
		end

		for cmd_no, cmd in ipairs(arg) do
			table.insert(cmds, fix_cmd(cmd))
		end
	end

	-- Execute a series of external utility programs.
	-- Returns 1 if everything executed OK, 0 if one of the
	-- critical commands failed or if the user cancelled.
	chain.execute = function(chain)
		local pr
		local cmd
		local i, n, result = 0, 0, 0
		local return_val = true
		local output

		n = table.getn(cmds)
	
		pr = DFUIProgress.new(App.c,
		    "Executing Commands", "Executing Commands", "",
		    0)
	
		pr:start()
	
		for i in cmds do
			result, output = command_execute(pr, cmds[i])
			if result == CmdChain.RESULT_CANCELLED then
				return_val = false
				break
			end
			if result > 0 and result < 256 then
				return_val = false
				if cmd.failure_mode == CmdChain.FAILURE_ABORT then
					break
				end
			end
			if cmds[i].capture then
				capture[cmds[i].capture] = output
			end
			pr:set_amount((i * 100) / n)
		end
	
		pr:stop()
	
		return return_val
	end

	-- Show the commands that have been added to this
	-- command chain to the user in a dialog box.
	chain.preview = function(chain)
		local contents = ""
		local i, cmd
	
		for i, cmd in cmds do
			contents = contents .. cmd.cmdline .. "\n"
		end
	
		App.c:present({
			id = "cmd_preview",
			name = "Command Preview",
			short_desc = contents,
			role = "informative",
			minimum_width = "72",
			monospaced = "true",
			actions = {
				{ id = "ok", name = "OK" }
			}
		})
	end

	-- Record these commands in a shell script file.
	chain.record = function(chain, file)
		local contents = ""
		local i, cmd
	
		for i, cmd in cmds do
			--
			-- Write lines apropos to the cmdline being
			-- executed, taking in account the failure mode.
			--
			if cmd.failure_mode == CmdChain.FAILURE_IGNORE then
				file:write(cmd.cmdline)
			elseif cmd.failure_mode == CmdChain.FAILURE_WARN then
				file:write("(" .. cmd.cmdline .. " || " ..
				   "echo \"WARNING: " .. cmd.cmdline ..
				   " failed with exit code $?\") && \\")
			elseif cmd.failure_mode == CmdChain.FAILURE_ABORT then
				file:write(cmd.cmdline .. " && \\")
			end

			--
			-- Write the description as a comment following.
			--
			if cmd.desc then
				file:write("   # " .. cmd.desc)
			end

			file:write("\n")
		end
	end

	-- ``Constructor'' - initialize our instance data.

	if table.getn(arg) > 0 then
		chain:add(unpack(arg))
	end

	return chain
end

-- The command chain executing engine proper follows.


-- Read from the pipe that was opened to the executing commands
-- and update the progress bar as data comes and (and/or as the
-- read from the pipe times out.)

-- Returns:
--   - the return value of the pipe;
--   - a boolean indicating whether it was cancelled by the user; and
--   - the output of the command, if requested (cmd.capture.)

function pipe_loop(pr, cmd)
	local done = false
	local cancelled = false
	local pipe = Pipe.open("(" .. cmd.cmdline .. ") 2>&1 </dev/null");
	local output = {}

	if pipe == nil then
		App.log("! could not open pipe to: " .. cmd.cmdline)
		return CmdChain.RESULT_POPEN_ERROR, false, output
	end

	while not done do
		if cancelled then break end
		line, err = pipe:readline(1000)
		
		if line then
			cancelled = not pr:update()
			if cmd.log_mode == CmdChain.LOG_VERBOSE then
				App.log("| " .. line)
			elseif cmd.log_mode == CmdChain.LOG_QUIET then
				io.stderr:write("| " .. line .. "\n")
			else -- cmd.log_mode == CmdChain.LOG_SILENT
				-- do nothing
			end
			if cmd.capture then
				table.insert(output, line)
			end
		else
			if err == Pipe.TIMEOUT then
				cancelled = not pr:update()
			elseif err == Pipe.EOF then
				break
			else
				App.log("! select() failed");
				pipe:close()
				return CmdChain.RESULT_SELECT_ERROR,
				    false, output;
			end
		end
	end

	if cancelled then
		pipe:signal(Pipe.SIGTERM)
	end

	return math.floor(pipe:close() / 256), cancelled, output
end

function interruption_dialog(cmd, cancelled, result)
	local done_interruption = false
	local done_command = false
	local msg
	
	if cancelled then
		msg = "was cancelled."
	else
		msg = "FAILED with a return code of " .. result .. "."
	end
	
	while not done_interruption do
		App.c:present({
		    id = "cancelled",
		    name = "Cancelled",
		    short_desc = "Execution of the command\n\n" ..
		        cmd.display_cmdline .. "\n\n" .. msg,
		    actions = {
			{
			    id = "view_log",
			    name = "View Log",
			    short_desc = "View the command output that led up to this",
			    effect = function()
				App.view_log()
			    end
			},
			{
			    id = "retry",
			    name = "Retry",
			    short_desc = "Try executing this command again",
			    effect = function()
				done_interruption = true
			    end
			},
			{
			    id = "cancel",
			    name = "Cancel",
			    short_desc = "Abort this sequence of commands",
			    effect = function()
				result = CmdChain.RESULT_CANCELLED
				done_interruption = true
				done_command = true
			    end
			},
			{
			    id = "skip",
			    name = "Skip",
			    short_desc = "Skip this particular command and resume with the next one",
			    effect = function()
				result = CmdChain.RESULT_SKIPPED
				done_interruption = true
				done_command = true
			    end
			}
		    }
		})
	end

	return done_command, result
end

-- Execute a single command.
-- Return values are:
--  - a COMMAND_RESULT_* constant, or a value from 0 to 255
--    to indicate the exit code from the utility; and
--  - the output of the command, if requested (cmd.capture.)

command_execute = function(pr, cmd)
	local filename
	local cancelled = false
	local done_command = false
	local result, output = CmdChain.RESULT_NEVER_EXECUTED, ""

	if cmd.desc then
		pr:set_short_desc(cmd.desc)
	else
		pr:set_short_desc(cmd.display_cmdline)
	end
	cancelled = not pr:update()

	if App.option.confirm_execution then
		done_command = not Dialog.confirm(App.c,
		    "About to execute:\n\n" .. cmd.display_cmdline ..
		    "\n\nIs this acceptable?")
	end

	while not done_command do
		output = nil
		if cmd.log_mode ~= CmdChain.LOG_SILENT then
			App.log(",-<<< Executing `" .. cmd.display_cmdline .. "'")
		end
		if App.option.fake_execution then
			if cmd.log_mode ~= CmdChain.LOG_SILENT then
				App.log("| (not actually executed)")
			end
			result = 0
		else
			result, cancelled, output = pipe_loop(pr, cmd)
		end
		if cmd.log_mode ~= CmdChain.LOG_SILENT then
			App.log("`->>> Exit status: " .. result)
		end

		if cancelled then
			pr:stop()
			done_command, result = interruption_dialog(cmd, cancelled, result)
			pr:start()
		elseif cmd.failure_mode == CmdChain.FAILURE_IGNORE then
			result = 0
			done_command = true
		elseif (result ~= 0 and cmd.failure_mode ~= CmdChain.FAILURE_WARN) then
			pr:stop()
			done_command, result = interruption_dialog(cmd, cancelled, result)
			pr:start()
		else
			done_command = true
		end
	end
	if cmd.on_executed ~= nil then
		cmd.on_executed(cmd, result, output)
	end

	return result, output
end
