<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

/**
 * Read a property from the input value and return it
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class PropertyAccessorTransformer implements ConfigurableTransformerInterface
{
    /** @var PropertyAccessorInterface */
    protected $accessor;

    /**
     * @param PropertyAccessorInterface $accessor
     */
    public function __construct(PropertyAccessorInterface $accessor)
    {
        $this->accessor = $accessor;
    }

    /**
     * Must return the transformed $value
     *
     * @param mixed $value
     * @param array $options
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \Symfony\Component\PropertyAccess\Exception\InvalidArgumentException
     * @throws \Symfony\Component\PropertyAccess\Exception\AccessException
     * @throws \Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException
     *
     * @return mixed $value
     */
    public function transform($value, array $options = [])
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);
        /** @noinspection ExceptionsAnnotatingAndHandlingInspection */
        $options = $resolver->resolve($options);

        if (null === $value && $options['ignore_null']) {
            return null;
        }

        if ($options['ignore_missing'] && !$this->accessor->isReadable($value, $options['property_path'])) {
            return null;
        }

        return $this->accessor->getValue($value, $options['property_path']);
    }

    /**
     * Returns the unique code to identify the transformer
     *
     * @return string
     */
    public function getCode()
    {
        return 'property_accessor';
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'property_path',
            ]
        );

        $resolver->setDefaults(
            [
                'ignore_null' => false,
                'ignore_missing' => false,
            ]
        );

        $resolver->setAllowedTypes('property_path', ['string']);
        $resolver->setAllowedTypes('ignore_null', ['boolean']);
        $resolver->setAllowedTypes('ignore_missing', ['boolean']);
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Registry;

use CleverAge\ProcessBundle\Exception\MissingTransformerException;
use CleverAge\ProcessBundle\Transformer\TransformerInterface;

/**
 * Holds all tagged transformer services
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class TransformerRegistry
{
    /** @var TransformerInterface[] */
    protected $transformers = [];

    /**
     * @param TransformerInterface $transformer
     */
    public function addTransformer(TransformerInterface $transformer)
    {
        if (array_key_exists($transformer->getCode(), $this->transformers)) {
            throw new \UnexpectedValueException("Transformer {$transformer->getCode()} is already defined");
        }
        $this->transformers[$transformer->getCode()] = $transformer;
    }

    /**
     * @return TransformerInterface[]
     */
    public function getTransformers()
    {
        return $this->transformers;
    }

    /**
     * @param string $code
     *
     * @throws MissingTransformerException
     *
     * @return TransformerInterface
     */
    public function getTransformer($code)
    {
        if (!$this->hasTransformer($code)) {
            throw new MissingTransformerException($code);
        }

        return $this->transformers[$code];
    }

    /**
     * @param string $code
     *
     * @return bool
     */
    public function hasTransformer($code)
    {
        return array_key_exists($code, $this->transformers);
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Class WrapperTransformer
 *
 * @author  Madeline Veyrenc <mveyrenc@clever-age.com>
 */
class WrapperTransformer implements ConfigurableTransformerInterface
{

    /**
     * Must return the transformed $value
     *
     * @param mixed $input
     * @param array $options
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \Exception
     *
     * @return mixed $value
     */
    public function transform($input, array $options = [])
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);
        $options = $resolver->resolve($options);

        return [$options['wrapper_key'] => $input];
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'wrapper_key',
            ]
        );
        $resolver->setAllowedTypes('wrapper_key', ['string']);
    }

    /**
     * Returns the unique code to identify the transformer
     *
     * @return string
     */
    public function getCode()
    {
        return 'wrapper';
    }
}


<?php

/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task\Serialization;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\SerializerInterface;

/**
 * @author Fabien Salles <fsalles@clever-age.com>
 */
class SerializerTask extends AbstractConfigurableTask
{
    /** @var SerializerInterface */
    protected $serializer;

    /**
     * @param SerializerInterface $serializer
     */
    public function __construct(SerializerInterface $serializer)
    {
        $this->serializer = $serializer;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function execute(ProcessState $state)
    {
        $options = $this->getOptions($state);
        $serializeData = $this->serializer->serialize(
            $state->getInput(),
            $options['format'],
            $options['context']
        );
        $state->setOutput($serializeData);
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'format',
            ]
        );
        $resolver->setAllowedTypes('format', ['string']);
        $resolver->setDefaults(
            [
                'context' => [],
            ]
        );
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Context;

/**
 * Class ContextualOptionResolver
 *
 * @author  Valentin Clavreul <vclavreul@clever-age.com>
 * @author  Madeline Veyrenc <mveyrenc@clever-age.com>
 */
class ContextualOptionResolver
{
    /**
     * Basic value inference
     * Replaces "{{ key }}" by context[key]
     *
     * @param string|array $value
     * @param array        $context
     *
     * @return mixed
     */
    public function contextualizeOption($value, array $context)
    {
        // Recursively parse options
        if (\is_array($value)) {
            return $this->contextualizeOptions($value, $context);
        }

        if (\is_string($value)) {
            $pattern = sprintf('/{{[ ]*(%s){1}[ ]*}}/', implode('|', array_keys($context)));

            $matches = [];
            $result = preg_match($pattern, $value, $matches);

            // If it's an exact match, directly returns the value (allowing complex values such as an array)
            if ($result && $matches[0] === $value) {
                return $context[$matches[1]];
            }

            // Else use a replace to insert a string value into another
            return preg_replace_callback(
                $pattern,
                function ($matches) use ($context) {
                    return $context[$matches[1]];
                },
                $value
            );
        }

        return $value;
    }

    /**
     * Replace all contextualized values from options
     *
     * @param array $options
     * @param array $context
     *
     * @return array
     */
    public function contextualizeOptions(array $options, array $context): array
    {
        $contextualizedOptions = [];
        foreach ($options as $key => $value) {
            $contextualizedKey = $this->contextualizeOption($key, $context);
            $contextualizedValue = $this->contextualizeOption($value, $context);
            $contextualizedOptions[$contextualizedKey] = $contextualizedValue;
        }

        return $contextualizedOptions;
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Convert input based on a callback
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class CallbackTransformer implements ConfigurableTransformerInterface
{
    /**
     * Must return the transformed $value
     *
     * @param mixed $value
     * @param array $options
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     *
     * @return mixed $value
     */
    public function transform($value, array $options = [])
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);
        $options = $resolver->resolve($options);

        if (count($options['additional_parameters'])
            && !count($options['right_parameters'])) {
            $options['right_parameters'] = $options['additional_parameters'];
        }

        $parameters = $options['left_parameters'];
        array_push($parameters, $value, ...$options['right_parameters']);

        return \call_user_func_array($options['callback'], $parameters);
    }

    /**
     * Returns the unique code to identify the transformer
     *
     * @return string
     */
    public function getCode()
    {
        return 'callback';
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'callback',
            ]
        );
        $resolver->setAllowedTypes('callback', ['string', 'array']);
        /** @noinspection PhpUnusedParameterInspection */
        $resolver->setNormalizer(
            'callback',
            function (Options $options, $value) {
                if (!\is_callable($value)) {
                    throw new InvalidOptionsException(
                        'Callback option must be callable'
                    );
                }

                return $value;
            }
        );
        $resolver->setDefaults(
            [
                'left_parameters' => [],
                'right_parameters' => [],
                'additional_parameters' => [],
            ]
        );
        $resolver->setAllowedTypes('left_parameters', ['array']);
        $resolver->setAllowedTypes('right_parameters', ['array']);
        $resolver->setAllowedTypes('additional_parameters', ['array']);

