diff --git a/Cargo.toml b/Cargo.toml index f8811ff..ceb4b92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,6 @@ log4rs = "0.3.3" daggy = "0.4.0" rustc-serialize = "0.3.18" valico = "0.8.2" -time = "*" +chrono = "0.2" colored = "1.2" mustache = "*" \ No newline at end of file diff --git a/src/factotum/executor/mod.rs b/src/factotum/executor/mod.rs new file mode 100644 index 0000000..54c780d --- /dev/null +++ b/src/factotum/executor/mod.rs @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2016 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, and + * you may not use this file except in compliance with the Apache License + * Version 2.0. You may obtain a copy of the Apache License Version 2.0 at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Apache License Version 2.0 is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Apache License Version 2.0 for the specific language + * governing permissions and limitations there under. + */ + +use factotum::factfile::*; +use std::process::Command; +use std::time::{Duration, Instant}; +use std::thread; +use std::sync::mpsc; +use std::collections::HashMap; +use chrono::DateTime; +use chrono::UTC; + +enum TaskResult { + Ok(i32, Duration), + TerminateJobPlease(i32, Duration), + Error(Option, String) +} + +pub struct RunResult { + pub run_started: DateTime, + pub duration: Duration, + pub requests_job_termination: bool, + pub task_execution_error: Option, + pub stdout: Option, + pub stderr: Option, + pub return_code: i32 +} + +pub struct TaskExecutionResult { + pub name: String, + pub attempted: bool, + pub run_details: Option +} + +pub enum ExecutionResult { + AllTasksComplete(Vec), + EarlyFinishOk(Vec), + AbnormalTermination(Vec) +} + +#[inline] +fn drain_values(mut map:HashMap, tasks_in_order:&Vec>) -> Vec { + let mut task_seq:Vec = vec![]; + for task_level in tasks_in_order.iter() { + for task in task_level.iter() { + match map.remove(&task.name) { + Some(task_result) => task_seq.push(task_result), + _ => warn!("A task ({}) does not have an execution result? Skipping", task.name) + } + } + } + task_seq +} + +pub fn execute_factfile(factfile:&Factfile) -> ExecutionResult { + let tasks = factfile.get_tasks_in_order(); + + let mut task_results:HashMap = HashMap::new(); + for task_level in tasks.iter() { // TODO replace me with helper iterator + for task in task_level.iter() { + let new_task_result = TaskExecutionResult { name: task.name.clone(), attempted: false, run_details:None }; + task_results.insert(new_task_result.name.clone(), new_task_result ); + } + } + + for task_level in tasks.iter() { + // everything in a task "level" gets run together + // this isn't quite right in a dag sense, but I think practically it'll be ok (if not we'll come back to it) + let (tx, rx) = mpsc::channel::<(usize, TaskResult, Option, Option, DateTime)>(); + + for (idx,task) in task_level.iter().enumerate() { + info!("Running task '{}'!", task.name); + { + let tx = tx.clone(); + let args = format_args(&task.command, &task.arguments); + let executor = task.executor.to_string(); + let continue_job_codes = task.on_result.continue_job.clone(); + let terminate_job_codes = task.on_result.terminate_job.clone(); + let task_name = task.name.to_string(); + + thread::spawn(move || { + //println!("Executing task '{}'!", &task_name.cyan()); + let start_time = UTC::now(); + let (task_result, stdout, stderr) = execute_task(task_name, executor, args, terminate_job_codes, continue_job_codes); + tx.send((idx, task_result, stdout, stderr, start_time)).unwrap(); + }); + } + } + + let mut terminate_job_please = false; + let mut task_failed = false; + + for _ in 0..task_level.len() { + match rx.recv().unwrap() { + (idx, TaskResult::Ok(code, duration), stdout, stderr, start_time) => { + info!("'{}' returned {} in {:?}", task_level[idx].name, code, duration); // todo; sensible Display implementation of Duration + //println!("Task '{}' after {} returned {}", &task_level[idx].name.cyan(), duration, code); + let task_result:&mut TaskExecutionResult = task_results.get_mut(&task_level[idx].name).unwrap(); + task_result.attempted = true; + task_result.run_details = Some(RunResult { run_started: start_time, + duration: duration, + requests_job_termination: false, + task_execution_error: None, + stdout: stdout, + stderr: stderr, + return_code: code }); + }, + (idx, TaskResult::Error(code, msg), stdout, stderr, start_time) => { + warn!("task '{}' failed to execute!\n{}", task_level[idx].name, msg); + //println!("{}", &msg.red()); + let task_result:&mut TaskExecutionResult = task_results.get_mut(&task_level[idx].name).unwrap(); + task_result.attempted = true; + + if let Some(return_code) = code { + task_result.run_details = Some(RunResult { + run_started: start_time, + duration: Duration::from_secs(0), + requests_job_termination: false, + task_execution_error: Some(msg), + stdout: stdout, + stderr: stderr, + return_code: return_code }); + } + task_failed = true; + }, + (idx, TaskResult::TerminateJobPlease(code, duration), stdout, stderr, start_time) => { + warn!("job will stop as task '{}' called for termination (no-op) with code {}", task_level[idx].name, code); + //println!("Job will now stop as task '{}' ended with {}", &task_level[idx].name.cyan(), code); + + let task_result:&mut TaskExecutionResult = task_results.get_mut(&task_level[idx].name).unwrap(); + task_result.attempted = true; + task_result.run_details = Some(RunResult { + run_started: start_time, + duration: duration, + requests_job_termination: true, + task_execution_error: None, + stdout: stdout, + stderr: stderr, + return_code: code }); + + terminate_job_please = true; + } + } + } + + match (terminate_job_please, task_failed) { + (_, true) => { return ExecutionResult::AbnormalTermination(drain_values(task_results, &tasks)); }, + (true, false) => { return ExecutionResult::EarlyFinishOk(drain_values(task_results, &tasks)); }, + _ => {} + } + } + + ExecutionResult::AllTasksComplete(drain_values(task_results, &tasks)) +} + +fn execute_task(task_name:String, executor:String, args:String, terminate_job_codes:Vec, continue_job_codes:Vec) -> (TaskResult, Option, Option) { + if executor!="shell" { + return (TaskResult::Error(None, "Only shell executions are supported currently!".to_string()), None, None) + } else { + let run_start = Instant::now(); + info!("Executing sh -c {:?}", args); + match Command::new("sh").arg("-c").arg(args).output() { + Ok(r) => { + let run_duration = run_start.elapsed(); + let return_code = r.status.code().unwrap_or(1); // 1 will be returned if the process was killed by a signal + + let task_stdout: String = String::from_utf8_lossy(&r.stdout).trim_right().into(); + let task_stderr: String = String::from_utf8_lossy(&r.stderr).trim_right().into(); + + info!("task '{}' stdout:\n'{}'", task_name, task_stdout); + info!("task '{}' stderr:\n'{}'", task_name, task_stderr); + + let task_stdout_opt = if task_stdout.is_empty() { None } else { Some(task_stdout) }; + let task_stderr_opt = if task_stderr.is_empty() { None } else { Some(task_stderr) }; + + if terminate_job_codes.contains(&return_code) { + (TaskResult::TerminateJobPlease(return_code, run_duration), task_stdout_opt, task_stderr_opt) + } else if continue_job_codes.contains(&return_code) { + (TaskResult::Ok(return_code, run_duration), task_stdout_opt, task_stderr_opt) + } else { + let expected_codes = continue_job_codes.iter() + .map(|code| code.to_string()) + .collect::>() + .join(","); + (TaskResult::Error(Some(return_code), format!("the task exited with a value not specified in continue_job - {} (task expects one of the following return codes to continue [{}])", return_code, expected_codes)), + task_stdout_opt, + task_stderr_opt) + } + + }, + Err(message) => (TaskResult::Error(None, format!("Error executing process - {}", message)), None, None) + } + } +} + +fn format_args(command:&str, args:&Vec) -> String { + let arg_str = args.iter() + .map(|s| format!("\"{}\"", s)) + .collect::>() + .join(" "); + format!("{} {}", command, arg_str) +} + diff --git a/src/factotum/factfile/mod.rs b/src/factotum/factfile/mod.rs index 0fdde17..6329ab4 100644 --- a/src/factotum/factfile/mod.rs +++ b/src/factotum/factfile/mod.rs @@ -15,7 +15,9 @@ #[cfg(test)] mod tests; + use daggy::*; +use factotum::sequencer; pub struct Factfile { pub name:String, @@ -53,12 +55,12 @@ impl Factfile { pub fn get_tasks_in_order<'a>(&'a self) -> Vec> { let mut tree:Vec> = vec![]; - super::get_tasks_in_order(&self.dag, &self.dag.children(self.root).iter(&self.dag).map(|(_, node_idx)| node_idx).collect(), &mut tree); + sequencer::get_tasks_in_order(&self.dag, &self.dag.children(self.root).iter(&self.dag).map(|(_, node_idx)| node_idx).collect(), &mut tree); tree } fn find_task_by_name(&self, name:&str) -> Option<(NodeIndex, &Task)> { - super::find_task_recursive(&self.dag, name, self.root) + sequencer::find_task_recursive(&self.dag, name, self.root) } pub fn add_task_obj(&mut self, task:&Task) { diff --git a/src/factotum/mod.rs b/src/factotum/mod.rs index 1d2a6ba..bef42e9 100644 --- a/src/factotum/mod.rs +++ b/src/factotum/mod.rs @@ -14,56 +14,11 @@ */ pub mod factfile; -pub mod fileparser; -pub mod runner; +pub mod parser; +pub mod executor; +pub mod sequencer; + #[cfg(test)] mod tests; -use factotum::factfile::Task; -use daggy::*; - -fn find_task_recursive<'a>(dag: &'a Dag, name:&str, start:NodeIndex) -> Option<(NodeIndex, &'a Task)> { - if dag.children(start).iter(&dag).count() != 0 { - if let Some((_, node)) = dag.children(start).find(&dag, |g, _, n| g[n].name == name) { - return Some((node, &dag[node])) - } else { - for (_, child_node) in dag.children(start).iter(&dag) { - if let Some(v) = find_task_recursive(dag, name, child_node) { - return Some(v); - } - } - None - } - } else { - None - } -} - -fn get_tasks_in_order<'a>(dag: &'a Dag, start:&Vec, tree:&mut Vec>) { - let mut row:Vec<&Task> = vec![]; - - for idx in start { - for row in tree.iter_mut() { - let posn = row.iter().position(|s| s.name==dag[*idx].name); - if let Some(remove_idx) = posn { - row.remove(remove_idx); - } - } - let no_dups = !row.iter().any(|s| s.name==dag[*idx].name); - if no_dups { - row.push(&dag[*idx]); - } - } - tree.push(row); - - let mut children:Vec = vec![]; - for parent in start.iter() { - for (_, node_index) in dag.children(*parent).iter(&dag) { - children.push(node_index); - } - } - if children.len() != 0 { - get_tasks_in_order(dag, &children, tree); - } -} diff --git a/src/factotum/fileparser/mod.rs b/src/factotum/parser/mod.rs similarity index 60% rename from src/factotum/fileparser/mod.rs rename to src/factotum/parser/mod.rs index 60d87cc..c58c973 100644 --- a/src/factotum/fileparser/mod.rs +++ b/src/factotum/parser/mod.rs @@ -15,17 +15,16 @@ #[cfg(test)] mod tests; +mod templater; +mod schemavalidator; use std::io::prelude::*; use std::fs::File; use rustc_serialize::json::{self, Json, error_str}; use rustc_serialize::json::ParserError::{self, SyntaxError, IoError}; use super::factfile; -use valico::json_schema; -use valico::common::error::*; -use std::error::Error; -extern crate mustache; +use std::error::Error; pub fn parse(factfile:&str, env:Option) -> Result { info!("reading {} into memory", factfile); @@ -41,53 +40,29 @@ pub fn inflate_env(env:&str) -> Result { Json::from_str(env).map_err(|err| format!("Supplied environment/config '{}' is not valid JSON: {}", env, Error::description(&err))) } -// there must be a way to do this normally -fn get_human_readable_parse_error(e:ParserError) -> String { - match e { - SyntaxError(code, line, col) => format!("{} at line {}, column {}", error_str(code), line, col), - IoError(msg) => unreachable!("Unexpected IO error: {}", msg) - } -} - fn parse_str(json:&str, from_filename:&str, env:Option) -> Result { info!("parsing json:\n{}", json); - let factotum_schema_str: &'static str = include_str!("./jsonschemas/factotum.json"); - let factotum_schema = if let Ok(fs) = Json::from_str(factotum_schema_str) { - fs - } else { - unreachable!("The JSON schema inside factotum is not valid json"); - }; - - let mut scope = json_schema::Scope::new(); - let schema = match scope.compile_and_return(factotum_schema.clone(),false) { - Ok(s) => s, - Err(msg) => { unreachable!("The JSON schema inside factotum could not be built! {:?}", msg) } - }; - - let json_tree = try!(Json::from_str(json).map_err(|e| format!("The factfile '{}' is not valid JSON: {}", from_filename, get_human_readable_parse_error(e)))); - info!("'{}' is valid JSON!", from_filename); - let json_schema_validation = schema.validate(&json_tree); - if json_schema_validation.is_valid() { - info!("'{}' matches the factotum schema definition!", from_filename); - - let conf = if let Some(c) = env { - info!("inflating config:\n{}", c); - Some(try!(inflate_env(&c))) - } else { - info!("no config specified!"); - None - }; - - parse_valid_json(json, conf).map_err(|msg| format!("'{}' is not a valid factotum factfile: {}", from_filename, msg)) - } else { - let errors_str = json_schema_validation.errors.iter() - .map(|e| format!("'{}' - {}{}", e.get_path(), - e.get_title(), - match e.get_detail() { Some(str) => format!(" ({})", str), _ => "".to_string() } )) - .collect::>() - .join("\n"); - Err(format!("'{}' is not a valid factotum factfile: {}", from_filename, errors_str)) + let validation_result = schemavalidator::validate_against_factfile_schema(json); + + match validation_result { + Ok(_) => { + info!("'{}' matches the factotum schema definition!", from_filename); + + let conf = if let Some(c) = env { + info!("inflating config:\n{}", c); + Some(try!(inflate_env(&c))) + } else { + info!("no config specified!"); + None + }; + + parse_valid_json(json, conf).map_err(|msg| format!("'{}' is not a valid factotum factfile: {}", from_filename, msg)) + }, + Err(msg) => { + info!("'{}' failed to match factfile schema definition!", from_filename); + Err(format!("'{}' is not a valid factotum factfile: {}", from_filename, msg)) + } } } @@ -123,12 +98,7 @@ struct FactfileTaskResultFormat { continueJob: Vec } -fn mustache_str(template:&str, env:&Json) -> Result { - let compiled_template = mustache::compile_str(&template); - let mut bytes = vec![]; - try!(compiled_template.render(&mut bytes, &env).map_err(|e| format!("Error rendering template: {}", Error::description(&e)))); - String::from_utf8(bytes).map_err(|e| format!("Error inflating rendered template to utf8: {}", Error::description(&e))) -} + fn parse_valid_json(file:&str, conf:Option) -> Result { let schema: SelfDescribingJson = try!(json::decode(file).map_err(|e| e.to_string())); @@ -153,15 +123,20 @@ fn parse_valid_json(file:&str, conf:Option) -> Result = file_task.dependsOn.iter().map(AsRef::as_ref).collect(); let args:Vec<&str> = decorated_args.iter().map(AsRef::as_ref).collect(); diff --git a/src/factotum/fileparser/jsonschemas/factotum.json b/src/factotum/parser/schemavalidator/jsonschemas/factotum.json similarity index 100% rename from src/factotum/fileparser/jsonschemas/factotum.json rename to src/factotum/parser/schemavalidator/jsonschemas/factotum.json diff --git a/src/factotum/parser/schemavalidator/mod.rs b/src/factotum/parser/schemavalidator/mod.rs new file mode 100644 index 0000000..e1f7c27 --- /dev/null +++ b/src/factotum/parser/schemavalidator/mod.rs @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2016 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, and + * you may not use this file except in compliance with the Apache License + * Version 2.0. You may obtain a copy of the Apache License Version 2.0 at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Apache License Version 2.0 is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Apache License Version 2.0 for the specific language + * governing permissions and limitations there under. + */ + +#[cfg(test)] +mod tests; + +use valico::json_schema; +use valico::common::error::*; +use rustc_serialize::json::{self, Json, error_str}; + +use std::error::Error; +use rustc_serialize::json::ParserError::{self, SyntaxError, IoError}; + +// there must be a way to do this normally +fn get_human_readable_parse_error(e:ParserError) -> String { + match e { + SyntaxError(code, line, col) => format!("{} at line {}, column {}", error_str(code), line, col), + IoError(msg) => unreachable!("Unexpected IO error: {}", msg) + } +} + +pub fn validate_schema(json:&str, schema:&str) -> Result<(), String> { + let mut scope = json_schema::Scope::new(); + let json_schema = try!(Json::from_str(schema).map_err(|e| format!("Schema is invalid json: {:?}", e))); + let compiled_schema = try!(scope.compile_and_return(json_schema,false).map_err(|e| format!("Failed to compile json schema: {:?}", e))); + + let json_tree = try!(Json::from_str(json).map_err(|e| format!("invalid JSON - {}", get_human_readable_parse_error(e)))); + info!("'{}' is valid JSON!", json); + let json_schema_validation = compiled_schema.validate(&json_tree); + + if json_schema_validation.is_valid() == true { + Ok(()) + } else { + let errors_str = json_schema_validation.errors.iter() + .map(|e| format!("'{}' - {}{}", e.get_path(), + e.get_title(), + match e.get_detail() { Some(str) => format!(" ({})", str), _ => "".to_string() } )) + .collect::>() + .join("\n"); + Err(format!("{}",errors_str)) + } +} + +pub fn validate_against_factfile_schema(json:&str) -> Result<(), String> { + let factotum_schema_str: &'static str = include_str!("jsonschemas/factotum.json"); + + validate_schema(json, factotum_schema_str) +} + +/* +//.map_err(|e| format!("The factfile '{}' is not valid JSON: {}", from_filename, get_human_readable_parse_error(e)))); + + put as test + let factotum_schema = if let Ok(fs) = Json::from_str(factotum_schema_str) { + fs + } else { + unreachable!("The JSON schema inside factotum is not valid json"); + }; + */ \ No newline at end of file diff --git a/src/factotum/parser/schemavalidator/tests.rs b/src/factotum/parser/schemavalidator/tests.rs new file mode 100644 index 0000000..ec07daf --- /dev/null +++ b/src/factotum/parser/schemavalidator/tests.rs @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2016 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, and + * you may not use this file except in compliance with the Apache License + * Version 2.0. You may obtain a copy of the Apache License Version 2.0 at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Apache License Version 2.0 is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Apache License Version 2.0 for the specific language + * governing permissions and limitations there under. + */ + \ No newline at end of file diff --git a/src/factotum/parser/templater/mod.rs b/src/factotum/parser/templater/mod.rs new file mode 100644 index 0000000..4dd812b --- /dev/null +++ b/src/factotum/parser/templater/mod.rs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2016 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, and + * you may not use this file except in compliance with the Apache License + * Version 2.0. You may obtain a copy of the Apache License Version 2.0 at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Apache License Version 2.0 is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Apache License Version 2.0 for the specific language + * governing permissions and limitations there under. + */ + +extern crate mustache; + +#[cfg(test)] +mod tests; + +use std::error::Error; +use rustc_serialize::json::Json; + +pub fn decorate_str(template:&str, env:&Json) -> Result { + let compiled_template = mustache::compile_str(&template); + let mut bytes = vec![]; + try!(compiled_template.render(&mut bytes, &env).map_err(|e| format!("Error rendering template: {}", Error::description(&e)))); + String::from_utf8(bytes).map_err(|e| format!("Error inflating rendered template to utf8: {}", Error::description(&e))) +} \ No newline at end of file diff --git a/src/factotum/parser/templater/tests.rs b/src/factotum/parser/templater/tests.rs new file mode 100644 index 0000000..d091a15 --- /dev/null +++ b/src/factotum/parser/templater/tests.rs @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2016 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, and + * you may not use this file except in compliance with the Apache License + * Version 2.0. You may obtain a copy of the Apache License Version 2.0 at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Apache License Version 2.0 is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Apache License Version 2.0 for the specific language + * governing permissions and limitations there under. + */ + +use factotum::parser::templater::*; +use rustc_serialize::json::Json; + +fn from_json(json:&str) -> Json { + Json::from_str(json).unwrap() +} + +#[test] +fn decorated_string_works() { + assert_eq!("hello Ed!".to_string(), decorate_str("hello {{name}}!", &from_json("{\"name\":\"Ed\"}")).unwrap()); +} + +#[test] +fn decorated_nested_string_works() { + assert_eq!("hello Ted!".to_string(), decorate_str("hello {{person.name}}!", &from_json("{\"person\": { \"name\":\"Ted\" } }")).unwrap()) +} \ No newline at end of file diff --git a/src/factotum/fileparser/tests.rs b/src/factotum/parser/tests.rs similarity index 95% rename from src/factotum/fileparser/tests.rs rename to src/factotum/parser/tests.rs index dfe0dae..5dd1f49 100644 --- a/src/factotum/fileparser/tests.rs +++ b/src/factotum/parser/tests.rs @@ -13,7 +13,7 @@ * governing permissions and limitations there under. */ -use factotum::fileparser::*; +use factotum::parser::*; use rustc_serialize::json::Json; #[inline] @@ -35,7 +35,7 @@ fn invalid_files_err() { fn invalid_json_err() { let res = parse(&resource("invalid_json.factotum"), None); if let Err(msg) = res { - assert_eq!(msg,format!("The factfile '{}' is not valid JSON: invalid syntax at line 1, column 3", resource("invalid_json.factotum")).to_string()) + assert_eq!(msg,format!("'{}' is not a valid factotum factfile: invalid JSON - invalid syntax at line 1, column 3", resource("invalid_json.factotum")).to_string()) } else { panic!("the file is invalid json - the test should have failed"); } diff --git a/src/factotum/runner/mod.rs b/src/factotum/runner/mod.rs deleted file mode 100644 index 9a6ef53..0000000 --- a/src/factotum/runner/mod.rs +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright (c) 2016 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, and - * you may not use this file except in compliance with the Apache License - * Version 2.0. You may obtain a copy of the Apache License Version 2.0 at - * http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Apache License Version 2.0 is distributed on an "AS - * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - * implied. See the Apache License Version 2.0 for the specific language - * governing permissions and limitations there under. - */ - -use factotum::factfile::*; -use std::process::Command; -use time::PreciseTime; -use time::Duration; -use std::thread; -use std::sync::mpsc; -use std::collections::HashMap; -use std::hash::Hash; -use colored::*; - -enum TaskResult { - Ok(i32, Duration), - TerminateJobPlease(i32, Duration), - InvalidTask(i32, String) -} - -#[allow(dead_code)] // remove me -pub struct RunResult { - duration: Duration, - requests_job_termination: bool, - task_execution_error: Option<&'static str>, - stdout: Option, - stderr: Option, - return_code: i32 -} - -pub struct TaskExecutionResult { - pub name: String, - pub run: bool, - result: Option -} - -pub enum ExecutionResult { - AllTasksComplete(Vec), - EarlyFinishOk(Vec), - AbnormalTermination(Vec) -} - -#[inline] -fn drain_values(mut map:HashMap) -> Vec { - map - .drain() - .map(|(_, v)| v) - .collect::>() -} - -pub fn execute_factfile(factfile:&Factfile) -> ExecutionResult { - let tasks = factfile.get_tasks_in_order(); - - let mut task_results:HashMap = HashMap::new(); - for task_level in tasks.iter() { // TODO replace me with helper iterator - for task in task_level.iter() { - let new_task_result = TaskExecutionResult { name: task.name.clone(), run: false, result:None }; - task_results.insert(new_task_result.name.clone(), new_task_result ); - } - } - - for task_level in tasks.iter() { - // everything in a task "level" gets run together - // this isn't quite right in a dag sense, but I think practically it'll be ok (if not we'll come back to it) - let (tx, rx) = mpsc::channel::<(usize, TaskResult)>(); - - for (idx,task) in task_level.iter().enumerate() { - info!("Running task '{}'!", task.name); - { - let tx=tx.clone(); - let args = format_args(&task.command, &task.arguments); - let executor = task.executor.to_string(); - let continue_job_codes = task.on_result.continue_job.clone(); - let terminate_job_codes = task.on_result.terminate_job.clone(); - let task_name = task.name.to_string(); - - thread::spawn(move || { - println!("Executing task '{}'!", &task_name.cyan()); - let task_result = execute_task(task_name, executor, args, terminate_job_codes, continue_job_codes); - tx.send((idx,task_result)).unwrap(); - }); - } - } - - let mut terminate_job_please = false; - let mut task_failed = false; - - for _ in 0..task_level.len() { - match rx.recv().unwrap() { - (idx, TaskResult::Ok(code, duration)) => { - info!("'{}' returned {} in {}", task_level[idx].name, code, duration); - println!("Task '{}' after {} returned {}", &task_level[idx].name.cyan(), duration, code); - let task_result:&mut TaskExecutionResult = task_results.get_mut(&task_level[idx].name).unwrap(); - task_result.run = true; - task_result.result = Some(RunResult { duration: duration, - requests_job_termination: false, - task_execution_error: None, - stdout: None, - stderr: None, - return_code: code }); - }, - (idx, TaskResult::InvalidTask(code, msg)) => { - warn!("task '{}' failed to execute!\n{}", task_level[idx].name, msg); - let msg = format!("task '{}' failed to execute!\n{}", task_level[idx].name, msg); - println!("{}", &msg.red()); - let task_result:&mut TaskExecutionResult = task_results.get_mut(&task_level[idx].name).unwrap(); - task_result.run = false; - task_result.result = Some(RunResult { duration: Duration::seconds(0), - requests_job_termination: false, - task_execution_error: None, - stdout: None, - stderr: None, - return_code: code }); - - task_failed = true; - }, - (idx, TaskResult::TerminateJobPlease(code, duration)) => { - warn!("job will stop as task '{}' called for termination (no-op) with code {}", task_level[idx].name, code); - println!("Job will now stop as task '{}' ended with {}", &task_level[idx].name.cyan(), code); - - let task_result:&mut TaskExecutionResult = task_results.get_mut(&task_level[idx].name).unwrap(); - task_result.run = true; - task_result.result = Some(RunResult { duration: duration, - requests_job_termination: true, - task_execution_error: None, - stdout: None, - stderr: None, - return_code: code }); - - terminate_job_please = true; - } - } - } - - match (terminate_job_please, task_failed) { - (_, true) => { return ExecutionResult::AbnormalTermination(drain_values(task_results)); }, - (true, false) => { return ExecutionResult::EarlyFinishOk(drain_values(task_results)); }, - _ => {} - } - } - - ExecutionResult::AllTasksComplete(drain_values(task_results)) -} - - - -fn execute_task(task_name:String, executor:String, args:String, terminate_job_codes:Vec, continue_job_codes:Vec) -> TaskResult { - if executor!="shell" { - TaskResult::InvalidTask(101, "Only shell executions are supported currently!".to_string()) - } else { - let start_time = PreciseTime::now(); - match Command::new("sh").arg("-c").arg(args).output() { - Ok(r) => { - - let stop_time = PreciseTime::now(); - let run_duration = start_time.to(stop_time); - let return_code = r.status.code().unwrap_or(1); // 1 will be returned if the process was killed by a signal - - let task_stdout = String::from_utf8_lossy(&r.stdout); - let task_stderr = String::from_utf8_lossy(&r.stderr); - info!("task '{}' stdout:\n{}", task_name, task_stdout); - info!("task '{}' stderr:\n{}", task_name, task_stderr); - - if task_stdout.len() != 0 { - println!("Task '{}' wrote the following to STDOUT:\n{}", &task_name.cyan(), &task_stdout.trim_right().green()); - } - - if task_stderr.len() != 0 { - println!("Task '{}' wrote the following to STDERR:\n{}", &task_name.cyan(), &task_stderr.trim_right().red()); - } - - if terminate_job_codes.contains(&return_code) { - TaskResult::TerminateJobPlease(return_code, run_duration) - } else if continue_job_codes.contains(&return_code) { - TaskResult::Ok(return_code, run_duration) - } else { - let expected_codes = continue_job_codes.iter() - .map(|code| code.to_string()) - .collect::>() - .join(","); - TaskResult::InvalidTask(return_code, format!("The task exited with a value not specified in continue_job: {} (task expects one of the following return codes to continue [{}])", return_code, expected_codes)) - } - - }, - Err(message) => TaskResult::InvalidTask(101, format!("Error executing process: {}", message)) - } - } -} - -fn format_args(command:&str, args:&Vec) -> String { - let arg_str = args.iter() - .map(|s| format!("\"{}\"", s)) - .collect::>() - .join(" "); - format!("{} {}", command, arg_str) -} - diff --git a/src/factotum/sequencer/mod.rs b/src/factotum/sequencer/mod.rs new file mode 100644 index 0000000..c4a2147 --- /dev/null +++ b/src/factotum/sequencer/mod.rs @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2016 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, and + * you may not use this file except in compliance with the Apache License + * Version 2.0. You may obtain a copy of the Apache License Version 2.0 at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Apache License Version 2.0 is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Apache License Version 2.0 for the specific language + * governing permissions and limitations there under. + */ + +use factotum::factfile::Task; +use daggy::*; + +#[cfg(test)] +mod tests; + +pub fn get_tasks_in_order<'a>(dag: &'a Dag, start:&Vec, tree:&mut Vec>) { + let mut row:Vec<&Task> = vec![]; + + for idx in start { + for row in tree.iter_mut() { + let posn = row.iter().position(|s| s.name==dag[*idx].name); + if let Some(remove_idx) = posn { + row.remove(remove_idx); + } + } + let no_dups = !row.iter().any(|s| s.name==dag[*idx].name); + if no_dups { + row.push(&dag[*idx]); + } + } + tree.push(row); + + let mut children:Vec = vec![]; + for parent in start.iter() { + for (_, node_index) in dag.children(*parent).iter(&dag) { + children.push(node_index); + } + } + + if children.len() != 0 { + get_tasks_in_order(dag, &children, tree); + } +} + + +pub fn find_task_recursive<'a>(dag: &'a Dag, name:&str, start:NodeIndex) -> Option<(NodeIndex, &'a Task)> { + if dag.children(start).iter(&dag).count() != 0 { + if let Some((_, node)) = dag.children(start).find(&dag, |g, _, n| g[n].name == name) { + return Some((node, &dag[node])) + } else { + for (_, child_node) in dag.children(start).iter(&dag) { + if let Some(v) = find_task_recursive(dag, name, child_node) { + return Some(v); + } + } + None + } + } else { + None + } +} diff --git a/src/factotum/sequencer/tests.rs b/src/factotum/sequencer/tests.rs new file mode 100644 index 0000000..73f0add --- /dev/null +++ b/src/factotum/sequencer/tests.rs @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2016 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, and + * you may not use this file except in compliance with the Apache License + * Version 2.0. You may obtain a copy of the Apache License Version 2.0 at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Apache License Version 2.0 is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the Apache License Version 2.0 for the specific language + * governing permissions and limitations there under. + */ + +use factotum::factfile::*; +use daggy::*; +use factotum::tests::*; + +#[test] +fn recursive_find_ok() { + let mut dag = Dag::::new(); + let parent = make_task("root", &vec![]); + + let idx = dag.add_node(parent); + + let task_child1 = make_task("child1", &vec![]); + dag.add_child(idx, (), task_child1); + + let task_child2 = make_task("child2", &vec![]); + let (_, child2) = dag.add_child(idx, (), task_child2); + + let grandchild_node = make_task("grandchild", &vec![]); + let (_, grandchild_idx) = dag.add_child(child2, (), grandchild_node); + + if let Some((found_idx, found_node)) = super::find_task_recursive(&dag, "grandchild", idx) { + assert_eq!(found_idx, grandchild_idx); + assert_eq!(found_node.name, "grandchild"); + } else { + panic!("couldn't find value"); + } +} + +#[test] +fn get_tasks_in_order_basic() { + let mut dag = Dag::::new(); + + let parent = make_task("root", &vec![]); + + let root_idx:NodeIndex = dag.add_node(parent); + + let child1 = make_task("child1", &vec![]); + + let child2 = make_task("child2", &vec![]); + + dag.add_child(root_idx, (), child1); + let (_, child2_idx) = dag.add_child(root_idx, (), child2); + + let grandchild = make_task("grandchild", &vec![]); + dag.add_child(child2_idx, (), grandchild); + + let expected = vec![vec!["root"], + vec!["child2", "child1"], + vec!["grandchild"]]; + + let mut actual:Vec> = vec![]; + super::get_tasks_in_order(&dag, &vec![root_idx], &mut actual); + + compare_tasks(expected,actual); +} + diff --git a/src/factotum/tests.rs b/src/factotum/tests.rs index 86071f4..fdacab3 100644 --- a/src/factotum/tests.rs +++ b/src/factotum/tests.rs @@ -1,73 +1,7 @@ -/* - * Copyright (c) 2016 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, and - * you may not use this file except in compliance with the Apache License - * Version 2.0. You may obtain a copy of the Apache License Version 2.0 at - * http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Apache License Version 2.0 is distributed on an "AS - * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - * implied. See the Apache License Version 2.0 for the specific language - * governing permissions and limitations there under. - */ - -use factotum::factfile::*; +use factotum::factfile::Task; +use factotum::factfile::OnResult; use daggy::*; -#[test] -fn recursive_find_ok() { - let mut dag = Dag::::new(); - let parent = make_task("root", &vec![]); - - let idx = dag.add_node(parent); - - let task_child1 = make_task("child1", &vec![]); - dag.add_child(idx, (), task_child1); - - let task_child2 = make_task("child2", &vec![]); - let (_, child2) = dag.add_child(idx, (), task_child2); - - let grandchild_node = make_task("grandchild", &vec![]); - let (_, grandchild_idx) = dag.add_child(child2, (), grandchild_node); - - if let Some((found_idx, found_node)) = super::find_task_recursive(&dag, "grandchild", idx) { - assert_eq!(found_idx, grandchild_idx); - assert_eq!(found_node.name, "grandchild"); - } else { - panic!("couldn't find value"); - } -} - -#[test] -fn get_tasks_in_order_basic() { - let mut dag = Dag::::new(); - - let parent = make_task("root", &vec![]); - - let root_idx:NodeIndex = dag.add_node(parent); - - let child1 = make_task("child1", &vec![]); - - let child2 = make_task("child2", &vec![]); - - dag.add_child(root_idx, (), child1); - let (_, child2_idx) = dag.add_child(root_idx, (), child2); - - let grandchild = make_task("grandchild", &vec![]); - dag.add_child(child2_idx, (), grandchild); - - let expected = vec![vec!["root"], - vec!["child2", "child1"], - vec!["grandchild"]]; - - let mut actual:Vec> = vec![]; - super::get_tasks_in_order(&dag, &vec![root_idx], &mut actual); - - compare_tasks(expected,actual); -} - pub fn compare_tasks(expected:Vec>, actual:Vec>) { for i in 0..expected.len() { for j in 0..expected[i].len() { diff --git a/src/main.rs b/src/main.rs index 39a4e5e..142a398 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,13 +20,18 @@ extern crate getopts; extern crate daggy; extern crate rustc_serialize; extern crate valico; -extern crate time; extern crate colored; +extern crate chrono; use getopts::Options; use std::env; use std::fs; -use factotum::runner::ExecutionResult; +use factotum::executor::ExecutionResult; +use factotum::executor::TaskExecutionResult; +use factotum::executor::RunResult; +use colored::*; +use std::time::Duration; +use chrono::UTC; mod factotum; @@ -35,24 +40,185 @@ const PROC_PARSE_ERROR: i32 = 1; const PROC_EXEC_ERROR: i32 = 2; const PROC_OTHER_ERROR: i32 = 3; +// macro to simplify printing to stderr +// https://github.com/rust-lang/rfcs/issues/1078 +macro_rules! print_err { + ($($arg:tt)*) => ( + { + use std::io::prelude::*; + if let Err(e) = write!(&mut ::std::io::stderr(), "{}\n", format_args!($($arg)*)) { + panic!("Failed to write to stderr.\ + \nOriginal error output: {}\ + \nSecondary error writing to stderr: {}", format!($($arg)*), e); + } + } + ) +} + fn print_usage(program:&str, opts:Options) { let brief = format!("Usage: {} FILE", program); print!("{}", opts.usage(&brief)) } +fn get_duration_as_string(d:&Duration) -> String { + // duration doesn't support the normal display format + // for now lets put together something that produces some sensible output + // e.g. + // if it's under a minute, show the number of seconds and nanos + // if it's under an hour, show the number of minutes, and seconds + // if it's over an hour, show the number of hours, minutes and seconds + const NANOS_ONE_SEC: f64 = 1000000000_f64; + const SECONDS_ONE_HOUR: u64 = 3600; + + if d.as_secs() < 60 { + let mut seconds: f64 = d.as_secs() as f64; + seconds += d.subsec_nanos() as f64 / NANOS_ONE_SEC; + format!("{:.1}s", seconds) + } else if d.as_secs() >= 60 && d.as_secs() < SECONDS_ONE_HOUR { + // ignore nanos here.. + let secs = d.as_secs() % 60; + let minutes = (d.as_secs() / 60) % 60; + format!("{}m, {}s", minutes, secs) + } else { + let secs = d.as_secs() % 60; + let minutes = (d.as_secs() / 60) % 60; + let hours = d.as_secs() / SECONDS_ONE_HOUR; + format!("{}h, {}m, {}s", hours, minutes, secs) + } +} + +fn get_task_result_line_str(task_result:&TaskExecutionResult) -> (String, Option) { + + let (opening_line, stdout, stderr, summary_line) = if let Some(ref run_result) = task_result.run_details { + // we know tasks with run details were attempted + + let opener = format!("Task '{}' was started at {}\n", task_result.name.cyan(), run_result.run_started); + + let output = match run_result.stdout { + Some(ref o) => Some(format!("Task '{}' stdout:\n{}\n", task_result.name.cyan(), o.trim_right().bold())), + None => None + }; + + let errors = match run_result.stderr { + Some(ref e) => Some(format!("Task '{}' stderr:\n{}\n", task_result.name.cyan(), e.trim_right().red())), + None => None + }; + + let summary = if let Some(ref run_error) = run_result.task_execution_error { + let mut failure_str = "Task '".red().to_string(); + failure_str.push_str(&format!("{}", task_result.name.cyan())); + failure_str.push_str(&format!("': failed after {}. Reason: {}", get_duration_as_string(&run_result.duration), run_error).red().to_string()); + failure_str + } else { + let mut success_str = "Task '".green().to_string(); + success_str.push_str(&format!("{}", task_result.name.cyan())); + success_str.push_str(&format!("': succeeded after {}", get_duration_as_string(&run_result.duration)).green().to_string()); + success_str + }; + + (opener, output, errors, summary) + + } else { + // tasks without run details may have been unable to start (some internal error) + // or skipped because a prior task errored or NOOPed + + let reason_for_not_running = if task_result.attempted { + "Factotum could not start the task".red().to_string() + } else { + "skipped".to_string() + }; + + let opener = format!("Task '{}': {}!\n", task_result.name.cyan(), reason_for_not_running); + (opener, None, None, String::from("")) + }; + + let mut result = opening_line; + if let Some(o) = stdout { + result.push_str(&o); + } + + if summary_line.len() > 0 { + result.push_str(&format!("{}\n", summary_line)); + } + + return (result, stderr); +} + +fn get_task_results_str(task_results:&Vec) -> (String, String) { + let mut stderr = String::new(); + let mut stdout = String::new(); + + let mut total_run_time = Duration::new(0,0); + let mut executed = 0; + + for task in task_results.iter() { + let (task_stdout, task_stderr) = get_task_result_line_str(task); + stdout.push_str(&task_stdout); + + if let Some(task_stderr_str) = task_stderr { + stderr.push_str(&task_stderr_str); + } + + if let Some(ref run_result) = task.run_details { + total_run_time = total_run_time + run_result.duration; + executed += 1; + } + } + + let summary = format!("{}/{} tasks run in {}\n", executed, task_results.len(), get_duration_as_string(&total_run_time)); + stdout.push_str(&summary.green().to_string()); + + (stdout,stderr) +} + fn parse_file(factfile:&str, env:Option) -> i32 { - match factotum::fileparser::parse(factfile, env) { + match factotum::parser::parse(factfile, env) { Ok(job) => { - match factotum::runner::execute_factfile(&job) { - ExecutionResult::AllTasksComplete(_) => PROC_SUCCESS, - ExecutionResult::EarlyFinishOk(_) => PROC_SUCCESS, - ExecutionResult::AbnormalTermination(res) => { - let incomplete_tasks = res.iter() - .filter(|r| !r.run) - .map(|r| r.name.clone()) + match factotum::executor::execute_factfile(&job) { // todo this is a stub, and not efficient (calls many times) + ExecutionResult::AllTasksComplete(tasks) => { + let (stdout_summary, stderr_summary) = get_task_results_str(&tasks); + print!("{}", stdout_summary); + if !stderr_summary.trim_right().is_empty() { + print_err!("{}", stderr_summary.trim_right()); + } + PROC_SUCCESS + }, + ExecutionResult::EarlyFinishOk(tasks) => { + let (stdout_summary, stderr_summary) = get_task_results_str(&tasks); + print!("{}", stdout_summary); + if !stderr_summary.trim_right().is_empty() { + print_err!("{}", stderr_summary.trim_right()); + } + let incomplete_tasks = tasks.iter() + .filter(|r| !r.attempted) + .map(|r| format!("'{}'", r.name.cyan())) .collect::>() .join(", "); - println!("\nFactotum job executed abnormally - the following tasks were not completed: {}!", incomplete_tasks); + let stop_requesters = tasks.iter() + .filter(|r| r.run_details.is_some() && r.run_details.as_ref().unwrap().requests_job_termination) + .map(|r| format!("'{}'", r.name.cyan())) + .collect::>() + .join(", "); + println!("Factotum job finished early as a task ({}) requested an early finish. The following tasks were not run: {}.", stop_requesters, incomplete_tasks); + PROC_SUCCESS + }, + ExecutionResult::AbnormalTermination(tasks) => { + let (stdout_summary, stderr_summary) = get_task_results_str(&tasks); + print!("{}", stdout_summary); + if !stderr_summary.trim_right().is_empty() { + print_err!("{}", stderr_summary.trim_right()); + } + let incomplete_tasks = tasks.iter() + .filter(|r| !r.attempted) + .map(|r| format!("'{}'", r.name.cyan())) + .collect::>() + .join(", "); + let failed_tasks = tasks.iter() + .filter(|r| r.run_details.is_some() && r.run_details.as_ref().unwrap().task_execution_error.is_some()) + .map(|r| format!("'{}'", r.name.cyan())) + .collect::>() + .join(", "); + println!("Factotum job executed abnormally as a task ({}) failed - the following tasks were not run: {}!", failed_tasks, incomplete_tasks); return PROC_EXEC_ERROR; } } @@ -122,4 +288,159 @@ fn have_valid_config() { if let Err(errs) = get_log_config() { panic!("config not building correctly! {:?}", errs); } +} + +#[test] +fn get_duration_under_minute() { + assert_eq!(get_duration_as_string(&Duration::new(2, 500000099)), "2.5s".to_string()); + assert_eq!(get_duration_as_string(&Duration::new(0, 0)), "0.0s".to_string()); +} + +#[test] +fn get_duration_under_hour() { + assert_eq!(get_duration_as_string(&Duration::new(62, 500000099)), "1m, 2s".to_string()); // drop nanos for minute level precision + assert_eq!(get_duration_as_string(&Duration::new(59*60+59, 0)), "59m, 59s".to_string()); +} + +#[test] +fn get_duration_with_hours() { + assert_eq!(get_duration_as_string(&Duration::new(3600, 0)), "1h, 0m, 0s".to_string()); + assert_eq!(get_duration_as_string(&Duration::new(3600*10+63, 0)), "10h, 1m, 3s".to_string()); +} + +#[test] +fn test_get_task_result_line_str() { + let dt = UTC::now(); + let sample_task = TaskExecutionResult { + name: String::from("hello world"), + attempted: true, + run_details: Some(RunResult { + run_started: dt, + duration: Duration::from_secs(20), + task_execution_error: None, + requests_job_termination: false, + stdout: Some(String::from("hello world")), + stderr: None, + return_code: 0 + }) + }; + + let expected = format!("Task '{}' was started at {}\nTask '{}' stdout:\n{}\n{}{}{}\n", "hello world".cyan(), dt, "hello world".cyan(), "hello world".bold(), "Task '".green(), "hello world".cyan(), "': succeeded after 20.0s".green()); + let (result_stdout, result_stderr) = get_task_result_line_str(&sample_task); + assert_eq!(result_stdout, expected); + assert_eq!(result_stderr, None); + + let sample_task_stdout = TaskExecutionResult { + name: String::from("hello world"), + attempted: true, + run_details: Some(RunResult { + run_started: dt, + duration: Duration::from_secs(20), + task_execution_error: None, + requests_job_termination: false, + stdout: Some(String::from("hello world")), + stderr: Some(String::from("There's errors")), + return_code: 0 + }) + }; + + assert_eq!(format!("Task '{}' stderr:\n{}\n", sample_task.name.cyan(), "There's errors".red()), get_task_result_line_str(&sample_task_stdout).1.unwrap()); + assert_eq!(get_task_result_line_str(&sample_task_stdout).0, expected); + + let task_skipped = TaskExecutionResult { + name: String::from("skip"), + attempted: false, + run_details: None + }; + + assert_eq!(format!("Task '{}': skipped!\n", "skip".cyan()), get_task_result_line_str(&task_skipped).0); + assert_eq!(None, get_task_result_line_str(&task_skipped).1); + + let task_init_fail = TaskExecutionResult { + name: String::from("init fail"), + attempted: true, + run_details: None + }; + // todo: is there a better error here? + // I think this specific case is very unlikely as it'd hint at a problem with the rust stdlib + // it means we've tried to execute a process, but didn't get a return code etc + assert_eq!(format!("Task '{}': {}!\n", "init fail".cyan(), "Factotum could not start the task".red()), get_task_result_line_str(&task_init_fail).0); + assert_eq!(None, get_task_result_line_str(&task_init_fail).1); + + let task_failure = TaskExecutionResult { + name: String::from("fails"), + attempted: true, + run_details: Some(RunResult { + run_started: dt, + duration: Duration::from_secs(20), + task_execution_error: Some(String::from("The task exited with something unexpected")), + requests_job_termination: false, + stdout: Some(String::from("hello world")), + stderr: Some(String::from("There's errors")), + return_code: 0 + }) + }; + + let expected_failed = format!("Task '{}' was started at {}\nTask '{}' stdout:\n{}\n{}{}{}\n", "fails".cyan(), dt, "fails".cyan(), "hello world".bold(), "Task '".red(), "fails".cyan(), "': failed after 20.0s. Reason: The task exited with something unexpected".red()); + let (stdout_failed, stderr_failed) = get_task_result_line_str(&task_failure); + assert_eq!(expected_failed, stdout_failed); + assert_eq!(format!("Task '{}' stderr:\n{}\n", "fails".cyan(), "There's errors".red()), stderr_failed.unwrap()); + + // todo noop ? +} + +#[test] +fn test_get_task_results_str_summary() { + + let dt = UTC::now(); + + let mut tasks = vec::!(); + let (stdout, stderr) = get_task_results_str(&tasks); + let expected:String = format!("{}", "0/0 tasks run in 0.0s\n".green()); + + assert_eq!(stdout, expected); + assert_eq!(stderr, ""); + + tasks.push(TaskExecutionResult { + name: String::from("hello world"), + attempted: true, + run_details: Some(RunResult { + run_started: dt, + duration: Duration::from_secs(20), + task_execution_error: None, + requests_job_termination: false, + stdout: Some(String::from("hello world")), + stderr: Some(String::from("Mistake")), + return_code: 0 + }) + }); + + let (one_task_stdout, one_task_stderr) = get_task_results_str(&tasks); + let (first_task_stdout, first_task_stderr) = get_task_result_line_str(&tasks[0]); + let expected_one_task = format!("{}{}", first_task_stdout, "1/1 tasks run in 20.0s\n".green()); + + assert_eq!(one_task_stdout, expected_one_task); + let first_task_stderr_str = first_task_stderr.unwrap(); + assert_eq!(one_task_stderr, first_task_stderr_str); + + tasks.push(TaskExecutionResult { + name: String::from("hello world 2"), + attempted: true, + run_details: Some(RunResult { + run_started: dt, + duration: Duration::from_secs(80), + task_execution_error: None, + requests_job_termination: false, + stdout: Some(String::from("hello world")), + stderr: Some(String::from("Mistake")), + return_code: 0 + }) + }); + + let (two_task_stdout, two_task_stderr) = get_task_results_str(&tasks); + let (task_two_stdout, task_two_stderr) = get_task_result_line_str(&tasks[1]); + let expected_two_task = format!("{}{}{}", first_task_stdout, task_two_stdout, "2/2 tasks run in 1m, 40s\n".green()); + assert_eq!(two_task_stdout, expected_two_task); + assert_eq!(two_task_stderr, format!("{}{}", first_task_stderr_str, task_two_stderr.unwrap())); + } \ No newline at end of file