├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── mix │ └── rotor.run.ex ├── rotor.ex └── rotor │ ├── basic_rotors.ex │ ├── config.ex │ ├── config_server.ex │ ├── file_watcher.ex │ ├── file_watcher_pool.ex │ ├── group_server.ex │ ├── runner.ex │ ├── supervisor.ex │ └── utils.ex ├── mix.exs └── test ├── rotor └── basic_rotors_test.exs ├── rotor_test.exs ├── samples ├── app1.js ├── app2.js ├── test1.txt └── test2.txt └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | test/samples/outputs 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Akash Manohar J 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rotor 2 | 3 | Rotor is a build system for Elixir projects. Use it to compile things, run commands or do anything that needs to be run when files change. 4 | 5 | > *[Wreckers][1] don't call for backup, they call for cleanup ~!* 6 | 7 | [1]: http://en.wikipedia.org/wiki/Wreckers_(Transformers) 8 | 9 | 10 | **Define your rotor watch groups in `config/rotors.exs` in your project and they'll be loaded when your app starts** 11 | 12 | ### Features 13 | 14 | * Works with any web framework or even plain mix projects 15 | * Easy to use 16 | * Extendable with simple functions 17 | * Can be configured to run commands or code or go to the moon. 18 | 19 | ### Usage 20 | 21 | * Add rotor as a dependency to your `mix.exs` 22 | * Define watch groups in `config/rotors.exs` 23 | * Run `Rotor.start` in your `IEx` console to run the rotors 24 | 25 | ### Example 1: Reload Elixir modules whenever they change 26 | 27 | ```elixir 28 | # config/rotors.exs 29 | 30 | use Rotor.Config 31 | 32 | paths = ["lib/**/*"] 33 | 34 | Rotor.define :ex_modules, paths, fn(changed, _all)-> 35 | reload_modules(changed) 36 | end 37 | ``` 38 | 39 | Make changes to any file in the lib dir of your project and watch it reload in your console 40 | 41 | ### Example 2: Compile CoffeeScript files whenever they change 42 | 43 | ```elixir 44 | # config/rotors.exs 45 | 46 | use Rotor.Config 47 | 48 | paths = ["assets/libs/*.coffee", "assets/*.coffee"] 49 | Rotor.define :coffee_assets, paths, fn(changed_files, all_files)-> 50 | read_files(all_files) 51 | |> coffee 52 | |> concat 53 | |> write_to("priv/static/assets/app.js") 54 | end 55 | 56 | ``` 57 | 58 | `touch` a file that's in the path provided and watch the rotor function being run. 59 | 60 | *The above example uses the [coffee_rotor](https://github.com/HashNuke/coffee_rotor).* 61 | 62 | 63 | > **NOTE:** Rotor is *not* a replacement for mix. It is intended to be used as your sidekick during development. 64 | 65 | 66 | ### Details 67 | 68 | #### What is a watch group? 69 | 70 | A set of paths you want to watch is called a *Watch group"*. Each watch group has the following: 71 | 72 | * name 73 | * a list of paths to watch 74 | * *rotor function* - a function that is run everytime any of the files in the paths changes. It should accept 2 arguments 75 | * *changed_files* - a list of maps, each containing info about a changed file 76 | * *all_files* - a list of maps, each containing info about all files that matched the path 77 | 78 | #### Where to define watch groups? 79 | 80 | `config/rotors.exs` is prefered. But if you want to define them elsewhere feel free. Take a look at examples 81 | 82 | #### How to run them? 83 | 84 | Run `Rotor.start` in your `IEx` console to run the rotors. 85 | 86 | You can also automate this by adding `Rotor.start` somewhere in your code. But be careful ~! 87 | 88 | #### How to define watch groups? 89 | 90 | ```elixir 91 | # With default options 92 | Rotor.define(name, files, rotor_function) 93 | 94 | # With options 95 | Rotor.define(name, files, rotor_function, options) 96 | ``` 97 | 98 | The rotor function is passed info about the list of files that match the paths specified. The rotor function calls other little functions called `rotors`, that run certain tasks. 99 | 100 | 101 | ```elixir 102 | paths = ["assets/javascripts/libs/*.js", "assets/javascripts/*.js"] 103 | Rotor.define :javascripts, paths, fn(changed_files, all_files)-> 104 | read_files(all_files) 105 | |> concat 106 | |> write_to("priv/static/assets/app.js") 107 | end 108 | ``` 109 | 110 | The fourth argument is options. It accepts a map. The following are valid options: 111 | 112 | * `manual` - defaults to false. If set to true, paths will only be polled when `Rotor.run/1` or `Rotor.run_async/1` is called. 113 | * `interval` - defaults to 2500 milliseconds (2.5 seconds). This is the interval at which files are checked for changes. 114 | 115 | 116 | #### Manually running watch group's rotor function 117 | 118 | If you want files to be polled only when you say so (and not at intervals). Then pass the `manual` option as `true` when adding a group. Then use one of the following functions to trigger a poll. 119 | 120 | * `Rotor.run(group_name)` - will poll paths and run the Rotor function synchronously 121 | * `Rotor.run_async(group_name)` - will poll paths and run the Rotor function asynchronously 122 | 123 | 124 | 125 | #### Rotors 126 | 127 | Rotor ships with a few simple helper functions in the `Rotor.BasicRotors` module. 128 | 129 | * `read_files(files)` - reads contents of files, and returns files with a property called `contents` 130 | * `copy_files(files, destination_dir)` - copies files to destination_dir 131 | * `concat(files)` - concats contents of files and returns a string 132 | * `write_to(contents, output_path)` - writes the contents to the file path specified in output path 133 | * `reload_modules(files)` - reloads the modules in the list of files passed 134 | 135 | You can also write your own. Check the *"Writing custom rotors"* section below. 136 | 137 | 138 | ### Other stuff 139 | 140 | * To remove a watch group 141 | 142 | ```elixir 143 | Rotor.stop_watching(name) 144 | ``` 145 | 146 | * To list all watch groups 147 | 148 | ```elixir 149 | Rotor.all 150 | ``` 151 | 152 | * To run a watch group's rotor function forcefully 153 | 154 | ```elixir 155 | Rotor.run(name) 156 | ``` 157 | 158 | ### Examples 159 | 160 | ```elixir 161 | paths = ["assets/stylesheets/libs/*.css", "assets/stylesheets/*.css"] 162 | Rotor.define :stylesheets, paths, fn(changed_files, all_files)-> 163 | read_files(all_files) 164 | |> concat 165 | |> write_to("app.css") 166 | end 167 | 168 | 169 | paths = ["assets/images/*", "assets/fonts/*"] 170 | Rotor.define :images_and_fonts, paths, fn(changed_files, all_files)-> 171 | copy_files(files, "priv/static/assets") 172 | end 173 | ``` 174 | 175 | ### Writing custom rotors 176 | 177 | Rotors are just functions that accept data and do something. 178 | 179 | Checkout [coffee_rotor](https://github.com/HashNuke/coffee_rotor), which provides a rotor to compile CoffeeScript files. 180 | 181 | 182 | ### License 183 | 184 | Copyright © 2014, Akash Manohar J, under the [MIT License](http://opensource.org/licenses/MIT) 185 | 186 | > Inspired by [gulp](https://github.com/gulpjs/gulp) 187 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application and 2 | # its dependencies. It must return a keyword list containing the 3 | # application name and have as value another keyword list with 4 | # the application key-value pairs. 5 | 6 | # Note this configuration is loaded before any dependency and is 7 | # restricted to this project. If another project depends on this 8 | # project, this file won't be loaded nor affect the parent project. 9 | 10 | # You can customize the configuration path by setting :config_path 11 | # in your mix.exs file. For example, you can emulate configuration 12 | # per environment by setting: 13 | # 14 | # config_path: "config/#{Mix.env}.exs" 15 | # 16 | # Changing any file inside the config directory causes the whole 17 | # project to be recompiled. 18 | 19 | # Sample configuration: 20 | # 21 | # [dep1: [key: :value], 22 | # dep2: [key: :value]] 23 | 24 | [] 25 | -------------------------------------------------------------------------------- /lib/mix/rotor.run.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Rotor.Run do 2 | use Mix.Task 3 | 4 | @shortdoc "Run rotors" 5 | 6 | 7 | #TODO incomplete 8 | def run([]) do 9 | Application.start(:rotor) 10 | Rotor.Config.load_rotors 11 | end 12 | 13 | 14 | #TODO incomplete 15 | def run([path]) do 16 | Application.start(:rotor) 17 | Rotor.Config.load_rotors(path) 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /lib/rotor.ex: -------------------------------------------------------------------------------- 1 | defmodule Rotor do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | 6 | # If the supervisor is started correctly, 7 | # load rotors from the default rotors file 8 | case Rotor.Supervisor.start_link do 9 | {:ok, _pid} = result -> 10 | load_rotors 11 | result 12 | anything -> anything 13 | end 14 | end 15 | 16 | 17 | def start do 18 | Application.start :rotor 19 | end 20 | 21 | 22 | defdelegate group_info(name), to: Rotor.GroupServer, as: :get 23 | defdelegate all(), to: Rotor.GroupServer, as: :all 24 | 25 | defdelegate define(name, paths, rotor_fn, options), to: Rotor.GroupServer, as: :add 26 | defdelegate define(name, paths, rotor_fn), to: Rotor.GroupServer, as: :add 27 | 28 | defdelegate run(name), to: Rotor.GroupServer 29 | defdelegate run_async(name), to: Rotor.GroupServer 30 | 31 | defdelegate stop_watching(name), to: Rotor.GroupServer, as: :remove 32 | 33 | 34 | # Retaining for backwards compatability 35 | defdelegate all_groups(), to: Rotor.GroupServer, as: :all 36 | 37 | defdelegate watch(name, paths, rotor_fn, options), to: Rotor.GroupServer, as: :add 38 | defdelegate watch(name, paths, rotor_fn), to: Rotor.GroupServer, as: :add 39 | 40 | defdelegate remove_group(name), to: Rotor.GroupServer, as: :remove 41 | 42 | 43 | 44 | def default_rotors_path do 45 | "config/rotors.exs" 46 | end 47 | 48 | 49 | def load_rotors do 50 | load_rotors default_rotors_path 51 | end 52 | 53 | 54 | def load_rotors(path) do 55 | if File.exists?(path) do 56 | Code.load_file path 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/rotor/basic_rotors.ex: -------------------------------------------------------------------------------- 1 | defmodule Rotor.BasicRotors do 2 | 3 | def concat(files) do 4 | Enum.map_join(files, "\n", fn(file)-> 5 | file.contents 6 | end) 7 | end 8 | 9 | 10 | def copy_files(files, destination_dir) do 11 | Enum.each files, fn(file)-> 12 | File.copy(file.path, "#{destination_dir}/#{Path.basename(file.path)}") 13 | end 14 | end 15 | 16 | 17 | def output_to(contents, output_path) do 18 | :ok = File.write output_path, contents 19 | end 20 | 21 | 22 | def read_files(files) do 23 | Enum.map files, fn(file)-> 24 | {:ok, contents} = File.read(file.path) 25 | %{file | :contents => contents} 26 | end 27 | end 28 | 29 | 30 | def reload_modules([]), do: true 31 | 32 | def reload_modules([file | files]) do 33 | Code.load_file(file.path) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/rotor/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Rotor.Config do 2 | 3 | defmacro __using__(_opts) do 4 | quote do 5 | import Rotor.BasicRotors 6 | end 7 | end 8 | 9 | end 10 | -------------------------------------------------------------------------------- /lib/rotor/config_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Rotor.ConfigServer do 2 | def start_link do 3 | Agent.start_link(fn -> %{} end, name: __MODULE__) 4 | end 5 | 6 | 7 | def set(option, value) do 8 | Agent.get_and_update __MODULE__, fn(config)-> 9 | updated_config = Map.put(config, option, value) 10 | {:ok, updated_config} 11 | end 12 | end 13 | 14 | 15 | def get(option) do 16 | Agent.get __MODULE__, fn(config)-> 17 | Map.get(config, option) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/rotor/file_watcher.ex: -------------------------------------------------------------------------------- 1 | defmodule Rotor.FileWatcher do 2 | import Rotor.Utils 3 | use GenServer 4 | 5 | def start_link(args \\ []) do 6 | GenServer.start_link(__MODULE__, args) 7 | end 8 | 9 | 10 | def init(watcher_info) do 11 | {:ok, watcher_info} 12 | end 13 | 14 | 15 | def handle_call(:state, _from, state) do 16 | {:reply, state, state} 17 | end 18 | 19 | 20 | # This is for synchronous poll 21 | def handle_call(:poll, _from, state) do 22 | new_state = poll(state) 23 | {:reply, :ok, new_state} 24 | end 25 | 26 | 27 | # This is for async poll 28 | def handle_info(:poll, state) do 29 | new_state = poll(state) 30 | {:noreply, new_state} 31 | end 32 | 33 | 34 | defp poll(state) do 35 | group_info = Rotor.group_info(state.name) 36 | {changed_files, file_index} = case get_in(state, [:file_index]) do 37 | nil -> 38 | file_index = build_file_index(group_info.paths) 39 | state = put_in state[:file_index], file_index 40 | {[], file_index} 41 | index -> 42 | update_file_index_timestamps(index) 43 | end 44 | 45 | state = run_rotor_function(state, changed_files, file_index) 46 | schedule_poll(group_info.options.manual, group_info.options.interval) 47 | state 48 | end 49 | 50 | 51 | defp schedule_poll(true, _interval) do 52 | end 53 | 54 | defp schedule_poll(false, interval) do 55 | Process.send_after(self, :poll, interval) 56 | end 57 | 58 | 59 | defp run_rotor_function(state, [], _file_index) do 60 | state 61 | end 62 | 63 | defp run_rotor_function(state, changed_files, file_index) do 64 | state = put_in state[:file_index], file_index 65 | all_files = HashDict.values(file_index) 66 | Rotor.Runner.run(state.name, changed_files, all_files) 67 | state 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/rotor/file_watcher_pool.ex: -------------------------------------------------------------------------------- 1 | defmodule Rotor.FileWatcherPool do 2 | use Supervisor 3 | 4 | def start_link do 5 | Supervisor.start_link(__MODULE__, [], name: __MODULE__) 6 | end 7 | 8 | 9 | def init([]) do 10 | children = [] 11 | supervise(children, strategy: :one_for_one) 12 | end 13 | 14 | 15 | def add(group_name, is_manual) do 16 | watcher_info = %{name: group_name, manual: is_manual} 17 | 18 | # Pass ID to make sure that the process is unique. 19 | # This avoids having to handle termination. 20 | child = worker(Rotor.FileWatcher, [watcher_info], id: unique_id(group_name)) 21 | Supervisor.start_child(__MODULE__, child) 22 | end 23 | 24 | 25 | def remove(group_name) do 26 | :ok = Supervisor.terminate_child __MODULE__, unique_id(group_name) 27 | :ok = Supervisor.delete_child __MODULE__, unique_id(group_name) 28 | end 29 | 30 | 31 | defp unique_id(group_name) do 32 | "#{group_name}-watcher" 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/rotor/group_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Rotor.GroupServer do 2 | use GenServer 3 | 4 | 5 | def start_link do 6 | Agent.start_link(fn-> %{} end, name: __MODULE__) 7 | end 8 | 9 | 10 | def all do 11 | Agent.get __MODULE__, &(&1) 12 | end 13 | 14 | 15 | def get(name) do 16 | Agent.get __MODULE__, &(get_in &1, [name]) 17 | end 18 | 19 | 20 | def add(name, paths, rotor_fn, options \\ %{}) do 21 | paths = format_paths(paths) 22 | 23 | group_info = Agent.get_and_update __MODULE__, fn(groups)-> 24 | group_info = build_group_info(paths, rotor_fn, options) 25 | updated_groups = put_in groups, [name], group_info 26 | {group_info, updated_groups} 27 | end 28 | 29 | pid = start_file_watcher(name, group_info.options) 30 | Agent.update __MODULE__, fn(groups)-> 31 | put_in groups, [name, :file_watcher_pid], pid 32 | end 33 | :ok 34 | end 35 | 36 | 37 | def remove(name) do 38 | Agent.update __MODULE__, fn(groups)-> 39 | Rotor.FileWatcherPool.remove(name) 40 | Map.delete groups, name 41 | end 42 | end 43 | 44 | 45 | def run(name) do 46 | group_info = Rotor.group_info name 47 | GenServer.call group_info.file_watcher_pid, :poll 48 | end 49 | 50 | 51 | def run_async(name) do 52 | group_info = Rotor.group_info name 53 | send group_info.file_watcher_pid, :poll 54 | end 55 | 56 | 57 | defp start_file_watcher(name, options) do 58 | case Rotor.FileWatcherPool.add(name, options.manual) do 59 | {:error, {:already_started, _pid}} -> 60 | Rotor.FileWatcherPool.remove(name) 61 | start_file_watcher(name, options) 62 | {:ok, pid} -> 63 | send(pid, :poll) 64 | pid 65 | end 66 | end 67 | 68 | 69 | defp set_default_options(options) do 70 | %{interval: 2500, manual: false} |> Map.merge(options) 71 | end 72 | 73 | 74 | defp build_group_info(paths, rotor_fn, options_passed) do 75 | options = set_default_options(options_passed) 76 | %{paths: paths, rotor_fn: rotor_fn, options: options} 77 | end 78 | 79 | 80 | defp format_paths(paths) do 81 | cond do 82 | String.valid?(paths) || :io_lib.char_list(paths) -> [paths] 83 | true -> paths 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/rotor/runner.ex: -------------------------------------------------------------------------------- 1 | defmodule Rotor.Runner do 2 | require Logger 3 | 4 | def run(name, changed_files, all_files) do 5 | Logger.debug "Running Rotor function for #{name}" 6 | group = Rotor.group_info name 7 | 8 | try do 9 | apply group.rotor_fn, [changed_files, all_files] 10 | rescue 11 | error -> 12 | Logger.debug "Error running rotor function for group: #{name}" 13 | IO.inspect error 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rotor/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Rotor.Supervisor do 2 | use Supervisor 3 | 4 | def start_link do 5 | Supervisor.start_link(__MODULE__, [], name: __MODULE__) 6 | end 7 | 8 | def init([]) do 9 | children = [ 10 | worker(Rotor.ConfigServer, []), 11 | worker(Rotor.GroupServer, []), 12 | supervisor(Rotor.FileWatcherPool, []) 13 | ] 14 | 15 | supervise(children, strategy: :one_for_one) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/rotor/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Rotor.Utils do 2 | 3 | def update_file_index_timestamps(current_index) do 4 | reducer = fn({path, file}, {changed_files, index})-> 5 | case File.stat(path) do 6 | {:ok, stat} -> 7 | {changed_files, file} = if file.last_modified_at != stat.mtime do 8 | { 9 | changed_files ++ [file], 10 | %{file | :last_modified_at => stat.mtime} 11 | } 12 | else 13 | {changed_files, file} 14 | end 15 | updated_index = HashDict.put_new(index, path, file) 16 | {changed_files, updated_index} 17 | _ -> 18 | # Handle delted files 19 | {changed_files, index} 20 | end 21 | end 22 | 23 | Enum.reduce(current_index, {[], HashDict.new()}, reducer) 24 | end 25 | 26 | 27 | def build_file_index(paths) do 28 | build_file_index(paths, HashDict.new) 29 | end 30 | 31 | def build_file_index([], file_index), do: file_index 32 | 33 | def build_file_index([path | paths], file_index) do 34 | updated_file_index = Enum.reduce Path.wildcard(path), file_index, fn(file_path, index)-> 35 | {:ok, file_info} = File.stat(file_path) 36 | if file_info.type == :directory || HashDict.has_key?(index, file_path) do 37 | index 38 | else 39 | file_props = %{:path => file_path, :contents => nil, :last_modified_at => file_info.mtime} 40 | HashDict.put_new index, file_path, file_props 41 | end 42 | end 43 | 44 | build_file_index(paths, updated_file_index) 45 | end 46 | 47 | end 48 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Rotor.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :rotor, 6 | version: "0.3.0", 7 | elixir: ">= 1.0.0", 8 | description: description, 9 | package: package, 10 | deps: deps] 11 | end 12 | 13 | # Configuration for the OTP application 14 | # 15 | # Type `mix help compile.app` for more information 16 | def application do 17 | [applications: [:logger], 18 | mod: {Rotor, []}] 19 | end 20 | 21 | 22 | defp description do 23 | """ 24 | Rotor is a build system for Elixir projects. Use it to compile things, run commands or do anything when files change. 25 | """ 26 | end 27 | 28 | 29 | defp package do 30 | [ 31 | contributors: ["Akash Manohar J"], 32 | licenses: ["MIT"], 33 | links: %{ "GitHub" => "https://github.com/HashNuke/rotor" } 34 | ] 35 | end 36 | 37 | 38 | defp deps do 39 | [] 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/rotor/basic_rotors_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BasicRotorsTest do 2 | use ExUnit.Case 3 | import Rotor.BasicRotors 4 | 5 | 6 | def sample_files do 7 | [ 8 | %{path: "test/samples/test1.txt", contents: nil}, 9 | %{path: "test/samples/test2.txt", contents: nil}, 10 | ] 11 | end 12 | 13 | 14 | test "should read files" do 15 | files = read_files(sample_files) 16 | 17 | assert hd(files).contents == "john\n" 18 | assert List.last(files).contents == "doe\n" 19 | end 20 | 21 | 22 | test "should copy files" do 23 | file1 = "test/samples/outputs/test1.txt" 24 | file2 = "test/samples/outputs/test2.txt" 25 | File.rm(file1) && File.rm(file2) 26 | 27 | copy_files sample_files, "test/samples/outputs" 28 | assert File.exists?(file1) && File.exists?(file2) 29 | end 30 | 31 | 32 | test "should concat files and return a string" do 33 | files = read_files(sample_files) 34 | assert concat(files) == "john\n\ndoe\n" 35 | end 36 | 37 | 38 | test "should content write to the file path specified" do 39 | output_path = "test/samples/outputs/output_test.txt" 40 | if File.exists?(output_path) do 41 | File.rm output_path 42 | end 43 | 44 | read_files(sample_files) 45 | |> concat 46 | |> output_to(output_path) 47 | 48 | {:ok, output} = File.read output_path 49 | assert output == "john\n\ndoe\n" 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/rotor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RotorTest do 2 | use ExUnit.Case 3 | 4 | import Rotor.BasicRotors 5 | 6 | test "state should be initialized on server start" do 7 | current_state = Rotor.all_groups 8 | assert is_map(current_state) 9 | end 10 | 11 | 12 | test "should be able to add and remove groups" do 13 | output_path = "test/samples/outputs/app.js" 14 | Rotor.watch :javascripts, ["test/samples/*.js"], fn(_changed_files, all_files)-> 15 | read_files(all_files) 16 | |> concat 17 | |> output_to(output_path) 18 | end 19 | 20 | current_state = Rotor.all_groups 21 | assert get_in(current_state, [:javascripts]) != nil 22 | 23 | Rotor.remove_group(:javascripts) 24 | current_state = Rotor.all_groups 25 | assert Map.has_key?(current_state, :javascripts) == false 26 | end 27 | 28 | 29 | test "should watch for changes and run pipeline functions" do 30 | output_path = "test/samples/outputs/app.js" 31 | File.rm output_path 32 | Rotor.watch :javascripts_pipeline_test, ["test/samples/*.js"], fn(_changed_files, all_files)-> 33 | read_files(all_files) 34 | |> concat 35 | |> output_to(output_path) 36 | end 37 | 38 | :ok = :timer.sleep(1000) 39 | :ok = File.touch "test/samples/app1.js" 40 | :ok = :timer.sleep(2000) 41 | Rotor.stop_watching(:javascripts_pipeline_test) 42 | 43 | {:ok, contents} = File.read output_path 44 | assert Regex.match?(~r/x=1/, contents) && Regex.match?(~r/y=2/, contents) 45 | end 46 | 47 | 48 | test "should not watch for changes if group is set to manual" do 49 | group_name = :javascripts_pipeline_test 50 | output_path = "test/samples/outputs/app.js" 51 | File.rm output_path 52 | 53 | Rotor.watch(group_name, ["test/samples/*.js"], fn(_changed_files, all_files)-> 54 | read_files(all_files) 55 | |> concat 56 | |> output_to(output_path) 57 | end, %{manual: true}) 58 | 59 | # Touch the file 60 | :ok = :timer.sleep(1000) 61 | :ok = File.touch "test/samples/app1.js" 62 | :ok = :timer.sleep(2000) 63 | 64 | # Should be an error 65 | assert File.read(output_path) == {:error, :enoent} 66 | 67 | Rotor.run(group_name) 68 | Rotor.stop_watching(group_name) 69 | 70 | {:ok, contents} = File.read output_path 71 | assert Regex.match?(~r/x=1/, contents) && Regex.match?(~r/y=2/, contents) 72 | end 73 | 74 | 75 | test "should not watch for changes if group is set to manual (async)" do 76 | group_name = :javascripts_pipeline_test 77 | output_path = "test/samples/outputs/app.js" 78 | File.rm output_path 79 | 80 | Rotor.watch(group_name, ["test/samples/*.js"], fn(_changed_files, all_files)-> 81 | read_files(all_files) 82 | |> concat 83 | |> output_to(output_path) 84 | end, %{manual: true}) 85 | 86 | # Touch the file 87 | :ok = :timer.sleep(1000) 88 | :ok = File.touch "test/samples/app1.js" 89 | :ok = :timer.sleep(2000) 90 | 91 | # Should be an error 92 | assert File.read(output_path) == {:error, :enoent} 93 | 94 | Rotor.run_async(group_name) 95 | :ok = :timer.sleep(1000) 96 | Rotor.stop_watching(group_name) 97 | 98 | {:ok, contents} = File.read output_path 99 | assert Regex.match?(~r/x=1/, contents) && Regex.match?(~r/y=2/, contents) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/samples/app1.js: -------------------------------------------------------------------------------- 1 | x=1; 2 | -------------------------------------------------------------------------------- /test/samples/app2.js: -------------------------------------------------------------------------------- 1 | y=2; 2 | -------------------------------------------------------------------------------- /test/samples/test1.txt: -------------------------------------------------------------------------------- 1 | john 2 | -------------------------------------------------------------------------------- /test/samples/test2.txt: -------------------------------------------------------------------------------- 1 | doe 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | outputs_dir = "test/samples/outputs" 4 | unless File.dir?(outputs_dir) do 5 | File.mkdir(outputs_dir) 6 | end 7 | --------------------------------------------------------------------------------