From 3ee78cbe52b2b6e2ae77ae27d73557d4c930da66 Mon Sep 17 00:00:00 2001 From: Ed Lewis Date: Mon, 25 Apr 2016 13:04:33 +0100 Subject: [PATCH] Improve output to STDERR / STDOUT (#51) A lot of refactoring took place here to draw the output from a store of task results rather than an ongoing stream WIP - large refactor use chrono crate instead of deprecated time crate pull out print logic from factotum, put in cli making it testable print task stderr to stderr ensure executor runs as normal when no config is passed (treats everything as a raw string) save the time a task started, include in output WIP Misc output fixes / new duration format WIP cleanup cli output some more WIP fix order of events display glitch WIP bold stdout --- Cargo.toml | 2 +- src/factotum/executor/mod.rs | 215 +++++++++++ src/factotum/factfile/mod.rs | 6 +- src/factotum/mod.rs | 53 +-- src/factotum/{fileparser => parser}/mod.rs | 89 ++--- .../jsonschemas/factotum.json | 0 src/factotum/parser/schemavalidator/mod.rs | 71 ++++ src/factotum/parser/schemavalidator/tests.rs | 15 + src/factotum/parser/templater/mod.rs | 29 ++ src/factotum/parser/templater/tests.rs | 31 ++ src/factotum/{fileparser => parser}/tests.rs | 4 +- src/factotum/runner/mod.rs | 208 ----------- src/factotum/sequencer/mod.rs | 67 ++++ src/factotum/sequencer/tests.rs | 71 ++++ src/factotum/tests.rs | 70 +--- src/main.rs | 343 +++++++++++++++++- 16 files changed, 876 insertions(+), 398 deletions(-) create mode 100644 src/factotum/executor/mod.rs rename src/factotum/{fileparser => parser}/mod.rs (60%) rename src/factotum/{fileparser => parser/schemavalidator}/jsonschemas/factotum.json (100%) create mode 100644 src/factotum/parser/schemavalidator/mod.rs create mode 100644 src/factotum/parser/schemavalidator/tests.rs create mode 100644 src/factotum/parser/templater/mod.rs create mode 100644 src/factotum/parser/templater/tests.rs rename src/factotum/{fileparser => parser}/tests.rs (95%) delete mode 100644 src/factotum/runner/mod.rs create mode 100644 src/factotum/sequencer/mod.rs create mode 100644 src/factotum/sequencer/tests.rs 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