diff --git a/.gitignore b/.gitignore
index 89329b1395c142d881cfb5c9359fed7012870491..2e46ea9f1fb4562fa5d2c455a134b5a9cfbbb19c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
 /vendor
 /composer.lock
 /.php_cs.*cache
+/.phpunit.result.cache
diff --git a/composer.json b/composer.json
index b9f1cadc18b9008618202ab3e6d7bf3ea3b3b5c1..0bd4ea66ac2950f66d0e80b87c350336fe23c294 100644
--- a/composer.json
+++ b/composer.json
@@ -1,26 +1,41 @@
 {
-    "name": "irstea/php-cs-fixer-config",
-    "description": "Jeux de règles pour php-cs-fixer.",
-    "type": "library",
-    "license": "MIT",
-    "authors": [
-        {
-            "name": "Irstea - DSI - pôle IS",
-            "email": "dsi.poleis@irstea.fr"
-        }
-    ],
-    "require": {
-        "php": "^5.6 || ^7.0",
-        "ext-json": "*",
-        "friendsofphp/php-cs-fixer": "^2.13"
-    },
-    "autoload": {
-        "psr-4": { "Irstea\\CS\\": "src/" }
-    },
-    "config": {
-        "sort-packages": true
-    },
-    "archive": {
-        "exclude": [".?*"]
+  "name": "irstea/php-cs-fixer-config",
+  "description": "Jeux de règles pour php-cs-fixer.",
+  "type": "library",
+  "license": "MIT",
+  "authors": [
+    {
+      "name": "Irstea - DSI - pôle IS",
+      "email": "dsi.poleis@irstea.fr"
     }
+  ],
+  "require": {
+    "php": "^5.6 || ^7.0",
+    "ext-json": "*",
+    "beberlei/assert": "^3.2",
+    "friendsofphp/php-cs-fixer": "^2.13",
+    "symfony/cache": "^5.0"
+  },
+  "require-dev": {
+    "mikey179/vfsstream": "^1.6",
+    "phpunit/phpunit": "^8.5"
+  },
+  "autoload": {
+    "psr-4": {
+      "Irstea\\CS\\": "src/"
+    }
+  },
+  "autoload-dev": {
+    "psr-4": {
+      "Irstea\\CS\\Tests\\": "tests/"
+    }
+  },
+  "config": {
+    "sort-packages": true
+  },
+  "archive": {
+    "exclude": [
+      ".?*"
+    ]
+  }
 }
diff --git a/headers/GPL-3.0.txt b/headers/GPL-3.0.txt
new file mode 100644
index 0000000000000000000000000000000000000000..6f0c39ea0162ec1be03fe66697d521a7ec69a1fd
--- /dev/null
+++ b/headers/GPL-3.0.txt
@@ -0,0 +1,15 @@
+%package% - %description%
+Copyright (C) %yearRange% IRSTEA
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
diff --git a/headers/LGPL-3.0.txt b/headers/LGPL-3.0.txt
new file mode 100644
index 0000000000000000000000000000000000000000..f8ecdd7c3d5364a3c6580441aef635fd5e370fd9
--- /dev/null
+++ b/headers/LGPL-3.0.txt
@@ -0,0 +1,15 @@
+%package% - %description%
+Copyright (C) %yearRange% IRSTEA
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Lesser General Public License as published
+by the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
diff --git a/.docheader b/headers/default.txt
similarity index 60%
rename from .docheader
rename to headers/default.txt
index 72e66b79b4b2259686c42107ed6aa954df6c239e..9e1f2c5d4deb7197a0a3e3361bd1a2cd9bd5cf03 100644
--- a/.docheader
+++ b/headers/default.txt
@@ -1,5 +1,5 @@
-This file is part of "%package%".
-(c) %yearRange% Irstea <dsi.poleis@irstea.fr>
+%package% - %description%
+Copyright (C) %yearRange% IRSTEA
 
 For the full copyright and license information, please view the LICENSE
 file that was distributed with this source code.
diff --git a/headers/proprietary.txt b/headers/proprietary.txt
new file mode 100644
index 0000000000000000000000000000000000000000..7c4bab25feeae5aa10f87243584637f7a9ba4d9e
--- /dev/null
+++ b/headers/proprietary.txt
@@ -0,0 +1,4 @@
+%package% - %description%
+Copyright (C) %yearRange% IRSTEA
+
+All rights reserved.
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000000000000000000000000000000000000..882f36c66ad4d3d434a10216577d6ebe4705c282
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd"
+         bootstrap="vendor/autoload.php"
+         executionOrder="depends,defects"
+         forceCoversAnnotation="true"
+         beStrictAboutCoversAnnotation="true"
+         beStrictAboutOutputDuringTests="true"
+         beStrictAboutTodoAnnotatedTests="true"
+         verbose="true">
+
+    <testsuites>
+        <testsuite name="default">
+            <directory suffix="Test.php">tests</directory>
+        </testsuite>
+    </testsuites>
+
+    <filter>
+        <whitelist processUncoveredFilesFromWhitelist="true">
+            <directory suffix=".php">src</directory>
+        </whitelist>
+    </filter>
+</phpunit>
diff --git a/src/Composer/ComposerPackage.php b/src/Composer/ComposerPackage.php
new file mode 100644
index 0000000000000000000000000000000000000000..ef10f1a7a945f1abe1be4d13b3ce8baee544a74c
--- /dev/null
+++ b/src/Composer/ComposerPackage.php
@@ -0,0 +1,119 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\Composer;
+
+use Assert\Assertion;
+use Irstea\CS\FileLocator\FileLocatorInterface;
+
+/**
+ * Class ComposerPackage.
+ */
+final class ComposerPackage implements ComposerPackageInterface
+{
+    /**
+     * @var array|null
+     */
+    private $composerJson;
+
+    /**
+     * @var FileLocatorInterface
+     */
+    private $fileLocator;
+
+    /**
+     * ComposerPackage constructor.
+     */
+    public function __construct(FileLocatorInterface $fileLocator)
+    {
+        $this->fileLocator = $fileLocator;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getName()
+    {
+        return $this->getKey('name');
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getDescription()
+    {
+        return $this->getKey('description');
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getRequiredPHPVersion()
+    {
+        $require = $this->getKey('require', []);
+        if (
+            isset($require['php'])
+            && preg_match('/(?:>=?|\^|~)\s*([578]\.\d)/', $require['php'], $groups)
+        ) {
+            return (float) $groups[1];
+        }
+
+        return 5.6;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getLicenses()
+    {
+        return (array) $this->getKey('license', 'proprietary');
+    }
+
+    /**
+     * @param string $key
+     * @param mixed  $default
+     *
+     * @throws \Assert\AssertionFailedException
+     *
+     * @return mixed|null
+     */
+    private function getKey($key, $default = null)
+    {
+        Assertion::string($key);
+
+        $data = $this->getComposerJson();
+
+        return \array_key_exists($key, $data) ? $data[$key] : $default;
+    }
+
+    /**
+     * @return array
+     */
+    private function getComposerJson()
+    {
+        return $this->composerJson !== null ? $this->composerJson : $this->readComposerJson();
+    }
+
+    /**
+     * @throws \Assert\AssertionFailedException
+     *
+     * @return array
+     */
+    private function readComposerJson()
+    {
+        $composerPath = $this->fileLocator->locate('composer.json');
+        Assertion::notNull($composerPath, 'could not find composer.json');
+
+        $content = file_get_contents($composerPath);
+        Assertion::string($content, "could not read `$composerPath`");
+
+        return $this->composerJson = json_decode($content, true, 512, \JSON_THROW_ON_ERROR);
+    }
+}
diff --git a/src/Composer/ComposerPackageInterface.php b/src/Composer/ComposerPackageInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..a6abdd90b0206a06ddec1c4cd4789eb513d070f4
--- /dev/null
+++ b/src/Composer/ComposerPackageInterface.php
@@ -0,0 +1,37 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\Composer;
+
+/**
+ * Interface ComposerPackageInterface.
+ */
+interface ComposerPackageInterface
+{
+    /**
+     * @return string
+     */
+    public function getName();
+
+    /**
+     * @return string
+     */
+    public function getDescription();
+
+    /**
+     * @return float
+     */
+    public function getRequiredPHPVersion();
+
+    /**
+     * @return iterable
+     */
+    public function getLicenses();
+}
diff --git a/src/Config.php b/src/Config.php
index f911ec8f02852af2ac7b5b1b9320664413937c88..1b69e7a5d59466f6aeb410a90323ac10d48d5ecb 100644
--- a/src/Config.php
+++ b/src/Config.php
@@ -1,15 +1,28 @@
 <?php
 /*
- * This file is part of "irstea/php-cs-fixer-config".
- * (c) 2018-2019 Irstea <dsi.poleis@irstea.fr>
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
  *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
+ *
  */
 
 namespace Irstea\CS;
 
+use Irstea\CS\Composer\ComposerPackage;
+use Irstea\CS\Composer\ComposerPackageInterface;
+use Irstea\CS\FileLocator\FileLocator;
+use Irstea\CS\Git\CachedGitRepository;
+use Irstea\CS\Git\GitRepository;
+use Irstea\CS\HeaderComment\ChainTemplateProvider;
+use Irstea\CS\HeaderComment\FormattedHeaderProvider;
+use Irstea\CS\HeaderComment\HeaderProviderInterface;
+use Irstea\CS\HeaderComment\LicenseTemplateProvider;
+use Irstea\CS\HeaderComment\TemplateFormatter;
+use Irstea\CS\HeaderComment\UserDefinedTemplateProvider;
 use PhpCsFixer\Config as PhpCsFixerConfig;
+use Symfony\Component\Cache\Adapter\ArrayAdapter;
 
 /**
  * Class Config.
@@ -17,41 +30,34 @@ use PhpCsFixer\Config as PhpCsFixerConfig;
 final class Config extends PhpCsFixerConfig
 {
     /**
-     * @var string|null
-     */
-    private $commit;
-
-    /**
-     * @var mixed[]
-     */
-    private $cache;
-
-    /**
-     * @var array|null
+     * @var ComposerPackageInterface
      */
-    private $composerConfig;
+    private $composerPackage;
 
     /**
-     * @var string
+     * @var HeaderProviderInterface
      */
-    private $cacheFile = '.php_cs.commit-cache';
+    private $headerProvider;
 
     /**
-     * @var string
+     * @var array<string, mixed>
      */
-    private $docHeaderfile = '.docheader';
-
-    /** @var array<string, mixed> */
     private $ruleOverrides = [];
 
     /**
-     * Set cacheFile.
+     * Config constructor.
      *
-     * @param string $cacheFile
+     * @param string $name
      */
-    public function setCacheFile($cacheFile)
-    {
-        $this->cacheFile = $cacheFile;
+    public function __construct(
+        ComposerPackageInterface $composerPackage,
+        HeaderProviderInterface $headerProvider,
+        $name = 'default'
+    ) {
+        parent::__construct($name);
+
+        $this->composerPackage = $composerPackage;
+        $this->headerProvider = $headerProvider;
     }
 
     /**
@@ -69,7 +75,7 @@ final class Config extends PhpCsFixerConfig
      */
     public function getRules()
     {
-        $phpVersion = $this->findRequiredPHPVersion();
+        $phpVersion = $this->composerPackage->getRequiredPHPVersion();
         $risky = $this->getRiskyAllowed();
 
         return array_replace(
@@ -97,6 +103,7 @@ final class Config extends PhpCsFixerConfig
             '@PHP70Migration:risky' => $risky && $phpVersion >= 7.0,
             '@PHP71Migration'       => $phpVersion >= 7.1,
             '@PHP71Migration:risky' => $risky && $phpVersion >= 7.1,
+            '@PHP73Migration'       => $phpVersion >= 7.3,
         ];
     }
 
@@ -109,33 +116,37 @@ final class Config extends PhpCsFixerConfig
     public function baseRules($phpVersion, $risky)
     {
         $rules = [
-                // Configuration && overrides
-                'binary_operator_spaces'                    => ['align_double_arrow' => true],
-                'blank_line_after_opening_tag'              => false,
-                'concat_space'                              => ['spacing' => 'one'],
-                'method_argument_space'                     => ['ensure_fully_multiline' => true],
-
-                // Safe
-                'align_multiline_comment'                   => true,
-                'array_syntax'                              => ['syntax' => 'short'],
-                'general_phpdoc_annotation_remove'          => ['annotations' => ['author', 'package']],
-                'no_multiline_whitespace_before_semicolons' => true,
-                'no_useless_else'                           => true,
-                'no_useless_return'                         => true,
-                'ordered_imports'                           => true,
-                'phpdoc_add_missing_param_annotation'       => true,
-                'phpdoc_annotation_without_dot'             => true,
-                'phpdoc_order'                              => true,
-                'semicolon_after_instruction'               => true,
-                'yoda_style'                                => false,
+            // Configuration && overrides
+            'binary_operator_spaces'                    => ['align_double_arrow' => true],
+            'blank_line_after_opening_tag'              => false,
+            'concat_space'                              => ['spacing' => 'one'],
+            'method_argument_space'                     => ['ensure_fully_multiline' => true],
+            'declare_strict_types'                      => $phpVersion >= 7.0,
+
+            // Safe
+            'align_multiline_comment'                   => true,
+            'array_syntax'                              => ['syntax' => 'short'],
+            'general_phpdoc_annotation_remove'          => ['annotations' => ['author', 'package']],
+            'no_multiline_whitespace_before_semicolons' => true,
+            'no_useless_else'                           => true,
+            'no_useless_return'                         => true,
+            'ordered_imports'                           => true,
+            'phpdoc_add_missing_param_annotation'       => true,
+            'phpdoc_annotation_without_dot'             => true,
+            'phpdoc_order'                              => true,
+            'semicolon_after_instruction'               => true,
+            'yoda_style'                                => false,
+        ];
 
-                'header_comment' => [
-                    'commentType' => 'comment',
-                    'location'    => 'after_declare_strict',
-                    'separate'    => 'bottom',
-                    'header'      => $this->headerComment(),
-                ],
+        $header = $this->headerProvider->getHeader();
+        if ($header) {
+            $rules['header_comment'] = [
+                'commentType' => 'comment',
+                'location'    => 'after_declare_strict',
+                'separate'    => 'bottom',
+                'header'      => $header,
             ];
+        }
 
         if ($risky) {
             $rules['is_null'] = ['use_yoda_style' => false];
@@ -166,146 +177,29 @@ final class Config extends PhpCsFixerConfig
     }
 
     /**
-     * @return string
-     */
-    private function headerComment()
-    {
-        $header = "Create and customize a file named {$this->docHeaderfile} at the root of the project to change this message.";
-
-        if (file_exists($this->docHeaderfile)) {
-            $header = trim(file_get_contents($this->docHeaderfile));
-        }
-
-        return str_replace(['%yearRange%', '%package%'], [$this->getYearRange(), $this->findPackageName()], $header);
-    }
-
-    /**
-     * @return string|null
+     * {@inheritdoc}
      */
-    private function findPackageName()
+    public static function create()
     {
-        return $this->memoize(
-            'package-name',
-            function () {
-                $config = $this->getComposerConfig();
+        $stackTrace = debug_backtrace(1);
+        $callerPath = \dirname($stackTrace[0]['file']);
 
-                return isset($config['name']) ? $config['name'] : null;
-            }
-        );
-    }
+        $fileLocator = new FileLocator($callerPath);
+        $composerPackage = new ComposerPackage($fileLocator);
 
-    /**
-     * @return string
-     */
-    private function getYearRange()
-    {
-        return $this->memoize(
-            'years',
-            function () {
-                $last = date('Y');
-                $first = exec('git log --format=%cd --date=format:%Y --date-order | tail -n1');
-                if (!$first) {
-                    $first = '???';
-                }
+        $cache = new ArrayAdapter();
 
-                return ($last !== null && $last !== $first) ? "$first-$last" : $first;
-            }
-        );
-    }
+        $backendGitRepository = new GitRepository($callerPath);
+        $gitRepository = new CachedGitRepository($backendGitRepository, $cache);
 
-    /**
-     * @return float
-     */
-    private function findRequiredPHPVersion()
-    {
-        return $this->memoize(
-            'php-req',
-            function () {
-                $config = $this->getComposerConfig();
-                if (isset($config['require']['php'])) {
-                    if (preg_match('/(?:>=?|\^|~)\s*([57]\.\d)/', $config['require']['php'], $groups)) {
-                        return (float) $groups[1];
-                    }
-                }
-
-                return 5.6;
-            }
+        $headerProvider = new FormattedHeaderProvider(
+            new ChainTemplateProvider([
+                new UserDefinedTemplateProvider($fileLocator),
+                new LicenseTemplateProvider($composerPackage),
+            ]),
+            new TemplateFormatter($gitRepository, $composerPackage)
         );
-    }
 
-    /**
-     * @return array
-     */
-    private function getComposerConfig()
-    {
-        if ($this->composerConfig !== null) {
-            return $this->composerConfig;
-        }
-        $this->composerConfig = [];
-
-        if (file_exists('composer.json')) {
-            $data = json_decode(file_get_contents('composer.json'), true);
-            if (\is_array($data)) {
-                $this->composerConfig = $data;
-            }
-        }
-
-        return $this->composerConfig;
-    }
-
-    /**
-     * @param string   $key
-     * @param callable $generator
-     *
-     * @return mixed
-     */
-    private function memoize($key, $generator)
-    {
-        $commit = $this->getHeadCommit();
-        $cache = $this->loadCache();
-        if (!isset($cache[$commit][$key])) {
-            if (!isset($cache[$commit])) {        // TODO: load a template from a local file.
-                $cache[$commit] = [];
-            }
-            $cache[$commit][$key] = $generator();
-            $this->saveCache($cache);
-        }
-
-        return $cache[$commit][$key];
-    }
-
-    /**
-     * @return string
-     */
-    private function getHeadCommit()
-    {
-        if ($this->commit === null) {
-            $this->commit = trim(shell_exec('git rev-parse HEAD'));
-        }
-
-        return $this->commit;
-    }
-
-    /**
-     * @return mixed[]
-     */
-    private function loadCache()
-    {
-        if ($this->cache === null) {
-            $this->cache = [];
-            if ($this->getUsingCache() && file_exists($this->cacheFile)) {
-                $this->cache = json_decode(file_get_contents($this->cacheFile), true);
-            }
-        }
-
-        return $this->cache;
-    }
-
-    /**
-     * @param array $cache
-     */
-    private function saveCache(array $cache)
-    {
-        file_put_contents($this->cacheFile, json_encode($cache));
+        return new self($composerPackage, $headerProvider);
     }
 }
diff --git a/src/FileLocator/ChainFileLocator.php b/src/FileLocator/ChainFileLocator.php
new file mode 100644
index 0000000000000000000000000000000000000000..74357b86b015eab003657c8f3f8808eeed82fb46
--- /dev/null
+++ b/src/FileLocator/ChainFileLocator.php
@@ -0,0 +1,52 @@
+<?php
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\FileLocator;
+
+use Assert\Assertion;
+
+/**
+ * Class ChainFileLocator.
+ */
+final class ChainFileLocator implements FileLocatorInterface
+{
+    /**
+     * @var FileLocatorInterface[]
+     */
+    private $fileLocators;
+
+    /**
+     * ChainFileLocator constructor.
+     *
+     * @param FileLocatorInterface[] $fileLocators
+     */
+    public function __construct($fileLocators)
+    {
+        Assertion::isArray($fileLocators);
+        Assertion::allImplementsInterface($fileLocators, FileLocatorInterface::class);
+
+        $this->fileLocators = $fileLocators;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function locate($filename)
+    {
+        foreach ($this->fileLocators as $fileLocator) {
+            $result = $fileLocator->locate($filename);
+            if ($result !== null) {
+                return $result;
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/src/FileLocator/FileLocator.php b/src/FileLocator/FileLocator.php
new file mode 100644
index 0000000000000000000000000000000000000000..88e32827cf207d14064dbc47caf7a0317b2ca3d5
--- /dev/null
+++ b/src/FileLocator/FileLocator.php
@@ -0,0 +1,51 @@
+<?php
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\FileLocator;
+
+use Assert\Assertion;
+
+/**
+ * Class FileLocator.
+ */
+final class FileLocator implements FileLocatorInterface
+{
+    /**
+     * @var string
+     */
+    private $baseDir;
+
+    /**
+     * FileLocator constructor.
+     *
+     * @param string $baseDir
+     */
+    public function __construct($baseDir)
+    {
+        Assertion::string($baseDir);
+
+        $this->baseDir = rtrim($baseDir, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function locate($filename)
+    {
+        Assertion::string($filename);
+
+        $path = $this->baseDir . ltrim($filename, \DIRECTORY_SEPARATOR);
+        if (!file_exists($path)) {
+            return null;
+        }
+
+        return $path;
+    }
+}
diff --git a/src/FileLocator/FileLocatorInterface.php b/src/FileLocator/FileLocatorInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..5a1fb82e38c8a9a0f33e7136309b4bb449c761a8
--- /dev/null
+++ b/src/FileLocator/FileLocatorInterface.php
@@ -0,0 +1,24 @@
+<?php
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\FileLocator;
+
+/**
+ * Interface FileLocatorInterface.
+ */
+interface FileLocatorInterface
+{
+    /**
+     * @param string $filename
+     *
+     * @return string|null
+     */
+    public function locate($filename);
+}
diff --git a/src/Git/CachedGitRepository.php b/src/Git/CachedGitRepository.php
new file mode 100644
index 0000000000000000000000000000000000000000..a6431177ff366f7965e03dbe67d2421bfbf485d5
--- /dev/null
+++ b/src/Git/CachedGitRepository.php
@@ -0,0 +1,55 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\Git;
+
+use Symfony\Contracts\Cache\CacheInterface;
+
+/**
+ * Class CachedGitRepository.
+ */
+final class CachedGitRepository implements GitRepositoryInterface
+{
+    /**
+     * @var GitRepositoryInterface
+     */
+    private $inner;
+
+    /**
+     * @var CacheInterface
+     */
+    private $cache;
+
+    /**
+     * CachedGitRepository constructor.
+     */
+    public function __construct(GitRepositoryInterface $inner, CacheInterface $cache)
+    {
+        $this->inner = $inner;
+        $this->cache = $cache;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getHeadCommit()
+    {
+        // Jamais mis en cache !
+        return $this->inner->getHeadCommit();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getYearRange()
+    {
+        return $this->cache->get('git.year-range', [$this->inner, 'getYearRange']);
+    }
+}
diff --git a/src/Git/GitRepository.php b/src/Git/GitRepository.php
new file mode 100644
index 0000000000000000000000000000000000000000..db6357eb1dd6634bccd64bd08466740f8bc21c3d
--- /dev/null
+++ b/src/Git/GitRepository.php
@@ -0,0 +1,59 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\Git;
+
+use Assert\Assertion;
+
+/**
+ * Class GitRepository.
+ */
+final class GitRepository implements GitRepositoryInterface
+{
+    /**
+     * @var string
+     */
+    private $repositoryPath;
+
+    /**
+     * GitRepository constructor.
+     *
+     * @param string $repositoryPath
+     */
+    public function __construct($repositoryPath)
+    {
+        Assertion::string($repositoryPath);
+        Assertion::directory($repositoryPath . '/.git');
+
+        $this->repositoryPath = $repositoryPath;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getHeadCommit()
+    {
+        return trim(shell_exec('git -C ' . escapeshellarg($this->repositoryPath) . ' rev-parse HEAD'));
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getYearRange()
+    {
+        $last = date('Y');
+        $first = exec('git -C ' . escapeshellarg($this->repositoryPath) . ' log --format=%cd --date=format:%Y --date-order | tail -n1');
+        if (!$first) {
+            $first = '???';
+        }
+
+        return ($last !== null && $last !== $first) ? "$first-$last" : $first;
+    }
+}
diff --git a/src/Git/GitRepositoryInterface.php b/src/Git/GitRepositoryInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..4ee3cf13514d66839d2b493aafe4bf411941abec
--- /dev/null
+++ b/src/Git/GitRepositoryInterface.php
@@ -0,0 +1,27 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\Git;
+
+/**
+ * Class GitRepositoryInterface.
+ */
+interface GitRepositoryInterface
+{
+    /**
+     * @return string
+     */
+    public function getHeadCommit();
+
+    /**
+     * @return string
+     */
+    public function getYearRange();
+}
diff --git a/src/HeaderComment/ChainTemplateProvider.php b/src/HeaderComment/ChainTemplateProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..52939e95f5ea177d5ee1710d85e7a583296ddda2
--- /dev/null
+++ b/src/HeaderComment/ChainTemplateProvider.php
@@ -0,0 +1,52 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\HeaderComment;
+
+use Assert\Assertion;
+
+/**
+ * Class ChainTemplateProvider.
+ */
+final class ChainTemplateProvider implements TemplateProviderInterface
+{
+    /**
+     * @var TemplateProviderInterface[]
+     */
+    private $templateProviders;
+
+    /**
+     * ChainTemplateProvider constructor.
+     *
+     * @param TemplateProviderInterface[] $templateProviders
+     */
+    public function __construct($templateProviders)
+    {
+        Assertion::isArray($templateProviders);
+        Assertion::allImplementsInterface($templateProviders, TemplateProviderInterface::class);
+
+        $this->templateProviders = $templateProviders;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getTemplate()
+    {
+        foreach ($this->templateProviders as $templateProvider) {
+            $template = $templateProvider->getTemplate();
+            if ($template) {
+                return $template;
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/src/HeaderComment/FormattedHeaderProvider.php b/src/HeaderComment/FormattedHeaderProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..017e642b3eac6b666a905cb89702ba3e0d884759
--- /dev/null
+++ b/src/HeaderComment/FormattedHeaderProvider.php
@@ -0,0 +1,47 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\HeaderComment;
+
+/**
+ * Class FormattedHeaderProvider.
+ */
+final class FormattedHeaderProvider implements HeaderProviderInterface
+{
+    /**
+     * @var TemplateFormatterInterface
+     */
+    private $templateFormatter;
+
+    /**
+     * @var TemplateProviderInterface
+     */
+    private $templateProvider;
+
+    /**
+     * UserDefinedTemplateProvider constructor.
+     */
+    public function __construct(TemplateProviderInterface $templateProvider, TemplateFormatterInterface $templateFormatter)
+    {
+        $this->templateProvider = $templateProvider;
+        $this->templateFormatter = $templateFormatter;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getHeader()
+    {
+        $template = $this->templateProvider->getTemplate();
+        var_dump($template);
+
+        return $template ? $this->templateFormatter->format($template) : null;
+    }
+}
diff --git a/src/HeaderComment/HeaderProviderInterface.php b/src/HeaderComment/HeaderProviderInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..51acfe7278767110eb2835e9ca55ad3ae97c6a4f
--- /dev/null
+++ b/src/HeaderComment/HeaderProviderInterface.php
@@ -0,0 +1,22 @@
+<?php
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\HeaderComment;
+
+/**
+ * Class HeaderProviderInterface.
+ */
+interface HeaderProviderInterface
+{
+    /**
+     * @return string|null
+     */
+    public function getHeader();
+}
diff --git a/src/HeaderComment/LicenseTemplateProvider.php b/src/HeaderComment/LicenseTemplateProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..2419045c0c9633ad20984af7647ee3d5862bed14
--- /dev/null
+++ b/src/HeaderComment/LicenseTemplateProvider.php
@@ -0,0 +1,72 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\HeaderComment;
+
+use Irstea\CS\Composer\ComposerPackageInterface;
+use Irstea\CS\FileLocator\FileLocator;
+use Irstea\CS\FileLocator\FileLocatorInterface;
+
+/**
+ * Class LicenseTemplateProvider.
+ */
+final class LicenseTemplateProvider implements TemplateProviderInterface
+{
+    /**
+     * @var ComposerPackageInterface
+     */
+    private $composerPackage;
+
+    /**
+     * @var FileLocator
+     */
+    private $fileLocator;
+
+    /**
+     * LicenseTemplateProvider constructor.
+     */
+    public function __construct(
+        ComposerPackageInterface $composerPackage,
+        FileLocatorInterface $fileLocator = null
+    ) {
+        $this->composerPackage = $composerPackage;
+        $this->fileLocator = $fileLocator ?: new FileLocator(\dirname(__DIR__, 2) . '/headers');
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getTemplate()
+    {
+        $path = $this->getLicensePath();
+
+        return $path ? file_get_contents($path) : null;
+    }
+
+    /**
+     * @return string|null
+     */
+    private function getLicensePath()
+    {
+        $licenses = $this->composerPackage->getLicenses();
+        if (!$licenses) {
+            return $this->fileLocator->locate('proprietary.txt');
+        }
+
+        foreach ($licenses as $license) {
+            $templatePath = $this->fileLocator->locate("$license.txt");
+            if ($templatePath) {
+                return $templatePath;
+            }
+        }
+
+        return $this->fileLocator->locate('default.txt');
+    }
+}
diff --git a/src/HeaderComment/TemplateFormatter.php b/src/HeaderComment/TemplateFormatter.php
new file mode 100644
index 0000000000000000000000000000000000000000..b2be4e92d719cc472efb59342057273a545b6ff9
--- /dev/null
+++ b/src/HeaderComment/TemplateFormatter.php
@@ -0,0 +1,60 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\HeaderComment;
+
+use Assert\Assertion;
+use Irstea\CS\Composer\ComposerPackageInterface;
+use Irstea\CS\Git\GitRepositoryInterface;
+
+/**
+ * Class TemplateFormatter.
+ */
+final class TemplateFormatter implements TemplateFormatterInterface
+{
+    /**
+     * @var GitRepositoryInterface
+     */
+    private $gitRepository;
+
+    /**
+     * @var ComposerPackageInterface
+     */
+    private $composerPackage;
+
+    /**
+     * TemplateFormatter constructor.
+     */
+    public function __construct(GitRepositoryInterface $gitRepository, ComposerPackageInterface $composerPackage)
+    {
+        $this->gitRepository = $gitRepository;
+        $this->composerPackage = $composerPackage;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function format($template)
+    {
+        Assertion::string($template);
+
+        $variables = [
+            '%package%'     => $this->composerPackage->getName(),
+            '%description%' => $this->composerPackage->getDescription(),
+            '%yearRange%'   => $this->gitRepository->getYearRange(),
+        ];
+
+        return str_replace(
+            array_keys($variables),
+            array_values($variables),
+            $template
+        );
+    }
+}
diff --git a/src/HeaderComment/TemplateFormatterInterface.php b/src/HeaderComment/TemplateFormatterInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..330f2d237df6bfe6029189d37df2e59a6da2816f
--- /dev/null
+++ b/src/HeaderComment/TemplateFormatterInterface.php
@@ -0,0 +1,24 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\HeaderComment;
+
+/**
+ * Class TemplateFormatterInterface.
+ */
+interface TemplateFormatterInterface
+{
+    /**
+     * @param string $template
+     *
+     * @return string
+     */
+    public function format($template);
+}
diff --git a/src/HeaderComment/TemplateProviderInterface.php b/src/HeaderComment/TemplateProviderInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..1883fcb5e2f33c02b73bf61b2ca23456b3ce2c69
--- /dev/null
+++ b/src/HeaderComment/TemplateProviderInterface.php
@@ -0,0 +1,22 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\HeaderComment;
+
+/**
+ * Class TemplateProviderInterface.
+ */
+interface TemplateProviderInterface
+{
+    /**
+     * @return string|null
+     */
+    public function getTemplate();
+}
diff --git a/src/HeaderComment/UserDefinedTemplateProvider.php b/src/HeaderComment/UserDefinedTemplateProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..54c00cf47d52b12b48d9cf0ec813e04071b9c63f
--- /dev/null
+++ b/src/HeaderComment/UserDefinedTemplateProvider.php
@@ -0,0 +1,40 @@
+<?php
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\HeaderComment;
+
+use Irstea\CS\FileLocator\FileLocatorInterface;
+
+/**
+ * Class UserDefinedTemplateProvider.
+ */
+final class UserDefinedTemplateProvider implements TemplateProviderInterface
+{
+    /**
+     * @var FileLocatorInterface
+     */
+    private $fileLocator;
+
+    /**
+     * UserDefinedTemplateProvider constructor.
+     */
+    public function __construct(FileLocatorInterface $fileLocator)
+    {
+        $this->fileLocator = $fileLocator;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getTemplate()
+    {
+        return $this->fileLocator->locate('.docheader');
+    }
+}
diff --git a/tests/FileLocator/FileLocatorTest.php b/tests/FileLocator/FileLocatorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..7dc2e3574976bebdac2502f6c2ec7fbe3d94a0a2
--- /dev/null
+++ b/tests/FileLocator/FileLocatorTest.php
@@ -0,0 +1,43 @@
+<?php declare(strict_types=1);
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\Tests\FileLocator;
+
+use Irstea\CS\FileLocator\FileLocator;
+use org\bovigo\vfs\vfsStream;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Class FileLocatorTest.
+ */
+class FileLocatorTest extends TestCase
+{
+    public function testShouldLocateExistingFile()
+    {
+        $fs = vfsStream::setup('root', 0755, [
+            'file' => 'content',
+        ]);
+
+        $locator = new FileLocator($fs->url());
+
+        self::assertEquals(
+            $fs->getChild('file')->url(),
+            $locator->locate('file')
+        );
+    }
+
+    public function testShouldReturnNullOnMissingFile()
+    {
+        $fs = vfsStream::setup();
+        $locator = new FileLocator($fs->url());
+
+        self::assertNull($locator->locate('file'));
+    }
+}
diff --git a/tests/HeaderComment/LicenseTemplateProviderTest.php b/tests/HeaderComment/LicenseTemplateProviderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a84096b92ab9c804bbf964e8047c19f158cc7af0
--- /dev/null
+++ b/tests/HeaderComment/LicenseTemplateProviderTest.php
@@ -0,0 +1,98 @@
+<?php
+/*
+ * irstea/php-cs-fixer-config - Jeux de règles pour php-cs-fixer.
+ * Copyright (C) 2018-2019 IRSTEA
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+namespace Irstea\CS\Tests\HeaderComment;
+
+use Irstea\CS\Composer\ComposerPackageInterface;
+use Irstea\CS\FileLocator\FileLocator;
+use Irstea\CS\FileLocator\FileLocatorInterface;
+use Irstea\CS\HeaderComment\LicenseTemplateProvider;
+use org\bovigo\vfs\vfsStream;
+use org\bovigo\vfs\vfsStreamDirectory;
+use PHPUnit\Framework\TestCase;
+use Prophecy\Prophecy\ObjectProphecy;
+
+/**
+ * Class LicenseTemplateProviderTest.
+ */
+final class LicenseTemplateProviderTest extends TestCase
+{
+    /**
+     * @var ComposerPackageInterface|ObjectProphecy
+     */
+    private $composerPackage;
+
+    /**
+     * @var vfsStreamDirectory
+     */
+    private $filesystem;
+
+    /**
+     * @var FileLocatorInterface
+     */
+    private $fileLocator;
+
+    /**
+     * @var LicenseTemplateProvider
+     */
+    private $templateProvider;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        /* @var ComposerPackageInterface|ObjectProphecy $composerPackage */
+        $this->composerPackage = $this->prophesize(ComposerPackageInterface::class);
+
+        $this->filesystem = vfsStream::setup(
+            'root',
+            0755,
+            [
+                'proprietary.txt' => 'proprietary-template',
+                'GPL.txt'         => 'GPL-template',
+                'default.txt'     => 'default-template',
+            ]
+        );
+
+        $this->fileLocator = new FileLocator($this->filesystem->url());
+
+        $this->templateProvider = new LicenseTemplateProvider(
+            $this->composerPackage->reveal(),
+            $this->fileLocator
+        );
+    }
+
+    public function testShouldProvideKnownLicenseHeader()
+    {
+        $this->composerPackage->getLicenses()
+            ->shouldBeCalled()
+            ->willReturn(['MIT', 'GPL']);
+
+        self::assertEquals('GPL-template', $this->templateProvider->getTemplate());
+    }
+
+    public function testShouldProvideDefaultLicenseHeader()
+    {
+        $this->composerPackage->getLicenses()
+            ->shouldBeCalled()
+            ->willReturn(['MIT']);
+
+        self::assertEquals('default-template', $this->templateProvider->getTemplate());
+    }
+
+    public function testShouldProvideProprietaryLicenseHeaderWithNoLicense()
+    {
+        $this->composerPackage->getLicenses()
+            ->shouldBeCalled()
+            ->willReturn([]);
+
+        self::assertEquals('proprietary-template', $this->templateProvider->getTemplate());
+    }
+}