From 66a57946d5b5c49fe76d4e02c302bbd51af7fdfd Mon Sep 17 00:00:00 2001 From: dario_azzali <90763809+Darioazzali@users.noreply.github.com> Date: Thu, 19 Oct 2023 19:42:15 +0200 Subject: [PATCH] feat: add graph ascii representation (#103) * feat: add graph ascii representation * feat: add graph ascii representation corrected test errors, refactored args --- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 15 ++- src/args.rs | 52 +++++--- src/graph/graph_task.rs | 274 ++++++++++++++++++++++++++++++++++++++++ src/graph/mod.rs | 59 +++++++++ src/graph/ui.rs | 202 +++++++++++++++++++++++++++++ src/main.rs | 44 ++++--- 8 files changed, 614 insertions(+), 40 deletions(-) create mode 100644 src/graph/graph_task.rs create mode 100644 src/graph/mod.rs create mode 100644 src/graph/ui.rs diff --git a/Cargo.lock b/Cargo.lock index 6460f83..b9be530 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2235,6 +2235,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "termgraph" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f78f23e6418b6ca453bf772e03567961c181230eb5bab587c7e3b186ae6fb42" + [[package]] name = "termtree" version = "0.4.1" @@ -2701,6 +2707,7 @@ dependencies = [ "shlex", "strip-ansi-escapes", "subprocess", + "termgraph", "textwrap", "tokio", "url", diff --git a/Cargo.toml b/Cargo.toml index 17fbfeb..9d5b30b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,3 +42,4 @@ url = "2.4.1" assert_cmd = "2.0.12" semver = "1.0.19" shlex = "1.2.0" +termgraph = "0.4.0" diff --git a/README.md b/README.md index ebc1c20..3a53bf4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ steps. curl -fsSL https://raw.githubusercontent.com/zifeo/whiz/main/installer.sh | bash # via cargo -cargo install whiz --locked +cargo install whiz --locked cargo install --git https://github.com/zifeo/whiz --locked # create your tasks file, see https://github.com/zifeo/whiz/blob/main/whiz.yaml for an example @@ -75,18 +75,24 @@ complete example. See `whiz --help` for more information. +| Subcommads | Description | +| ------------------- | ------------------------------------------------- | +| upgrade | Upgrade whiz | +| list-jobs | List all the available jobs | +| graph | Print the graphical ascii representation | +| help | Display help message or the help for subcommand | + + | Flags | Description | | ------------------- | ------------------------------------------------- | | -f, --file \ | Specify the config file | | -h, --help | Print help information | -| --list-jobs | List all the available jobs | | -r, --run \ | Run specific jobs | | -t, --timestamp | Enable timestamps in logging | | -v, --verbose | Enable verbose mode | | -V, --version | Print whiz version | -| -V, --version | Print whiz version | | --watch | Globally enable/disable fs watching | -| --exit-after | Exit whiz after all tasks are done. Useful for CI | +| --exit-after | Exit whiz after all tasks are done | ### Key bindings @@ -111,3 +117,4 @@ See `whiz --help` for more information. ```bash cargo run -- ``` + diff --git a/src/args.rs b/src/args.rs index ae3c8f1..4246200 100644 --- a/src/args.rs +++ b/src/args.rs @@ -3,57 +3,67 @@ use clap::{Parser, Subcommand}; #[derive(Parser, Debug, Clone)] pub struct Upgrade { /// Upgrade to specific version (e.g. 1.0.0) - #[clap(long)] + #[arg(long)] pub version: Option, /// Do not ask for version confirmation - #[clap(short, long, default_value_t = false)] + #[arg(short, long, default_value_t = false)] pub yes: bool, } +#[derive(Parser, Debug, Clone)] +pub struct Graph { + /// Draw the line using box-drawing character + #[arg(long, short, default_value_t = false)] + pub boxed: bool, +} + /// Set of subcommands. #[derive(Subcommand, Debug)] pub enum Command { /// Upgrade whiz. Upgrade(Upgrade), + /// PUpgrade whizrint the graphical ascii representation + Graph(Graph), + /// List all the jobs set in the config file + ListJobs, } #[derive(Parser, Debug)] -#[clap(name="whiz", about, long_about = None, disable_version_flag = true, disable_help_flag = true)] +#[command( + name = "whiz", + about, + long_about= None, +)] pub struct Args { - #[clap(long, value_parser)] + #[arg(long)] pub version: bool, - #[clap(short, long, value_parser)] - pub help: bool, - - #[clap(subcommand)] + #[command(subcommand)] pub command: Option, - #[clap(short, long, default_value = "whiz.yaml")] + #[arg(short, long, default_value = "whiz.yaml")] pub file: String, - #[clap(short, long)] + #[arg(short, long)] pub verbose: bool, + #[arg(short, long)] /// Enable timestamps in logging - #[clap(short, long)] pub timestamp: bool, /// Run specific jobs - #[clap(short, long, value_name = "JOB")] + #[arg(short, long, value_name = "JOB")] pub run: Vec, - /// List all the jobs set in the config file - #[clap(long)] - pub list_jobs: bool, - + // This disables fs watching despite any values given to the `watch` flag. + // /// Whiz will exit after all tasks have finished executing. - /// This disables fs watching despite any values given to the `watch` flag. - #[clap(long)] + #[arg(long)] pub exit_after: bool, - /// Globally toggle triggering task reloading from any watched files - #[clap(long, default_value = "true", value_name = "WATCH")] - pub watch: Option, + // Globally toggle triggering task reloading from any watched files + /// Globally enable/disable fs watching + #[arg(long, default_value_t = true)] + pub watch: bool, } diff --git a/src/graph/graph_task.rs b/src/graph/graph_task.rs new file mode 100644 index 0000000..434231b --- /dev/null +++ b/src/graph/graph_task.rs @@ -0,0 +1,274 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +pub struct Graph<'a> { + pub independent_tasks: Vec<&'a Task>, + nodes_dictionary: HashMap, + edges: Vec<(usize, usize)>, +} + +impl<'a> Graph<'a> { + pub fn from_tasks_list(tasks_list: &'a [Task]) -> Self { + let (independent_tasks, dependent_tasks) = Task::split_tasks(tasks_list); + let mut nodes_dictionary: HashMap = HashMap::new(); + Self::populate_node_dictionary(&mut nodes_dictionary, &dependent_tasks); + let edges = Self::build_edges(&dependent_tasks, &nodes_dictionary); + Self { + independent_tasks, + nodes_dictionary, + edges, + } + } + + pub fn nodes(&self) -> HashMap<&usize, &String> { + self.nodes_dictionary + .iter() + .map(|node| (node.1, node.0)) + .collect() + } + + pub fn edges(&self) -> Vec<(&usize, &usize)> { + self.edges.iter().map(|t| (&t.0, &t.1)).collect() + } + + fn build_edges( + dependent_tasks: &[&Task], + nodes_dictionary: &HashMap, + ) -> Vec<(usize, usize)> { + dependent_tasks + .iter() + .enumerate() + .filter_map(|(uid, task)| { + Self::dependecies_lists_to_tuple_nodes(&task.depends_on, uid, nodes_dictionary) + }) + .flatten() + .collect() + } + + fn dependecies_lists_to_tuple_nodes( + dependecies_lists: &Vec, + uid: usize, + nodes_dictionary: &HashMap, + ) -> Option> { + if dependecies_lists.is_empty() { + return None; + }; + let mut result: Vec<(usize, usize)> = vec![]; + for dependecy in dependecies_lists { + match nodes_dictionary.get(dependecy) { + Some(node) => result.push((*node, uid)), + None => return None, + } + } + Some(result) + } + + fn populate_node_dictionary( + nodes_dictionary: &mut HashMap, + dependent_tasks: &[&Task], + ) { + dependent_tasks.iter().enumerate().for_each(|(uid, task)| { + nodes_dictionary.insert(task.name.to_owned(), uid); + }); + } + + pub fn format_independent_task(&self) -> String { + //Format the indipendent tasks on the first line + if self.independent_tasks.is_empty() { + return String::new(); + }; + self.independent_tasks.iter().skip(1).fold( + format!("|{}|", &self.independent_tasks[0].name), + |accumulatotask_list, task| format!("{} |{}|", accumulatotask_list, task.name), + ) + "\n" + + "\n" + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TaskFile { + #[serde(flatten)] + file: HashMap, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DependsOn { + pub depends_on: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Task { + pub name: String, + + pub depends_on: Vec, +} + +impl Task { + pub fn split_tasks(tasks: &[Task]) -> (Vec<&Task>, Vec<&Task>) { + let mut dependencies_tasks: HashSet<&str> = HashSet::new(); + tasks.iter().for_each(|task| { + task.depends_on.iter().for_each(|dep_task| { + dependencies_tasks.insert(dep_task); + }) + }); + tasks.iter().partition(|task| { + task.depends_on.is_empty() + && !tasks + .iter() + .all(|_| dependencies_tasks.contains(task.name.as_str())) + }) + } +} + +#[cfg(test)] +mod helpers_tests { + use super::{Graph, Task}; + use std::collections::HashMap; + // + //Test helpers + type TestInputTask = (&'static str, &'static [&'static str]); + impl Task { + pub fn from_formatted(formatted_tasks: &[TestInputTask]) -> Vec { + formatted_tasks.iter().map(|t| Task::from(*t)).collect() + } + } + impl From for Task { + fn from(value: TestInputTask) -> Self { + Task { + name: value.0.to_owned(), + depends_on: value + .1 + .iter() + .map(|refer| refer.to_string()) + .collect::>(), + } + } + } + + #[test] + fn test_split_tasks() { + let input: &[TestInputTask] = &[("once", &[]), ("once_b", &["once"]), ("third_task", &[])]; + let task_vec: Vec = Task::from_formatted(input); + + assert_eq!( + Task::split_tasks(&task_vec).0.get(0).unwrap(), + &task_vec.get(2).unwrap() + ) + } + + #[test] + fn split_multiple_tasks() { + let input: &[TestInputTask] = &[ + ("once", &[]), + ("once_b", &["once"]), + ("third_task", &[]), + ("once_c", &["once", "once_b"]), + ("speedy", &[]), + ("err", &[]), + ]; + + let tasks: Vec = Task::from_formatted(input); + let (indipendent_tasks, dependent_tasks) = Task::split_tasks(&tasks); + assert_eq!( + indipendent_tasks, + &[ + tasks.get(2).unwrap(), + tasks.get(4).unwrap(), + tasks.get(5).unwrap() + ] + ); + assert_eq!( + dependent_tasks, + vec![ + tasks.get(0).unwrap(), + tasks.get(1).unwrap(), + tasks.get(3).unwrap() + ] + ) + } + + #[test] + fn split_bigger_list() { + let input: &[TestInputTask] = &[ + ("2.2_task", &["1.4_task"]), + ("0.8_task", &[]), + ("1.3_task", &["0.6_task"]), + ( + "1.4_task", + &["0.6_task", "0.7_task", "0.8_task", "0.9_task", "0.10_task"], + ), + ("0.5_task", &[]), + ("0.3_task", &[]), + ("0.2_task", &[]), + ("1.1_task", &["0.2_task"]), + ("0.7_task", &[]), + ("0.6_task", &[]), + ("0.11_task", &[]), + ( + "1.2_task", + &["0.2_task", "0.3_task", "0.4_task", "0.6_task", "0.10_task"], + ), + ("1.5_task", &["0.7_task"]), + ("0.9_task", &[]), + ("0.4_task", &[]), + ("2.1_task", &["1.4_task", "1.5_task"]), + ("0.10_task", &[]), + ("0.1_task", &[]), + ]; + let tasks = Task::from_formatted(input); + + let (indipendent, _) = Task::split_tasks(&tasks); + [ + Task { + name: "0.1_task".into(), + depends_on: vec![], + }, + Task { + name: "0.5_task".into(), + depends_on: vec![], + }, + Task { + name: "0.11_task".into(), + depends_on: vec![], + }, + ] + .iter() + .for_each(|el| assert!(indipendent.contains(&el))); + } + + #[test] + fn dep_list_to_nodes() { + let one = Task { + name: "one".to_owned(), + depends_on: vec![], + }; + + let two = Task { + name: "two".to_owned(), + depends_on: vec!["one".to_owned()], + }; + + let three = Task { + name: "three".to_owned(), + depends_on: vec!["one".to_owned(), "two".to_owned()], + }; + + let dependent_dictionary: HashMap = HashMap::from([ + ("one".to_owned(), 1), + ("two".to_owned(), 2), + ("three".to_owned(), 3), + ]); + let dependencies_for_one = + Graph::dependecies_lists_to_tuple_nodes(&one.depends_on, 1, &dependent_dictionary); + assert_eq!(dependencies_for_one, None); + + let dependencies_for_two = + Graph::dependecies_lists_to_tuple_nodes(&two.depends_on, 2, &dependent_dictionary); + + let dependencies_for_three = + Graph::dependecies_lists_to_tuple_nodes(&three.depends_on, 3, &dependent_dictionary); + + assert_eq!(dependencies_for_two, Some(vec![(1, 2)])); + assert_eq!(dependencies_for_three, Some(vec![(1, 3), (2, 3)])); + } +} diff --git a/src/graph/mod.rs b/src/graph/mod.rs new file mode 100644 index 0000000..19ef024 --- /dev/null +++ b/src/graph/mod.rs @@ -0,0 +1,59 @@ +pub use graph_task::{Graph, Task}; +use ratatui::prelude::{CrosstermBackend, Terminal}; +use std::error::Error; +use termgraph::fdisplay; + +use ui::{Drawer, Model, TaskFormatter}; + +use self::ui::LineFormat; + +pub mod graph_task; +mod ui; + +pub fn draw_graph(tasks_list: Vec, boxed: bool) -> Result<(), Box> { + let boxed = match boxed { + true => LineFormat::Boxed, + _ => LineFormat::Ascii, + }; + let graph = Graph::from_tasks_list(&tasks_list); + + //use termgraph to generate the ascii representation + let config = termgraph::Config::new(TaskFormatter::new(), 200) + .line_glyphs(TaskFormatter::from_commandline(boxed)); + let mut ascii_graph = termgraph::DirectedGraph::new(); + ascii_graph.add_nodes(graph.nodes()); + ascii_graph.add_edges(graph.edges()); + + // Write graphics into the buffer + let mut formatted_ascii_graph = Vec::new(); + fdisplay(&ascii_graph, &config, &mut formatted_ascii_graph); + + //Start ratatui initializaion + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?; + let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?; + + // let mut ui = Model::default(); + let mut ui = Model::new(&formatted_ascii_graph, graph.format_independent_task()); + + loop { + terminal.draw(|f| { + Drawer::draw(&mut ui, f); + })?; + + let mut current_msg = ui::handle_key_event()?; + + while current_msg.is_some() { + current_msg = ui::update(&mut ui, current_msg.unwrap()) + } + + if ui.should_quit { + break; + } + } + + crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen)?; + crossterm::terminal::disable_raw_mode()?; + + Ok(()) +} diff --git a/src/graph/ui.rs b/src/graph/ui.rs new file mode 100644 index 0000000..fb604ac --- /dev/null +++ b/src/graph/ui.rs @@ -0,0 +1,202 @@ +use std::{fmt::Display, rc::Rc}; + +use crossterm::event::KeyCode; +use ratatui::{ + prelude::{Backend, Constraint, Layout, Rect}, + widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, + Frame, +}; +use termgraph::{LineGlyphBuilder, LineGlyphs, NodeFormat}; + +pub enum LineFormat { + Ascii, + Boxed, +} + +#[derive(PartialEq)] +pub enum Message { + ScrollDown, + ScrollUp, + ScrollRight, + ScrollLeft, + Quit, +} + +#[derive(Default)] +pub struct Model { + vertical_scroll_state: ScrollbarState, + horizontal_scroll_state: ScrollbarState, + vertical_scroll: u16, + horizontal_scroll: u16, + pub should_quit: bool, + graph_string_representation: String, + indipendent_tasks: String, +} + +impl Model { + pub fn new(graph_string_representation: &[u8], indipendent_tasks: String) -> Self { + Model { + vertical_scroll: 0, + horizontal_scroll: 0, + should_quit: false, + horizontal_scroll_state: ScrollbarState::default(), + vertical_scroll_state: ScrollbarState::default(), + graph_string_representation: String::from_utf8_lossy(graph_string_representation) + .into_owned(), + indipendent_tasks, + } + } +} + +pub fn handle_key_event() -> Result, Box> { + let message = if crossterm::event::poll(std::time::Duration::from_millis(250))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + match key.code { + KeyCode::Char('q') => Message::Quit, + KeyCode::Char('j') | KeyCode::Down => Message::ScrollDown, + KeyCode::Char('k') | KeyCode::Up => Message::ScrollUp, + KeyCode::Char('h') | KeyCode::Left => Message::ScrollLeft, + KeyCode::Char('l') | KeyCode::Right => Message::ScrollRight, + _ => return Ok(None), + } + } else { + return Ok(None); + } + } else { + return Ok(None); + }; + + Ok(Some(message)) +} + +pub fn update(model: &mut Model, msg: Message) -> Option { + use Message::*; + match msg { + ScrollRight => { + model.horizontal_scroll = model.horizontal_scroll.saturating_add(5); + model.horizontal_scroll_state = model + .horizontal_scroll_state + .position(model.horizontal_scroll); + } + ScrollLeft => { + model.horizontal_scroll = model.horizontal_scroll.saturating_sub(5); + model.horizontal_scroll_state = model + .horizontal_scroll_state + .position(model.horizontal_scroll); + } + ScrollUp => { + model.vertical_scroll = model.vertical_scroll.saturating_sub(5); + model.vertical_scroll_state = + model.vertical_scroll_state.position(model.vertical_scroll); + } + + ScrollDown => { + model.vertical_scroll = model.vertical_scroll.saturating_add(5); + model.vertical_scroll_state = + model.vertical_scroll_state.position(model.vertical_scroll); + } + Quit => model.should_quit = true, + } + None +} + +pub struct Drawer {} +impl Drawer { + fn render_indipendent_tasks( + frame: &mut Frame, + chunks: Rc<[Rect]>, + model: &mut Model, + ) { + frame.render_widget( + Paragraph::new(model.indipendent_tasks.as_str()) + .block( + Block::new() + .title("Indipendent task") + .title_alignment(ratatui::prelude::Alignment::Center) + .borders(Borders::ALL), + ) + // .alignment(ratatui::prelude::Alignment::Center) + .scroll((0, model.horizontal_scroll)), + chunks.clone()[0], + ); + } + + fn render_dependency_graph( + frame: &mut Frame, + chunks: Rc<[Rect]>, + model: &mut Model, + ) { + frame.render_widget( + Paragraph::new(model.graph_string_representation.to_owned()) + .block( + Block::new() + .title("Dependency Graph") + .title_alignment(ratatui::prelude::Alignment::Center) + .borders(Borders::ALL), + ) + .scroll((model.vertical_scroll, model.horizontal_scroll)), + chunks.clone()[1], + ); + } + + pub fn render_scrollbar( + model: &mut Model, + frame: &mut Frame, + chunks: Rc<[Rect]>, + ) { + frame.render_stateful_widget( + Scrollbar::default().orientation(ScrollbarOrientation::HorizontalTop), + chunks[1], + &mut model.horizontal_scroll_state, + ); + + frame.render_stateful_widget( + Scrollbar::default().orientation(ScrollbarOrientation::VerticalLeft), + chunks[1], + &mut model.vertical_scroll_state, + ); + } + + pub fn get_layout(frame: &Frame) -> Rc<[Rect]> { + Layout::default() + .direction(ratatui::prelude::Direction::Vertical) + .constraints(vec![Constraint::Length(5), Constraint::Min(0)]) + .split(frame.size()) + } + + pub fn draw(model: &mut Model, frame: &mut Frame) { + let chunks = Self::get_layout(frame); + Self::render_scrollbar(model, frame, chunks.clone()); + Self::render_dependency_graph(frame, chunks.clone(), model); + Self::render_indipendent_tasks(frame, chunks.clone(), model); + } +} + +pub struct TaskFormatter {} +impl TaskFormatter { + /// Creates a new Instance of the Formatter + pub fn new() -> Self { + Self {} + } + + pub fn from_commandline(line_format: LineFormat) -> LineGlyphs { + match line_format { + LineFormat::Ascii => LineGlyphBuilder::ascii().finish(), + LineFormat::Boxed => LineGlyphBuilder::ascii() + .vertical('\u{2502}') + .crossing('\u{253C}') + .horizontal('\u{2500}') + .arrow_down('▼') + .finish(), + } + } +} + +impl NodeFormat for TaskFormatter +where + T: Display, +{ + fn format_node(&self, _: &ID, name: &T) -> String { + format!("|{}|", name) + } +} diff --git a/src/main.rs b/src/main.rs index fd0d8a3..6ea38f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,12 @@ -use std::eprintln; - use actix::prelude::*; use anyhow::anyhow; use anyhow::Ok; use anyhow::Result; use chrono::{Duration, Utc}; -use clap::CommandFactory; use clap::Parser; use self_update::{backends::github::Update, cargo_crate_version, update::UpdateStatus}; use semver::Version; +use std::eprintln; use tokio::time::{sleep, Duration as TokioDuration}; use whiz::actors::command::CommandActorsBuilder; use whiz::{ @@ -18,6 +16,7 @@ use whiz::{ global_config::GlobalConfig, utils::recurse_config_file, }; +mod graph; use whiz::args::Args; @@ -57,18 +56,13 @@ async fn upgrade_check() -> Result<()> { } fn main() -> Result<()> { - let args = Args::try_parse()?; + let args = Args::parse(); if args.version { println!("whiz {}", env!("CARGO_PKG_VERSION")); return Ok(()); } - if args.help { - Args::command().print_help()?; - return Ok(()); - } - if let Some(Command::Upgrade(opts)) = args.command { let mut update = Update::configure(); update @@ -148,12 +142,36 @@ async fn run(args: Args) -> Result<()> { .filter_jobs(&args.run) .map_err(|err| anyhow!("argument error: {}", err))?; - if args.list_jobs { + if let Some(Command::ListJobs) = args.command { let formatted_list_of_jobs = config.get_formatted_list_of_jobs(); println!("List of jobs:\n{formatted_list_of_jobs}"); return Ok(()); } + if let Some(Command::Graph(opts)) = args.command { + let filtered_tasks: Vec = config + .ops + .into_iter() + .map(|task| graph::Task { + name: task.0.to_owned(), + depends_on: task.1.depends_on.resolve(), + }) + .collect(); + + match graph::draw_graph(filtered_tasks, opts.boxed) + .map_err(|err| anyhow!("Error visualizing graph: {}", err)) + { + Result::Ok(..) => { + System::current().stop_with_code(0); + return Ok(()); + } + Err(e) => { + System::current().stop_with_code(1); + return Err(e); + } + }; + } + let base_dir = config_path.parent().unwrap().to_path_buf(); let console = @@ -168,11 +186,7 @@ async fn run(args: Args) -> Result<()> { ) .verbose(args.verbose) .pipes_map(pipes_map) - .globally_enable_watch(if args.exit_after { - false - } else { - args.watch.unwrap_or(true) - }) + .globally_enable_watch(if args.exit_after { false } else { args.watch }) .build() .await .map_err(|err| anyhow!("error spawning commands: {}", err))?;