        /** @noinspection PhpUnusedParameterInspection */
        $resolver->setNormalizer(
            'additional_parameters',
            function (Options $options, $value) {
                if ($value) {
                    @trigger_error('The "additional_parameters" option is deprecated. Use "right_parameters" instead.', E_USER_DEPRECATED);
                }

                return $value;
            }
        );
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Class EvaluatorTransformer
 *
 * @author  Madeline Veyrenc <mveyrenc@clever-age.com>
 */
class EvaluatorTransformer implements ConfigurableTransformerInterface
{
    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'expression',
            ]
        );
        $resolver->setAllowedTypes('expression', ['string']);
    }

    /**
     * @param mixed $value
     * @param array $options
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\OptionDefinitionException
     * @throws \Symfony\Component\OptionsResolver\Exception\NoSuchOptionException
     * @throws \Symfony\Component\OptionsResolver\Exception\MissingOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     *
     * @return string
     */
    public function transform($value, array $options = [])
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);
        $options = $resolver->resolve($options);

        $language = new ExpressionLanguage();

        return $language->evaluate(
            $options['expression'],
            $value
        );
    }

    /**
     * @return string
     */
    public function getCode()
    {
        return 'evaluator';
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\EventListener;

use CleverAge\ProcessBundle\Event\EventDispatcherTaskEvent;

/**
 * Class DataQueueEventListener
 * This is a basic queue, mainly aiming to catch data coming out of a process
 * Used mostly for testing purpose
 *
 * @author  Valentin Clavreul <vclavreul@¢levere-age.com>
 */
class DataQueueEventListener
{
    /** @var \SplQueue[] */
    protected $queues = [];

    /**
     * @param EventDispatcherTaskEvent $event
     */
    public function pushData(EventDispatcherTaskEvent $event)
    {
        $queue = $this->getQueue($event->getState()->getProcessConfiguration()->getCode());
        $queue->push(clone $event->getState());
    }

    /**
     * @param string $processName
     *
     * @return \SplQueue
     */
    public function getQueue($processName): \SplQueue
    {
        if (!array_key_exists($processName, $this->queues)) {
            $this->queues[$processName] = new \SplQueue();
        }

        return $this->queues[$processName];
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task\File;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Move the file passed as input, requires the destination path in options
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class FileMoverTask extends AbstractConfigurableTask
{
    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\Filesystem\Exception\IOException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \UnexpectedValueException
     */
    public function execute(ProcessState $state)
    {
        $options = $this->getOptions($state);
        $fs = new Filesystem();
        $file = $state->getInput();
        if (!$fs->exists($file)) {
            throw new \UnexpectedValueException("File does not exists: '{$file}'");
        }
        $dest = $options['destination'];
        if (is_dir($dest)) {
            $dest = rtrim($dest, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.basename($file);
        }
        if ($options['autoincrement']) {
            $dest = $this->makeFilenameUnique($dest);
        }
        $fs->rename($file, $dest, $options['overwrite']);
        $state->setOutput($dest);
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'destination',
            ]
        );
        $resolver->setAllowedTypes('destination', ['string']);
        $resolver->setDefaults(
            [
                'overwrite' => false,
                'autoincrement' => false,
            ]
        );
        $resolver->setAllowedTypes('overwrite', ['boolean']);
        $resolver->setAllowedTypes('autoincrement', ['boolean']);
    }

    /**
     * @param string $dest
     *
     * @return string
     */
    protected function makeFilenameUnique($dest)
    {
        $fs = new Filesystem();
        $i = 1;
        while ($fs->exists($dest)) {
            if (preg_match('/^(.*?)(-\d+)?(\.[^\.]*)$/', $dest, $matches)) {
                $dest = $matches[1].'-'.$i.$matches[3];
                ++$i;
            } else {
                $dest .= '-'.$i; // Fallback brutal mode
            }
        }

        return $dest;
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\FlushableTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Simple example of how to manage an internal buffer for batch processing
 *
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class SimpleBatchTask extends AbstractConfigurableTask implements FlushableTaskInterface
{
    /** @var array */
    protected $elements = [];

    /**
     * @param ProcessState $state
     */
    public function flush(ProcessState $state)
    {
        if (0 === \count($this->elements)) {
            $state->setSkipped(true);
        } else {
            $state->setOutput($this->elements);
            $this->elements = [];
        }
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \InvalidArgumentException
     */
    public function execute(ProcessState $state)
    {
        $batchCount = $this->getOption($state, 'batch_count');
        $this->elements[] = $state->getInput();

        if (null !== $batchCount && \count($this->elements) >= $batchCount) {
            $state->setOutput($this->elements);
            $this->elements = [];
        } else {
            $state->setSkipped(true);
        }
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(
            [
                'batch_count' => 10,
            ]
        );
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Command;

use CleverAge\ProcessBundle\Configuration\ProcessConfiguration;
use CleverAge\ProcessBundle\Configuration\TaskConfiguration;
use CleverAge\ProcessBundle\Model\BlockingTaskInterface;
use CleverAge\ProcessBundle\Model\FlushableTaskInterface;
use CleverAge\ProcessBundle\Model\IterableTaskInterface;
use CleverAge\ProcessBundle\Model\TaskInterface;
use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry;
use CleverAge\ProcessBundle\Task\Process\ProcessExecutorTask;
use CleverAge\ProcessBundle\Task\Process\ProcessLauncherTask;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Describe a process configuration
 * This is a POC, waiting to evolve properly
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 */
class ProcessHelpCommand extends Command
{
    protected const CHAR_DOWN = '│';
    protected const CHAR_MERGE = '┘';
    protected const CHAR_MULTIMERGE = '┴─';
    protected const CHAR_JUMP = '┿─';
    protected const CHAR_HORIZ = '──';
    protected const CHAR_MULTIEXPAND = '┬─';
    protected const CHAR_EXPAND = '┐';
    protected const CHAR_RECEIVE = '├─';
    protected const CHAR_NODE = '■';

    protected const BRANCH_SIZE = 2;
    protected const INDENT_SIZE = 4;

    /** @var ProcessConfigurationRegistry */
    protected $processConfigRegistry;

    /** @var ContainerInterface */
    protected $container;

    /**
     * @param ProcessConfigurationRegistry $processConfigRegistry
     * @param ContainerInterface           $container
     *
     * @throws \Symfony\Component\Console\Exception\LogicException
     */
    public function __construct(ProcessConfigurationRegistry $processConfigRegistry, ContainerInterface $container)
    {
        $this->processConfigRegistry = $processConfigRegistry;
        $this->container = $container;
        parent::__construct();
    }

    /**
     * {@inheritdoc}
     *
     * @throws \Symfony\Component\Console\Exception\InvalidArgumentException
     */
    protected function configure()
    {
        $this->setName('cleverage:process:help');
        $this->setDescription('Describe the process');
        $this->addArgument('process_code');
    }

    /**
     * {@inheritdoc}
     *
     * @throws \Psr\Container\NotFoundExceptionInterface
     * @throws \Psr\Container\ContainerExceptionInterface
     * @throws \UnexpectedValueException
     * @throws \Symfony\Component\Console\Exception\InvalidArgumentException
     * @throws \CleverAge\ProcessBundle\Exception\MissingProcessException
     * @throws \CleverAge\ProcessBundle\Exception\MissingTaskConfigurationException
     * @throws \InvalidArgumentException
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $output->getFormatter()->setStyle('fire', new OutputFormatterStyle('red'));

        $processCode = $input->getArgument('process_code');
        $process = $this->processConfigRegistry->getProcessConfiguration($processCode);

        $output->writeln("<comment>Process: </comment>");
        $output->writeln(str_repeat(' ', self::INDENT_SIZE) . $processCode);
        $output->writeln('');

        if ($process->getDescription()) {
            $output->writeln("<comment>Description:</comment>");
            $output->writeln(str_repeat(' ', self::INDENT_SIZE) . $process->getDescription());
            $output->writeln('');
        }

        if ($process->getHelp()) {
            $output->writeln("<comment>Help:</comment>");
            $helpLines = array_filter(explode("\n", $process->getHelp()));
            foreach ($helpLines as $helpLine) {
                $output->writeln(str_repeat(' ', self::INDENT_SIZE) . $helpLine);
            }
            $output->writeln('');
        }

        $output->writeln("<comment>Tasks tree:</comment>");

        $branches = [];

        $taskList = $process->getMainTaskGroup();
        $remainingTasks = $taskList;
        $totalBranches = \count($taskList);
        for ($i = 0; $i < $totalBranches; $i++) {
            // Find the best task to display
            $nextTaskCode = $this->findBestNextTask($branches, $remainingTasks, $process);

            $this->resolveBranchOutput($branches, $nextTaskCode, $process, $output);

            // Remove the task from the remaining list
            $remainingTasks = array_filter($remainingTasks, function ($task) use ($nextTaskCode) {
                return $task != $nextTaskCode;
            });
        }

        $branches = array_filter($branches);
        if (!empty($branches)) {
            $branchStr = '[' . implode(', ', $branches) . ']';
            $output->writeln("<error>All branches are not resolved : {$branchStr}</error>");
        }
    }

    /**
     * Try to find a best candidate for next display
     *
     * @param                      $branches
     * @param                      $taskList
     * @param ProcessConfiguration $process
     *
     * @return int|null|string
     */
    protected function findBestNextTask($branches, $taskList, ProcessConfiguration $process)
    {
        // Get resolvable tasks
        $taskCandidates = [];
        foreach ($taskList as $taskCode) {
            $task = $process->getTaskConfiguration($taskCode);
            if (empty($task->getPreviousTasksConfigurations())) {
                return $taskCode;
            }

            // Check if task has all necessary ancestors in branches
            $hasAllAncestors = array_reduce($task->getPreviousTasksConfigurations(), function ($result, TaskConfiguration $prevTask) use ($branches) {
                return $result && \in_array($prevTask->getCode(), $branches);
            }, true);

            if ($hasAllAncestors) {
                $taskCandidates[] = $taskCode;
            }
        }

        if (empty($taskCandidates)) {
            throw new \UnexpectedValueException('Cannot find a task to output');
        }

        // Try to find the task the most on the right
        $taskWeights = [];
        foreach ($taskCandidates as $taskCandidate) {
            $weight = 0;
            $task = $process->getTaskConfiguration($taskCandidate);
            foreach ($task->getPreviousTasksConfigurations() as $prevTask) {
                $key = array_search($prevTask->getCode(), $branches);

                // Should never be non-numeric...
                if (!is_numeric($key)) {
                    throw new \UnexpectedValueException('Invalid key type');
                }
                $weight += $key;
            }

            if (!empty($task->getPreviousTasksConfigurations())) {
                $weight = $weight / \count($task->getPreviousTasksConfigurations());
            }

            $taskWeights[$taskCandidate] = $weight;
        }

        arsort($taskWeights);
        $bestCandidate = key($taskWeights);
        $bestWeight = $taskWeights[$bestCandidate];

        $equalWeights = array_filter($taskWeights, function ($item) use ($bestWeight) {
            return $item == $bestWeight;
        });

        if (count($equalWeights) == 1) {
            return $bestCandidate;
        }

        // If a few tasks have the same weight, return the tasks with the lowest number of children
        $childCounts = [];
        foreach ($equalWeights as $taskCode => $weight) {
            $task = $process->getTaskConfiguration($taskCode);
            $childCounts[$taskCode] = $this->getTaskChildrenCount($task);
        }
        asort($childCounts);

        return key($childCounts);
    }

    /**
     * Get the number of children (error or not) of a task
     *
     * @param TaskConfiguration $task
     *
     * @return int
     */
    protected function getTaskChildrenCount(TaskConfiguration $task)
    {
        $count = 0;

        foreach ($task->getNextTasksConfigurations() as $nextTasksConfiguration) {
            $count += 1 + $this->getTaskChildrenCount($nextTasksConfiguration);
        }

        foreach ($task->getErrorTasksConfigurations() as $errorTasksConfiguration) {
            $count += 1 + $this->getTaskChildrenCount($errorTasksConfiguration);
        }

        return $count;
    }

    /**
     * Merge needed branches, display a task node, split following needed branches
     *
     * @param                      $branches
     * @param                      $taskCode
     * @param ProcessConfiguration $process
     * @param OutputInterface      $output
     */
    protected function resolveBranchOutput(&$branches, $taskCode, ProcessConfiguration $process, OutputInterface $output)
    {
        $task = $process->getTaskConfiguration($taskCode);
        $branchesToMerge = [];
        $gapBranches = [];
        $origin = null;
        $final = null;

        // Get unique previous branches
        $previousTasks = [];
        foreach ($task->getPreviousTasksConfigurations() as $previousTasksConfiguration) {
            $previousTasks[$previousTasksConfiguration->getCode()] = $previousTasksConfiguration;
        }

        // Check previous branches
        if (empty($previousTasks)) {
            $branches[] = $task->getCode();
        } elseif (1 === \count($previousTasks)) {
            $prevTask = current($previousTasks)->getCode();
            foreach (array_reverse($branches, true) as $i => $branchTask) {
                if ($branchTask === $prevTask) {
                    $branches[$i] = $taskCode;
                    break;
                }
            }
        } else {
            foreach ($previousTasks as $prevTask) {
                $foundBranch = false;
                foreach (array_reverse($branches, true) as $i => $branchTask) {
                    if ($branchTask === $prevTask->getCode()) {
                        $branchesToMerge[] = $i;
                        $foundBranch = true;
                        break;
                    }
                }

                if (!$foundBranch) {
                    $output->writeln(
                        "<error>Could not find previous branch : {$taskCode} depends on {$prevTask->getCode()}</error>"
                    );
                }
            }

            // Don't touch the 1st branch to merge
            sort($branchesToMerge);

            $gapFrom = null;
            $gapTo = null;
            foreach ($branchesToMerge as $i) {
                $gapTo = $i;
                if (null !== $gapFrom) {
                    for ($j = $gapFrom + 1; $j < $gapTo; ++$j) {
                        $gapBranches[] = $j;
                    }
                }
                $gapFrom = $i;
            }

            $origin = array_shift($branchesToMerge);
            $final = $gapFrom;
            $branches[$origin] = $taskCode;
        }

        // Merge branches
        if (!empty($branchesToMerge)) {
            $this->writeBranches($output, $branches);

            $this->writeBranches(
                $output,
                $branches,
                '',
                function ($taskCode, $i) use ($branchesToMerge, $gapBranches, $origin) {
                    return \in_array($i, $branchesToMerge, true)
                        || \in_array($i, $gapBranches, true)
                        || $i === $origin;
                },
                function ($taskCode, $i) use ($gapBranches, $origin, $final, $branches) {
                    if ($i === $origin) {
                        return self::CHAR_RECEIVE;
                    }
                    if (\in_array($i, $gapBranches, true)) {
                        if (null !== $branches[$i]) {
                            return self::CHAR_JUMP;
                        }

                        return self::CHAR_HORIZ;
                    }

                    if ($i === $final) {
                        return self::CHAR_MERGE;
                    }

                    return self::CHAR_MULTIMERGE;
                }
            );

            foreach ($branches as $i => $branchTask) {
                if (\in_array($i, $branchesToMerge, true)) {
                    $branches[$i] = null;
                }
            }
            $this->writeBranches($output, $branches);
        }

        // Cleanup empty trailing branches
        foreach (array_reverse($branches, true) as $i => $branchTask) {
            if (null !== $branchTask) {
                $branches = \array_slice($branches, 0, $i + 1);
                break;
            }
        }

        // Write main line
        $nodeStr = self::CHAR_NODE;
        if ($task->isInErrorBranch()) {
            $nodeStr = "<fire>{$nodeStr}</fire>";
        }

        $this->writeBranches(
            $output,
            $branches,
            $this->getTaskDescription($task),
            function ($branchTask, $i) use ($taskCode) {
                return $branchTask === $taskCode;
            },
            $nodeStr
        );

        // Write task help message
        if ($output->isVerbose() && $task->getHelp()) {
            $helpLines = array_filter(explode("\n", $task->getHelp()));
            foreach ($helpLines as $helpLine) {
                $helpMessage = str_repeat(' ', self::INDENT_SIZE) . "<info>{$helpLine}</info>";
                $this->writeBranches($output, $branches, $helpMessage);
            }
        }

        // Check next tasks
        $nextTasks = array_unique(array_map(
            function (TaskConfiguration $task) {
                return $task->getCode();
            },
            array_merge($task->getNextTasksConfigurations(), $task->getErrorTasksConfigurations())
        ));
        if (\count($nextTasks) > 1) {
            $this->writeBranches($output, $branches);
            array_shift($nextTasks);
            $origin = array_search($taskCode, $branches, true);
            $expandBranches = [];
            foreach ($nextTasks as $nextTask) {
                $index = array_search(null, $branches, true);
                if (false !== $index && $index >= $origin) {
                    /** @var $index int */
                    $branches[$index] = $taskCode;
                    $expandBranches[] = $index;
                } else {
                    $expandBranches[] = \count($branches);
                    $branches[] = $taskCode;
                }
            }
            $gapBranches = [];
            sort($expandBranches);
            $gapFrom = $origin;
            $gapTo = null;

            foreach ($expandBranches as $i) {
                $gapTo = $i;
                for ($j = $gapFrom + 1; $j < $gapTo; ++$j) {
                    $gapBranches[] = $j;
                }
                $gapFrom = $i;
            }
            $final = $gapFrom;

            $this->writeBranches(
                $output,
                $branches,
                '',
                function ($branchTask, $i) use ($origin, $final) {
                    return $i >= $origin && $i <= $final;
                },
                function ($branchTask, $i) use ($origin, $branches, $gapBranches, $final) {
                    if ($i === $origin) {
                        return self::CHAR_RECEIVE;
                    }
                    if (\in_array($i, $gapBranches, true)) {
                        if (null !== $branches[$i]) {
                            return self::CHAR_JUMP;
                        }

                        return self::CHAR_HORIZ;
                    }
                    if ($final === $i) {
                        return self::CHAR_EXPAND;
                    }

                    return self::CHAR_MULTIEXPAND;
                }
            );
        }

        if (empty($nextTasks)) {
            foreach ($branches as $i => $branchTask) {
                if ($branchTask === $taskCode) {
                    $branches[$i] = null;
                }
            }
        }

        // Cleanup empty trailing branches
        foreach (array_reverse($branches, true) as $i => $branchTask) {
            if (null !== $branchTask) {
                $branches = \array_slice($branches, 0, $i + 1);
                break;
            }
        }

        $this->writeBranches($output, $branches);
    }

    /**
     * @param OutputInterface $output
     * @param array           $branches
     * @param string          $comment
     * @param callable        $match
     * @param string|callable $char
     *
     * @throws \InvalidArgumentException
     */
    protected function writeBranches(OutputInterface $output, $branches, $comment = '', $match = null, $char = null)
    {
        $output->write(str_repeat(' ', self::INDENT_SIZE));

        // Merge lines
        foreach ($branches as $i => $branchTask) {
            $str = '';
            if (null !== $match && $match($branchTask, $i)) {
                if (\is_string($char)) {
                    $str = $char;
                } elseif (\is_callable($char)) {
                    $str = $char($branchTask, $i);
                } else {
                    throw new \InvalidArgumentException('Char must be string|callable');
                }
            } elseif (null !== $branchTask) {
                $str = self::CHAR_DOWN;
            }

            // Str_pad does not work with unicode ?
            $noFormatStrLen = mb_strlen(preg_replace('/<[^>]*>/', '', $str));
            for ($j = $noFormatStrLen; $j < self::BRANCH_SIZE; ++$j) {
                $str .= ' ';
            }
            $output->write($str);
        }
        $output->writeln($comment);
    }

    /**
     * @param TaskConfiguration $task
     *
     * @throws \Psr\Container\NotFoundExceptionInterface
     * @throws \Psr\Container\ContainerExceptionInterface
     * @throws \UnexpectedValueException
     *
     * @return string
     */
    protected function getTaskDescription(TaskConfiguration $task)
    {
        $description = $task->getCode();
        $interfaces = [];
        $subprocess = [];
        $taskService = $this->getTaskService($task);

        if ($taskService instanceof IterableTaskInterface) {
            $interfaces[] = 'Iterable';
        }

        if ($taskService instanceof BlockingTaskInterface) {
            $interfaces[] = 'Blocking';
        }

        if ($taskService instanceof FlushableTaskInterface) {
            $interfaces[] = 'Flushable';
        }

        if ($taskService instanceof ProcessExecutorTask || $taskService instanceof ProcessLauncherTask) {
            $subprocess[] = $task->getOption('process');
        }

        if (\count($interfaces)) {
            $description .= ' <info>(' . implode(', ', $interfaces) . ')</info>';
        }

        if (\count($subprocess)) {
            $description .= ' <fire>{' . implode(', ', $subprocess) . '}</fire>';
        }

        if ($task->getDescription()) {
            $description .= " <comment>{$task->getDescription()}</comment>";
        }

        return $description;
    }

    /**
     * @param TaskConfiguration $taskConfiguration
     *
     * @throws \Psr\Container\NotFoundExceptionInterface
     * @throws \Psr\Container\ContainerExceptionInterface
     * @throws \UnexpectedValueException
     *
     * @return mixed
     */
    protected function getTaskService(TaskConfiguration $taskConfiguration)
    {
        // Duplicate code from \CleverAge\ProcessBundle\Manager\ProcessManager::initialize
        // @todo Refactor this using a Registry with this feature:
        // https://symfony.com/doc/current/service_container/service_subscribers_locators.html
        $serviceReference = $taskConfiguration->getServiceReference();
        if (0 === strpos($serviceReference, '@')) {
            $task = $this->container->get(ltrim($serviceReference, '@'));
        } elseif ($this->container->has($serviceReference)) {
            $task = $this->container->get($serviceReference);
        } else {
            throw new \UnexpectedValueException(
                "Unable to resolve service reference for Task '{$taskConfiguration->getCode()}'"
            );
        }
        if (!$task instanceof TaskInterface) {
            throw new \UnexpectedValueException(
                "Service defined in Task '{$taskConfiguration->getCode()}' is not a TaskInterface"
            );
        }

        return $task;
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task;

use CleverAge\ProcessBundle\Configuration\TaskConfiguration;
use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\ProcessState;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

/**
 * Accepts an object or an array as input and sets values from configuration
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class PropertySetterTask extends AbstractConfigurableTask
{
    /** @var LoggerInterface */
    protected $logger;

    /** @var PropertyAccessorInterface */
    protected $accessor;

    /**
     * @param LoggerInterface           $logger
     * @param PropertyAccessorInterface $accessor
     */
    public function __construct(LoggerInterface $logger, PropertyAccessorInterface $accessor)
    {
        $this->logger = $logger;
        $this->accessor = $accessor;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Exception
     */
    public function execute(ProcessState $state)
    {
        $options = $this->getOptions($state);
        $input = $state->getInput();
        /** @noinspection ForeachSourceInspection */
        foreach ($options['values'] as $key => $value) {
            try {
                $this->accessor->setValue($input, $key, $value);
            } catch (\Exception $e) {
                $state->addErrorContextValue('property', $key);
                $state->addErrorContextValue('value', $value);
                $state->setException($e);

                return;
            }
        }

        $state->setOutput($input);
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'values',
            ]
        );
        $resolver->setAllowedTypes('values', ['array']);
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\BlockingTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Merge every input array, and return the result
 */
class ArrayMergeTask extends AbstractConfigurableTask implements BlockingTaskInterface
{
    /** @var array */
    protected $mergedOutput = [];

    /**
     * @param ProcessState $state
     *
     * @throws \UnexpectedValueException
     */
    public function execute(ProcessState $state)
    {
        $input = $state->getInput();
        if (!\is_array($input)) {
            throw new \UnexpectedValueException('Input must be an array');
        }

        $mergeFunction = $this->getOption($state, 'merge_function');
        if ($mergeFunction == 'array_merge') {
            $this->mergedOutput = \array_merge($this->mergedOutput, $input);
        } elseif ($mergeFunction == 'array_merge_recursive') {
            $this->mergedOutput = \array_merge_recursive($this->mergedOutput, $input);
        } elseif ($mergeFunction == 'array_replace') {
            $this->mergedOutput = \array_replace($this->mergedOutput, $input);
        } elseif ($mergeFunction == 'array_replace_recursive') {
            $this->mergedOutput = \array_replace_recursive($this->mergedOutput, $input);
        } else {
            throw new \InvalidArgumentException("Unknown merge function {$mergeFunction}");
        }
    }

    /**
     * @param ProcessState $state
     */
    public function proceed(ProcessState $state)
    {
        $state->setOutput($this->mergedOutput);
    }

    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefault('merge_function', 'array_merge');
        $resolver->setAllowedTypes('merge_function', 'string');
        $resolver->setAllowedValues('merge_function', ['array_merge', 'array_merge_recursive', 'array_replace', 'array_replace_recursive']);
    }

}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task\Serialization;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

/**
 * Normalize input to output with configurable format
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class NormalizerTask extends AbstractConfigurableTask
{
    /** @var NormalizerInterface */
    protected $normalizer;

    /**
     * @param NormalizerInterface $normalizer
     */
    public function __construct(NormalizerInterface $normalizer)
    {
        $this->normalizer = $normalizer;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\Serializer\Exception\LogicException
     * @throws \Symfony\Component\Serializer\Exception\InvalidArgumentException
     * @throws \Symfony\Component\Serializer\Exception\CircularReferenceException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function execute(ProcessState $state)
    {
        $options = $this->getOptions($state);

        if (!$this->normalizer->supportsNormalization($state->getInput(), $options['format'])) {
            throw new \UnexpectedValueException('Given value is not normalizable for format ' . $options['format']);
        }

        $normalizedData = $this->normalizer->normalize(
            $state->getInput(),
            $options['format'],
            $options['context']
        );
        $state->setOutput($normalizedData);
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'format',
            ]
        );
        $resolver->setAllowedTypes('format', ['string']);
        $resolver->setDefaults(
            [
                'context' => [],
            ]
        );
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task;

use CleverAge\ProcessBundle\Configuration\TaskConfiguration;
use CleverAge\ProcessBundle\Exception\InvalidProcessConfigurationException;
use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\BlockingTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Wait for defined inputs before passing an aggregated output.
 * Should have been a BlockingTask, but due to limitations in the current model, it's a hack using skips and finalize.
 *
 * @see README.md:Known issues
 */
class RowAggregatorTask extends AbstractConfigurableTask implements BlockingTaskInterface
{
    /** @var LoggerInterface */
    protected $logger;

    /**
     * @var array
     */
    protected $result = [];

    /**
     * @param LoggerInterface $logger
     */
    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    /**
     * Store inputs and once everything has been received, pass to next task
     * Once an output has been generated this task is reset, and may wait for another loop
     *
     * @param ProcessState $state
     *
     * @throws \UnexpectedValueException
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function execute(ProcessState $state)
    {
        $input = $state->getInput();

        $aggregateBy = $this->getOption($state, 'aggregate_by');
        $aggregateColumns = $this->getOption($state, 'aggregate_columns');
        $aggregationKey = $this->getOption($state, 'aggregation_key');

        if (!array_key_exists($aggregateBy, $input)) {
            throw new InvalidProcessConfigurationException(
                "Array aggregator exception: missing column '{$aggregateBy}'"
            );
        }

        $inputAggregateBy = $input[$aggregateBy];

        if (!array_key_exists($inputAggregateBy, $this->result)) {
            $this->result[$inputAggregateBy] = $input;
            foreach ($aggregateColumns as $aggregateColumn) {
                if (array_key_exists($aggregateColumn, $this->result[$inputAggregateBy])) {
                    unset($this->result[$inputAggregateBy][$aggregateColumn]);
                }
            }
        }

        $inputAggregateColumns = [];
        foreach ($aggregateColumns as $aggregateColumn) {
            if (!array_key_exists($aggregateColumn, $input)) {
                throw new InvalidProcessConfigurationException(
                    "Array aggregator exception: missing column {$aggregateColumn}"
                );
            }
            $inputAggregateColumns[$aggregateColumn] = $input[$aggregateColumn];
        }
        $this->result[$inputAggregateBy][$aggregationKey][] = $inputAggregateColumns;
    }

    /**
     * @param ProcessState $state
     */
    public function proceed(ProcessState $state)
    {
        $state->setOutput(array_values($this->result));
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired('aggregate_by');
        $resolver->setRequired('aggregate_columns');
        $resolver->setRequired('aggregation_key');
        $resolver->setAllowedTypes('aggregate_by', 'string');
        $resolver->setAllowedTypes('aggregate_columns', 'array');
        $resolver->setAllowedTypes('aggregation_key', 'string');
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task\Reporting;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\ProcessState;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

/**
 * Class LoggerTask
 *
 * Add custom log in state
 *
 * @author  Madeline Veyrenc <mveyrenc@clever-age.com>
 */
class LoggerTask extends AbstractConfigurableTask
{
    /** @var LoggerInterface */
    protected $logger;

    /** @var PropertyAccessorInterface */
    protected $accessor;

    /**
     * @param LoggerInterface           $logger
     * @param PropertyAccessorInterface $accessor
     *
     * @internal param LoggerInterface $logger
     */
    public function __construct(
        LoggerInterface $logger,
        PropertyAccessorInterface $accessor
    ) {
        $this->logger = $logger;
        $this->accessor = $accessor;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \Symfony\Component\PropertyAccess\Exception\AccessException
     * @throws \Symfony\Component\PropertyAccess\Exception\InvalidArgumentException
     * @throws \Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException
     * @throws \Symfony\Component\Serializer\Exception\CircularReferenceException
     * @throws \Symfony\Component\Serializer\Exception\InvalidArgumentException
     * @throws \Symfony\Component\Serializer\Exception\LogicException
     */
    public function execute(ProcessState $state)
    {
        $options = $this->getOptions($state);
        $context = [];
        foreach ($options['context'] as $contextInfo) {
            $context[$contextInfo] = $this->accessor->getValue($state, $contextInfo);
        }
        if ($options['reference']) {
            $context['reference'] = $options['reference'];
        }
        $this->logger->log($options['level'], $options['message'], $context);

        $state->setOutput($state->getInput());
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(
            [
                'level' => 'debug',
                'message' => 'Log state input',
                'context' => ['input'],
                'reference' => null,
            ]
        );
        $resolver->setAllowedTypes('level', ['string']);
        $resolver->setAllowedTypes('message', ['string']);
        $resolver->setAllowedTypes('context', ['array']);
        $resolver->setAllowedTypes('reference', ['string', 'NULL']);
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task\Doctrine;

use CleverAge\ProcessBundle\Model\IterableTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Internal\Hydration\IterableResult;
use Psr\Log\LoggerInterface;

/**
 * Fetch entities from doctrine
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class DoctrineReaderTask extends AbstractDoctrineQueryTask implements IterableTaskInterface
{
    /** @var LoggerInterface */
    protected $logger;

    /** @var IterableResult */
    protected $iterator;

    /**
     * @param LoggerInterface $logger
     * @param ManagerRegistry $doctrine
     */
    public function __construct(LoggerInterface $logger, ManagerRegistry $doctrine)
    {
        $this->logger = $logger;
        parent::__construct($doctrine);
    }

    /**
     * Moves the internal pointer to the next element,
     * return true if the task has a next element
     * return false if the task has terminated it's iteration
     *
     * @param ProcessState $state
     *
     * @throws \LogicException
     *
     * @return bool
     */
    public function next(ProcessState $state)
    {
        if (!$this->iterator) {
            return false;
        }
        $this->iterator->next();

        return $this->iterator->valid();
    }

    /**
     * @param ProcessState $state
     *
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \UnexpectedValueException
     */
    public function execute(ProcessState $state)
    {
        $options = $this->getOptions($state);
        if (!$this->iterator) {
            $class = $options['class_name'];
            $entityManager = $this->doctrine->getManagerForClass($class);
            if (!$entityManager instanceof EntityManagerInterface) {
                throw new \UnexpectedValueException("No manager found for class {$class}");
            }
            $repository = $entityManager->getRepository($class);
            if (!$repository instanceof EntityRepository) {
                throw new \UnexpectedValueException("No repository found for class {$class}");
            }
            $this->initIterator($repository, $options);
        }

        $result = $this->iterator->current();

        // Handle empty results
        if (false === $result) {
            $logContext = ['options' => $options];
            $this->logger->log($options['empty_log_level'], 'Empty resultset for query', $logContext);
            $state->setSkipped(true);
            $this->iterator = null;

            return;
        }

        $state->setOutput(reset($result));
    }

    /**
     * @param EntityRepository $repository
     * @param array            $options
     *
     * @throws \UnexpectedValueException
     */
    protected function initIterator(EntityRepository $repository, array $options)
    {
        $qb = $this->getQueryBuilder(
            $repository,
            $options['criteria'],
            $options['order_by'],
            $options['limit'],
            $options['offset']
        );

        $this->iterator = $qb->getQuery()->iterate();
        $this->iterator->next(); // Move to first element
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\DependencyInjection;

use CleverAge\ProcessBundle\Configuration\TaskConfiguration;
use Psr\Log\LogLevel;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\NodeBuilder;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

/**
 * This is the class that validates and merges configuration from your app/config files.
 *
 * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/configuration.html}
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class Configuration implements ConfigurationInterface
{
    /** @var string */
    protected $root;

    /**
     * @param string $root
     */
    public function __construct($root = 'clever_age_process')
    {
        $this->root = $root;
    }

    /**
     * {@inheritdoc}
     *
     * @throws \RuntimeException
     */
    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder();
        $rootNode = $treeBuilder->root($this->root);
        $definition = $rootNode->children();

        /** @var ArrayNodeDefinition $configurationsArrayDefinition */
        $configurationsArrayDefinition = $definition
            ->scalarNode('default_error_strategy')->defaultValue(TaskConfiguration::STRATEGY_SKIP)->end()
            ->arrayNode('configurations')
            ->useAttributeAsKey('code')
            ->prototype('array');

        // Process list
        $processListDefinition = $configurationsArrayDefinition
            ->performNoDeepMerging()
            ->cannotBeOverwritten()
            ->children();

        $this->appendProcessConfigDefinition($processListDefinition);

        $processListDefinition->end();
        $configurationsArrayDefinition->end();
        $definition->end();

        return $treeBuilder;
    }

    /**
     * @param NodeBuilder $definition
     */
    protected function appendProcessConfigDefinition(NodeBuilder $definition)
    {
        $definition
            ->scalarNode('entry_point')->defaultNull()->end()
            ->scalarNode('end_point')->defaultNull()->end()
            ->scalarNode('description')->defaultValue('')->end()
            ->scalarNode('help')->defaultValue('')->end()
            ->scalarNode('public')->defaultTrue()->end()
            ->arrayNode('options')->prototype('variable')->end()->end();

        /** @var ArrayNodeDefinition $tasksArrayDefinition */
        $tasksArrayDefinition = $definition
            ->arrayNode('tasks')
            ->useAttributeAsKey('code')
            ->prototype('array');

        // Process list
        $taskListDefinition = $tasksArrayDefinition
            ->performNoDeepMerging()
            ->cannotBeOverwritten()
            ->children();

        $this->appendTaskConfigDefinition($taskListDefinition);

        $taskListDefinition->end();
        $tasksArrayDefinition->end();
    }

    /**
     * @param NodeBuilder $definition
     */
    protected function appendTaskConfigDefinition(NodeBuilder $definition)
    {
        $logLevels = [
            LogLevel::EMERGENCY,
            LogLevel::ALERT,
            LogLevel::CRITICAL,
            LogLevel::ERROR,
            LogLevel::WARNING,
            LogLevel::NOTICE,
            LogLevel::INFO,
            LogLevel::DEBUG,
        ];
        $definition
            ->scalarNode('service')->isRequired()->end()
            ->scalarNode('description')->defaultValue('')->end()
            ->scalarNode('help')->defaultValue('')->end()
            ->arrayNode('options')->prototype('variable')->end()->end()
            ->arrayNode('outputs')->prototype('scalar')->end()->end()
            ->arrayNode('errors')->prototype('scalar')->end()->setDeprecated()->end()
            ->arrayNode('error_outputs')->prototype('scalar')->end()->end()
            ->scalarNode('error_strategy')->defaultNull()->end()
            ->enumNode('log_level')->values($logLevels)->defaultValue(LogLevel::CRITICAL)->end()
            ->booleanNode('log_errors')->defaultTrue()->setDeprecated()->end();
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Wait for defined inputs before passing an aggregated output.
 * Should have been a BlockingTask, but due to limitations in the current model, it's a hack using skips and finalize.
 *
 * @see README.md:Known issues
 * @deprecated It's way too much error prone - should be refactored as a blocking task
 */
class InputAggregatorTask extends AbstractConfigurableTask
{
    /** @var array */
    protected $inputs = [];

    /**
     * Store inputs and once everything has been received, pass to next task
     * Once an output has been generated this task is reset, and may wait for another loop
     *
     * @param ProcessState $state
     *
     * @throws \UnexpectedValueException
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function execute(ProcessState $state)
    {
        $previousState = $state->getPreviousState();
        if (!$previousState || !$previousState->getTaskConfiguration()) {
            throw new \UnexpectedValueException('This task cannot be used without a previous task');
        }

        $inputCode = $this->getInputCode($state);
        if (array_key_exists($inputCode, $this->inputs)) {
            if ($this->getOption($state, 'clean_input_on_override')) {
                $this->inputs = [];
            } else {
                throw new \UnexpectedValueException(
                    "The output from input '{$inputCode}' has already been defined, please use an aggregator if you have an iterable output"
                );
            }
        }

        $this->inputs[$inputCode] = $state->getInput();

        if ($this->isResolved($state)) {
            $state->setOutput($this->inputs);
            $keepInputs = $this->getOption($state, 'keep_inputs');
            // Only clear inputs that are not in the keep_inputs option
            foreach ($this->inputs as $inputCode => $value) {
                if (null !== $keepInputs && \in_array($inputCode, $keepInputs, true)) {
                    continue;
                }
                unset($this->inputs[$inputCode]);
            }
        } else {
            $state->setSkipped(true);
        }
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired('input_codes');
        $resolver->setDefaults([
            'clean_input_on_override' => true,
            'keep_inputs' => null,
        ]);
        $resolver->setAllowedTypes('input_codes', 'array');
        $resolver->setAllowedTypes('clean_input_on_override', 'boolean');
        $resolver->setAllowedTypes('keep_inputs', ['NULL', 'array']);
    }

    /**
     * Map the previous task code to an input code
     *
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \InvalidArgumentException
     * @throws \UnexpectedValueException
     *
     * @return string
     */
    protected function getInputCode(ProcessState $state)
    {
        $previousState = $state->getPreviousState();
        $previousTaskCode = $previousState->getTaskConfiguration()->getCode();
        $inputCodes = $this->getOption($state, 'input_codes');
        if (!array_key_exists($previousTaskCode, $inputCodes)) {
            throw new \UnexpectedValueException("Task '{$previousTaskCode}' is not mapped in the input_codes option");
        }

        return $inputCodes[$previousTaskCode];
    }

    /**
     * Check if the received inputs match the defined mappings
     *
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \InvalidArgumentException
     *
     * @return bool
     */
    protected function isResolved(ProcessState $state)
    {
        $inputCodes = $this->getOption($state, 'input_codes');
        foreach ($inputCodes as $inputCode) {
            if (!array_key_exists($inputCode, $this->inputs)) {
                return false;
            }
        }

        return true;
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task\File;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\IterableTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use League\Flysystem\FilesystemInterface;
use League\Flysystem\MountManager;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Class FileFetchTask
 *
 * Copy (or move) file from one filesystem to another, using Flysystem
 * Either get files using a file regexp, or take files from input
 *
 * @author  Madeline Veyrenc <mveyrenc@clever-age.com>
 */
class FileFetchTask extends AbstractConfigurableTask implements IterableTaskInterface
{

    /** @var  MountManager */
    protected $mountManager;

    /** @var FilesystemInterface */
    protected $sourceFS;

    /** @var FilesystemInterface */
    protected $destinationFS;

    /** @var array */
    protected $matchingFiles = [];

    /**
     * @param MountManager|null $mountManager
     */
    public function __construct(MountManager $mountManager = null)
    {
        $this->mountManager = $mountManager;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \League\Flysystem\FilesystemNotFoundException
     */
    public function initialize(ProcessState $state)
    {
        if (!$this->mountManager) {
            throw new ServiceNotFoundException('MountManager service not found, you need to install FlySystemBundle');
        }
        // Configure options
        parent::initialize($state);

        $this->sourceFS = $this->mountManager->getFilesystem($this->getOption($state, 'source_filesystem'));
        $this->destinationFS = $this->mountManager->getFilesystem($this->getOption($state, 'destination_filesystem'));
    }

    /**
     * @param ProcessState $state
     *
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \UnexpectedValueException
     * @throws \League\Flysystem\FilesystemNotFoundException
     * @throws \League\Flysystem\FileNotFoundException
     */
    public function execute(ProcessState $state)
    {
        $this->findMatchingFiles($state);

        $file = current($this->matchingFiles);
        if (!$file) {
            $state->setSkipped(true);

            return;
        }

        $this->doFileCopy($state, $file, $this->getOption($state, 'remove_source'));
        $state->setOutput($file);
    }

    /**
     * @param ProcessState $state
     *
     * @throws \UnexpectedValueException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \InvalidArgumentException
     *
     * @return bool|mixed
     */
    public function next(ProcessState $state)
    {
        $this->findMatchingFiles($state);

        return next($this->matchingFiles);
    }

    /**
     * @param ProcessState $state
     *
     * @throws \UnexpectedValueException
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    protected function findMatchingFiles(ProcessState $state)
    {
        $filePattern = $this->getOption($state, 'file_pattern');
        if ($filePattern) {
            foreach ($this->sourceFS->listContents('/') as $file) {
                if ('file' === $file['type']
                    && preg_match($filePattern, $file['path'])
                    && !\in_array($file['path'], $this->matchingFiles, true)) {
                    $this->matchingFiles[] = $file['path'];
                }
            }
        } else {
            $input = $state->getInput();
            if (!$input) {
                throw new \UnexpectedValueException('No pattern neither input provided for the Task');
            }
            if (\is_array($input)) {
                foreach ($input as $file) {
                    if (!\in_array($file, $this->matchingFiles, true)) {
                        $this->matchingFiles[] = $file;
                    }
                }
            } elseif (!\in_array($input, $this->matchingFiles, true)) {
                $this->matchingFiles[] = $input;
            }
        }
    }

    /**
     * @param ProcessState $state
     * @param string       $filename
     * @param bool         $removeSource
     *
     * @throws \League\Flysystem\FileNotFoundException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \League\Flysystem\FilesystemNotFoundException
     * @throws \InvalidArgumentException
     *
     * @return mixed
     */
    protected function doFileCopy(ProcessState $state, $filename, $removeSource)
    {
        $prefixFrom = $this->getOption($state, 'source_filesystem');
        $prefixTo = $this->getOption($state, 'destination_filesystem');

        $buffer = $this->mountManager->getFilesystem($prefixFrom)->readStream($filename);

        if (false === $buffer) {
            return false;
        }

        $result = $this->mountManager->getFilesystem($prefixTo)->putStream($filename, $buffer);

        if (\is_resource($buffer)) {
            fclose($buffer);
        }

        if ($removeSource) {
            $this->mountManager->delete(sprintf('%s://%s', $prefixFrom, $filename));
        }

        return $result ? $filename : null;
    }

    /**
     * {@inheritdoc}
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(['source_filesystem', 'destination_filesystem']);
        $resolver->setAllowedTypes('source_filesystem', 'string');
        $resolver->setAllowedTypes('destination_filesystem', 'string');

        $resolver->setDefault('file_pattern', null);
        $resolver->setAllowedTypes('file_pattern', ['string', 'null']);

        $resolver->setDefault('remove_source', false);
        $resolver->setAllowedTypes('remove_source', 'boolean');
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task\File\Csv;

use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Reads the filepath from the input
 */
class InputCsvReaderTask extends CsvReaderTask
{
    /**
     * @param ProcessState $state
     *
     * @TODO refactor to get file path outside of options
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     *
     * @return array
     */
    protected function getOptions(ProcessState $state)
    {
        $options = parent::getOptions($state);
        if (null !== $state->getInput()) {
            $options['file_path'] = $this->getFilePath($options, $state->getInput());
        }

        return $options;
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);
        $resolver->remove('file_path');

        // If there is no base_path, then the given path should be absolute
        $resolver->setDefault('base_path', '');
        $resolver->setAllowedTypes('base_path', 'string');
    }

    /**
     * If there is no base_path, then the given path from input should be absolute
     *
     * @param array  $options
     * @param string $input
     *
     * @return string
     */
    protected function getFilePath(array $options, string $input)
    {
        $basePath = $options['base_path'];
        if (\strlen($basePath) > 0) {
            $basePath = rtrim($options['base_path'], '/').'/';
        }

        return $basePath.$input;
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Command;

use CleverAge\ProcessBundle\Configuration\ProcessConfiguration;
use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * List all configured processes
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class ListProcessCommand extends Command
{
    /** @var ProcessConfigurationRegistry */
    protected $processConfigRegistry;

    /**
     * @param ProcessConfigurationRegistry $processConfigRegistry
     *
     * @throws \Symfony\Component\Console\Exception\LogicException
     */
    public function __construct(ProcessConfigurationRegistry $processConfigRegistry)
    {
        $this->processConfigRegistry = $processConfigRegistry;
        parent::__construct();
    }

    /**
     * {@inheritdoc}
     * @throws \Symfony\Component\Console\Exception\InvalidArgumentException
     */
    protected function configure()
    {
        $this->setName('cleverage:process:list');
        $this->setDescription('List defined process');
        $this->addOption('all', 'a', InputOption::VALUE_NONE, 'Shows all processes (including hidden ones)');
    }

    /**
     * {@inheritdoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $processConfigurations = $this->processConfigRegistry->getProcessConfigurations();
        \usort($processConfigurations, [$this, 'processSorter']);

        $publicCount = \array_reduce($processConfigurations, [$this, 'publicProcessCounter'], 0);
        $privateCount = \array_reduce($processConfigurations, [$this, 'privateProcessCounter'], 0);
        $output->writeln("<info>There are {$publicCount} process configurations defined (and {$privateCount} private) :</info>");

        $messages = [];
        foreach ($processConfigurations as $processConfiguration) {
            if ($processConfiguration->isPublic() || $input->getOption('all')) {
                $countTasks = \count($processConfiguration->getTaskConfigurations());
                $message = "<info> - </info>{$processConfiguration->getCode()}<info> with {$countTasks} tasks</info>";

                if ($processConfiguration->isPrivate()) {
                    $message .= " <comment>(private)</comment>";
                }

                $messages[] = [
                    'process' => $processConfiguration,
                    'output'  => $message,
                ];
            }
        }

        // Add process descriptions at a fixed position
        $maxMessageLength = \array_reduce($messages, [$this, 'maxMessageLengthFilter'], 0);
        $outputMessages = [];
        foreach ($messages as $message) {
            /** @var ProcessConfiguration $processConfiguration */
            $processConfiguration = $message['process'];
            $outputMessage = $message['output'];

            if ($processConfiguration->getDescription()) {
                $outputMessage = $this->padMessage($outputMessage, $maxMessageLength + 3);
                $outputMessage .= "{$processConfiguration->getDescription()}";
            }

            $outputMessages[] = $outputMessage;
        }

        // Output messages
        foreach ($outputMessages as $message) {
            $output->writeln($message);
        }
    }

    /**
     * Counter callback for public processes
     *
     * @param int                  $sum
     * @param ProcessConfiguration $processConfiguration
     *
     * @return int
     */
    public function publicProcessCounter($sum, ProcessConfiguration $processConfiguration)
    {
        return $sum + ($processConfiguration->isPublic() ? 1 : 0);
    }

    /**
     * Counter callback for private processes
     *
     * @param int                  $sum
     * @param ProcessConfiguration $processConfiguration
     *
     * @return int
     */
    public function privateProcessCounter($sum, ProcessConfiguration $processConfiguration)
    {
        return $sum + ($processConfiguration->isPrivate() ? 1 : 0);
    }

    /**
     * Sorter callback for process codes
     *
     * @param ProcessConfiguration $a
     * @param ProcessConfiguration $b
     *
     * @return int
     */
    public function processSorter(ProcessConfiguration $a, ProcessConfiguration $b)
    {
        if ($a->getCode() === $b->getCode()) {
            return 0;
        }

        return ($a->getCode() < $b->getCode()) ? -1 : 1;
    }

    /**
     * Filter callback to find max message length
     *
     * @param ProcessConfiguration $a
     * @param ProcessConfiguration $b
     *
     * @return int
     */
    public function maxMessageLengthFilter($max, array $message)
    {
        return \max($max, strlen($this->filterOutTags($message['output'])));
    }

    /**
     * Returns a padded message (without counting metadata)
     *
     * @param string $message
     * @param int    $length
     *
     * @return string
     */
    protected function padMessage($message, $length = 80)
    {
        $currentLen = strlen($this->filterOutTags($message));
        if ($currentLen < $length) {
            $message .= str_repeat(' ', $length - $currentLen);
        }

        return $message;
    }

    /**
     * Filter out tags used in console outputs
     *
     * @param $string
     *
     * @return string
     */
    protected function filterOutTags($string)
    {
        return preg_replace('/<[^<>]*>/', '', $string);
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task\File\Csv;

use CleverAge\ProcessBundle\Filesystem\CsvFile;
use CleverAge\ProcessBundle\Model\IterableTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Reads the file path from configuration and iterates over it
 * Ignores any input
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class CsvReaderTask extends AbstractCsvTask implements IterableTaskInterface
{
    /** @var \Psr\Log\LoggerInterface */
    protected $logger;

    /**
     * @param \Psr\Log\LoggerInterface $logger
     */
    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \UnexpectedValueException
     * @throws \RuntimeException
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \LogicException
     */
    public function execute(ProcessState $state)
    {
        $output = null;
        if ($this->csv instanceof CsvFile
            && $this->csv->getFilePath() !== $this->getOption($state, 'file_path')) {
            $this->csv = null;
        }

        if (!$this->csv instanceof CsvFile) {
            $this->initFile($state);
        }
        $output = $this->csv->readLine();

        if (null === $output) {
            if ($this->getOption($state, 'log_empty_lines')) {
                $logContext = [
                    'csv_file' => $this->csv->getFilePath(),
                    'csv_line' => $this->csv->getCurrentLine(),
                ];
                $this->logger->warning("Empty line detected at line: {$this->csv->getCurrentLine()}", $logContext);
            }

            $state->setSkipped(true);
        }

        $state->addErrorContextValue('csv_file', $this->csv->getFilePath());
        $state->addErrorContextValue('csv_line', $this->csv->getCurrentLine());
        $state->setOutput($output);
    }

    /**
     * Moves the internal pointer to the next element,
     * return true if the task has a next element
     * return false if the task has terminated it's iteration
     *
     * @param ProcessState $state
     *
     * @throws \LogicException
     * @throws \UnexpectedValueException
     * @throws \RuntimeException
     *
     * @return bool
     */
    public function next(ProcessState $state)
    {
        if (!$this->csv instanceof CsvFile) {
            throw new \LogicException('No CSV File initialized');
        }

        $state->removeErrorContext('csv_file');
        $state->removeErrorContext('csv_line');

        return !$this->csv->isEndOfFile();
    }

    /**
     * @param ProcessState $state
     * @param array        $options
     *
     * @return array
     */
    protected function getHeaders(ProcessState $state, array $options)
    {
        return $options['headers'];
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);
        $resolver->setDefaults(
            [
                'log_empty_lines' => false,
            ]
        );
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task;

use CleverAge\ProcessBundle\Model\IterableTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;

/**
 * Class InputIteratorTask
 *
 * @author  Madeline Veyrenc <mveyrenc@clever-age.com>
 */
class InputIteratorTask implements IterableTaskInterface
{
    /** @var \Iterator */
    protected $iterator;

    /**
     * @param ProcessState $state
     *
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function execute(ProcessState $state)
    {
        $this->handleIteratorFromInput($state);

        $state->addErrorContextValue('iterate_on_array_key', $this->iterator->key());

        // If the initial value is already null, skip right now the next steps
        if ($this->iterator->valid()) {
            $state->setOutput($this->iterator->current());
        } else {
            $state->setSkipped(true);
            $this->iterator = null;
        }
    }

    /**
     * Moves the internal pointer to the next element,
     * return true if the task has a next element
     * return false if the task has terminated it's iteration
     *
     * @param ProcessState $state
     *
     * @return bool
     */
    public function next(ProcessState $state)
    {
        if (!$this->iterator) {
            return false;
        }
        $this->iterator->next();
        $state->removeErrorContext('iterate_on_array_key');

        return $this->iterator->valid();
    }

    /**
     * Create or recreate an iterator from input
     *
     * @param ProcessState $state
     */
    protected function handleIteratorFromInput(ProcessState $state)
    {
        if ($this->iterator instanceof \Iterator) {
            if ($this->iterator->valid()) {
                // No action needed, execution is in progress
                return;
            }
            // Cleanup invalid iterator => prepare for new iteration cycle
            $this->iterator = null;
        }

        // This should never be reached
        if (null !== $this->iterator) {
            throw new \UnexpectedValueException(
                "At this point iterator should have been null, maybe it's a wrong type..."
            );
        }

        // Create iterator
        if ($state->getInput() instanceof \Iterator) {
            $this->iterator = $state->getInput();
        } elseif (\is_array($state->getInput())) {
            $this->iterator = new \ArrayIterator($state->getInput());
        }

        // Assert iterator is OK
        if (!$this->iterator instanceof \Iterator) {
            throw new \UnexpectedValueException('Cannot create iterator from input');
        }
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task\File\Csv;

use CleverAge\ProcessBundle\Filesystem\CsvFile;
use CleverAge\ProcessBundle\Model\BlockingTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Reads the file path from configuration and iterates over it
 * Ignores any input
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 *
 * @property CsvFile $csv
 */
class CsvWriterTask extends AbstractCsvTask implements BlockingTaskInterface
{
    /**
     * @param ProcessState $state
     *
     * @throws \RuntimeException
     * @throws \UnexpectedValueException
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function execute(ProcessState $state)
    {
        if (!$this->csv instanceof CsvFile) {
            $this->initFile($state);
            if ($this->getOption($state, 'write_headers') && 0 === filesize($this->csv->getFilePath())) {
                $this->csv->writeHeaders();
            }
        }
        $this->csv->writeLine($this->getInput($state));
    }

    /**
     * @param ProcessState $state
     */
    public function proceed(ProcessState $state)
    {
        if ($this->csv) {
            $state->setOutput($this->csv->getFilePath());
        }
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);
        $resolver->setDefaults(
            [
                'mode' => 'wb',
                'split_character' => '|',
                'write_headers' => true,
            ]
        );

        $resolver->setNormalizer(
            'file_path',
            function (Options $options, $value) {
                $value = strtr(
                    $value,
                    [
                        '{date}' => date('Ymd'),
                        '{date_time}' => date('Ymd_His'),
                        '{unique_token}' => uniqid(),
                    ]
                );

                return $value;
            }
        );
    }

    /**
     * @param ProcessState $state
     *
     * @throws \UnexpectedValueException
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     *
     * @return array
     */
    protected function getInput(ProcessState $state)
    {
        $input = $state->getInput();
        if (!\is_array($input)) {
            throw new \UnexpectedValueException('Input value is not an array');
        }
        $splitCharacter = $this->getOption($state, 'split_character');

        /** @var array $input */
        foreach ($input as $key => &$item) {
            if (\is_array($item)) {
                $item = implode($splitCharacter, $item);
            }
        }

        return $input;
    }

    /**
     * @param ProcessState $state
     * @param array        $options
     *
     * @return array
     */
    protected function getHeaders(ProcessState $state, array $options)
    {
        $headers = $options['headers'];
        if (null === $headers) {
            $headers = array_keys($state->getInput());
        }

        return $headers;
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task\Serialization;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

/**
 * Denormalize input to output with configurable class and format
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class DenormalizerTask extends AbstractConfigurableTask
{
    /** @var DenormalizerInterface */
    protected $denormalizer;

    /**
     * @param DenormalizerInterface $denormalizer
     */
    public function __construct(DenormalizerInterface $denormalizer)
    {
        $this->denormalizer = $denormalizer;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\Serializer\Exception\UnexpectedValueException
     * @throws \Symfony\Component\Serializer\Exception\RuntimeException
     * @throws \Symfony\Component\Serializer\Exception\LogicException
     * @throws \Symfony\Component\Serializer\Exception\InvalidArgumentException
     * @throws \Symfony\Component\Serializer\Exception\ExtraAttributesException
     * @throws \Symfony\Component\Serializer\Exception\BadMethodCallException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function execute(ProcessState $state)
    {
        $options = $this->getOptions($state);
        $normalizedData = $this->denormalizer->denormalize(
            $state->getInput(),
            $options['class'],
            $options['format'],
            $options['context']
        );
        $state->setOutput($normalizedData);
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'class',
            ]
        );
        $resolver->setAllowedTypes('class', ['string']);
        $resolver->setDefaults(
            [
                'format' => null,
                'context' => [],
            ]
        );
        $resolver->setAllowedTypes('format', ['NULL', 'string']);
        $resolver->setAllowedTypes('context', ['array']);
    }
}