McpServerLogFileActions.java

/*-
 * ========================LICENSE_START=================================
 * flyway-command-mcp
 * ========================================================================
 * Copyright (C) 2010 - 2026 Red Gate Software Ltd
 * ========================================================================
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * =========================LICENSE_END==================================
 */
package org.flywaydb.mcp;

import java.io.IOException;
import java.io.OutputStream;
import java.math.BigInteger;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.logging.Log;

/**
 * Provides actions for managing log files for use with the mcp server log implementation. This is an experimental API
 * and may be removed or changed in future versions.
 */
@RequiredArgsConstructor
class McpServerLogFileActions {
    private static final int MAX_RETRIES = 5;
    private final Log log;
    private final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("yyyy_MM_dd_HH_mm_ss", Locale.ROOT)
        .withZone(ZoneOffset.UTC);
    private final Pattern pattern = Pattern.compile(
        "flyway-mcp (\\d{4}_\\d{2}_\\d{2}_\\d{2}_\\d{2}_\\d{2})_(\\d+)\\.log");

    Optional<LogFileIdentifier> parseIdentifier(final Path path) {
        final Matcher matcher = pattern.matcher(path.getFileName().toString());
        if (matcher.matches()) {
            try {
                final Instant instant = Instant.from(timeFormatter.parse(matcher.group(1)));
                return Optional.of(new LogFileIdentifier(path, instant, new BigInteger(matcher.group(2))));
            } catch (final DateTimeParseException ignored) {
            }
        }
        return Optional.empty();
    }

    List<LogFileIdentifier> getAllLogs() {
        final Path dir = getLogFileDirectory();
        if (!Files.isDirectory(dir)) {
            return List.of();
        }

        try (final Stream<Path> stream = Files.find(dir, 1, (p, a) -> a.isRegularFile())) {
            return stream.flatMap(x -> parseIdentifier(x).stream()).toList();
        } catch (final IOException e) {
            throw new FlywayException("Failed to list MCP log files", e);
        }
    }

    OutputStream startNewLog(final Clock clock) {
        try {
            final Path dir = getLogFileDirectory();
            Files.createDirectories(dir);

            for (int retry = 0; retry < MAX_RETRIES; retry++) {
                final Instant now = Instant.now(clock).truncatedTo(ChronoUnit.SECONDS);
                final BigInteger number = getAllLogs().stream()
                    .filter(x -> x.instant().equals(now))
                    .reduce(BigInteger.ZERO, (a, b) -> a.max(b.number()), BigInteger::max);
                final Path path = dir.resolve("flyway-mcp "
                    + timeFormatter.format(now)
                    + "_"
                    + number.add(BigInteger.ONE)
                    + ".log");

                try {
                    return Files.newOutputStream(path, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
                } catch (final FileAlreadyExistsException ignored) {
                    log.debug("Failed to create MCP log file on retry " + (1 + retry) + " / " + MAX_RETRIES);
                }
            }

            throw new FlywayException("Failed to create MCP log file after " + MAX_RETRIES + " retries");
        } catch (final IOException e) {
            log.error("Failed to create MCP log file", e);
            throw new FlywayException("Failed to create MCP log file", e);
        }
    }

    void pruneLogs(final int maxLogs) {
        if (maxLogs < 1) {
            log.warn("maxLogs configured as < 1 - removal of old logs skipped");
            return;
        }
        try {
            getAllLogs().stream().sorted(Comparator.reverseOrder()).skip(maxLogs).forEach(x -> {
                try {
                    Files.deleteIfExists(x.path());
                } catch (final IOException e) {
                    log.warn("Failed to delete old MCP log file " + x.path() + ": " + e.getMessage());
                }
            });
        } catch (final Exception e) {
            log.warn("Failed to prune old MCP log files: " + e.getMessage());
        }
    }

    private Path getLogFileDirectory() {
        final boolean isWindows = System.getProperty("os.name").toLowerCase(Locale.ENGLISH).contains("win");
        return isWindows
            ? Path.of(System.getenv("LocalAppData"), "Red Gate", "Logs", "Flyway")
            : Path.of(System.getProperty("user.home"), ".local", "share", "Red Gate", "Logs", "Flyway");
    }

    record LogFileIdentifier(Path path, Instant instant, BigInteger number) implements Comparable<LogFileIdentifier> {
        @Override
        public int compareTo(final @NonNull McpServerLogFileActions.LogFileIdentifier o) {
            final int instantComparison = instant.compareTo(o.instant);
            if (instantComparison != 0) {
                return instantComparison;
            }

            final int numberComparison = number.compareTo(o.number);
            if (numberComparison != 0) {
                return numberComparison;
            }

            return path.compareTo(o.path);
        }
    }
}