Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
ChangelogManager
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
8 / 8
20
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addEntry
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 promote
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 inferNextVersion
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 renderReleaseNotes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 load
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resolveRepositoryUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 persist
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Changelog\Manager;
21
22use Throwable;
23use FastForward\DevTools\Changelog\Document\ChangelogDocument;
24use FastForward\DevTools\Changelog\Document\ChangelogRelease;
25use FastForward\DevTools\Changelog\Entry\ChangelogEntryType;
26use FastForward\DevTools\Filesystem\FilesystemInterface;
27use FastForward\DevTools\Git\GitClientInterface;
28use FastForward\DevTools\Changelog\Parser\ChangelogParserInterface;
29use FastForward\DevTools\Changelog\Renderer\MarkdownRendererInterface;
30use RuntimeException;
31
32/**
33 * Applies changelog mutations and derived release metadata.
34 */
35  class ChangelogManager implements ChangelogManagerInterface
36{
37    /**
38     * @param FilesystemInterface $filesystem
39     * @param ChangelogParserInterface $parser
40     * @param MarkdownRendererInterface $renderer
41     * @param GitClientInterface $gitClient
42     */
43    public function __construct(
44        private FilesystemInterface $filesystem,
45        private ChangelogParserInterface $parser,
46        private MarkdownRendererInterface $renderer,
47        private GitClientInterface $gitClient,
48    ) {}
49
50    /**
51     * Adds a changelog entry to the selected release section.
52     *
53     * @param string $file
54     * @param ChangelogEntryType $type
55     * @param string $message
56     * @param string $version
57     * @param ?string $date
58     */
59    public function addEntry(
60        string $file,
61        ChangelogEntryType $type,
62        string $message,
63        string $version = ChangelogDocument::UNRELEASED_VERSION,
64        ?string $date = null,
65    ): void {
66        $document = $this->load($file);
67        $release = $document->getRelease($version) ?? new ChangelogRelease($version, $date);
68
69        if (null !== $date && $release->getDate() !== $date) {
70            $release = $release->withDate($date);
71        }
72
73        $document = $document->withRelease($release->withEntry($type, $message));
74
75        $this->persist($file, $document);
76    }
77
78    /**
79     * Promotes the Unreleased section into a published release.
80     *
81     * @param string $file
82     * @param string $version
83     * @param string $date
84     */
85    public function promote(string $file, string $version, string $date): void
86    {
87        $document = $this->load($file);
88
89        if (! $document->getUnreleased()->hasEntries()) {
90            throw new RuntimeException(\sprintf('%s does not contain unreleased entries to promote.', $file));
91        }
92
93        $document = $document->promoteUnreleased($version, $date);
94
95        $this->persist($file, $document);
96    }
97
98    /**
99     * Returns the next semantic version inferred from unreleased entries.
100     *
101     * @param string $file
102     * @param ?string $currentVersion
103     */
104    public function inferNextVersion(string $file, ?string $currentVersion = null): string
105    {
106        $document = $this->load($file);
107        $unreleased = $document->getUnreleased();
108
109        if (! $unreleased->hasEntries()) {
110            throw new RuntimeException(\sprintf(
111                '%s does not contain unreleased entries to infer a version from.',
112                $file
113            ));
114        }
115
116        $currentVersion ??= $document->getLatestPublishedRelease()?->getVersion() ?? '0.0.0';
117        [$major, $minor, $patch] = array_map(intval(...), explode('.', $currentVersion));
118
119        if ([] !== $unreleased->getEntriesFor(ChangelogEntryType::Removed)
120            || [] !== $unreleased->getEntriesFor(ChangelogEntryType::Deprecated)
121        ) {
122            return \sprintf('%d.0.0', $major + 1);
123        }
124
125        if ([] !== $unreleased->getEntriesFor(ChangelogEntryType::Added)
126            || [] !== $unreleased->getEntriesFor(ChangelogEntryType::Changed)
127        ) {
128            return \sprintf('%d.%d.0', $major, $minor + 1);
129        }
130
131        return \sprintf('%d.%d.%d', $major, $minor, $patch + 1);
132    }
133
134    /**
135     * Returns the rendered notes body for a specific released version.
136     *
137     * @param string $file
138     * @param string $version
139     */
140    public function renderReleaseNotes(string $file, string $version): string
141    {
142        $release = $this->load($file)
143            ->getRelease($version);
144
145        if (! $release instanceof ChangelogRelease) {
146            throw new RuntimeException(\sprintf('%s does not contain a [%s] section.', $file, $version));
147        }
148
149        return $this->renderer->renderReleaseBody($release);
150    }
151
152    /**
153     * Loads and parses the changelog file.
154     *
155     * @param string $file
156     */
157    public function load(string $file): ChangelogDocument
158    {
159        if (! $this->filesystem->exists($file)) {
160            return ChangelogDocument::create();
161        }
162
163        return $this->parser->parse($this->filesystem->readFile($file));
164    }
165
166    /**
167     * Resolves the canonical repository URL for compare links.
168     *
169     * @param string $workingDirectory
170     */
171    private function resolveRepositoryUrl(string $workingDirectory): ?string
172    {
173        try {
174            $repositoryUrl = $this->gitClient->getConfig('remote.origin.url', $workingDirectory);
175        } catch (Throwable) {
176            return null;
177        }
178
179        return '' === $repositoryUrl ? null : $repositoryUrl;
180    }
181
182    /**
183     * Persists the rendered changelog document to disk.
184     *
185     * @param string $file
186     * @param ChangelogDocument $document
187     */
188    private function persist(string $file, ChangelogDocument $document): void
189    {
190        $this->filesystem->dumpFile(
191            $file,
192            $this->renderer->render($document, $this->resolveRepositoryUrl($this->filesystem->dirname($file))),
193        );
194    }
195}