ArchitectureRelationGitCommandService.java

package com.taxonomy.relations.command;

import com.taxonomy.dsl.command.ArchitectureRelationDslTransformer;
import com.taxonomy.dsl.command.ArchitectureRelationDslTransformer.ChangeKind;
import com.taxonomy.dsl.command.ArchitectureRelationDslTransformer.ChangeResult;
import com.taxonomy.dsl.command.ArchitectureRelationDslTransformer.RelationDefinition;
import com.taxonomy.dsl.command.ArchitectureRelationDslTransformer.RelationIdentity;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.dsl.storage.ExpectedHeadDslCommitter;
import com.taxonomy.dsl.storage.ExpectedHeadDslCommitter.CommitRequest;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.RepositoryScope;
import org.eclipse.jgit.lib.ObjectId;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.Locale;
import java.util.Objects;

/**
 * Executes one relation command against the exact repository context and makes
 * the resulting JGit commit the authority token.
 *
 * <p>This service deliberately has no relational repository dependency. A later
 * projection stage may consume {@link CommandResult#authoritativeCommitId()},
 * but a database write can never precede or substitute the Git command here.</p>
 */
@Service
public class ArchitectureRelationGitCommandService {

    private final DslGitRepositoryFactory repositoryFactory;
    private final ArchitectureRelationDslTransformer transformer;
    private final ExpectedHeadDslCommitter committer;

    @Autowired
    public ArchitectureRelationGitCommandService(
            DslGitRepositoryFactory repositoryFactory) {
        this(repositoryFactory,
                new ArchitectureRelationDslTransformer(),
                new ExpectedHeadDslCommitter());
    }

    ArchitectureRelationGitCommandService(
            DslGitRepositoryFactory repositoryFactory,
            ArchitectureRelationDslTransformer transformer,
            ExpectedHeadDslCommitter committer) {
        this.repositoryFactory = Objects.requireNonNull(
                repositoryFactory, "repositoryFactory");
        this.transformer = Objects.requireNonNull(transformer, "transformer");
        this.committer = Objects.requireNonNull(committer, "committer");
    }

    /**
     * Applies and commits one command using only the supplied request-stable
     * repository context.
     */
    public CommandResult execute(
            RepositoryContext context,
            String expectedHeadCommit,
            RelationCommand command) throws IOException {
        Objects.requireNonNull(context, "context");
        Objects.requireNonNull(command, "command");
        requireMutable(context);
        String normalizedExpectedHead = normalizeExpectedHead(expectedHeadCommit);

        DslGitRepository repository = repositoryFactory.resolveRepository(context);
        String sourceDsl = normalizedExpectedHead == null
                ? ""
                : repository.getDslAtCommit(normalizedExpectedHead);
        if (sourceDsl == null) {
            sourceDsl = "";
        }

        ChangeResult change = apply(sourceDsl, command);
        if (!change.changed()) {
            String authoritativeHead = committer.verifyExpectedHead(
                    repository,
                    context.branch(),
                    normalizedExpectedHead);
            if (authoritativeHead == null) {
                throw new MissingAuthoritativeHeadException(
                        "Semantic no-op has no existing Git commit on branch '"
                                + context.branch() + "'");
            }
            return result(
                    context,
                    normalizedExpectedHead,
                    authoritativeHead,
                    change.kind(),
                    false,
                    command.metadata().causationId());
        }

        ExpectedHeadDslCommitter.CommitResult commit = committer.commit(
                repository,
                new CommitRequest(
                        context.branch(),
                        normalizedExpectedHead,
                        change.dsl(),
                        context.username(),
                        commitMessage(command, change.kind())));
        return result(
                context,
                commit.previousHeadCommit(),
                commit.commitId(),
                change.kind(),
                true,
                command.metadata().causationId());
    }

    private ChangeResult apply(String sourceDsl, RelationCommand command) {
        if (command instanceof UpsertRelation upsert) {
            return transformer.upsert(sourceDsl, upsert.definition());
        }
        if (command instanceof RemoveRelation remove) {
            return transformer.remove(sourceDsl, remove.identity());
        }
        throw new IllegalArgumentException(
                "Unsupported relation command: " + command.getClass().getName());
    }

    private String commitMessage(RelationCommand command, ChangeKind changeKind) {
        RelationIdentity identity = command.identity();
        StringBuilder message = new StringBuilder("relation: ")
                .append(changeKind.name().toLowerCase(Locale.ROOT))
                .append(' ')
                .append(identity.sourceId()).append(' ')
                .append(identity.relationType()).append(' ')
                .append(identity.targetId())
                .append("\n\nCausation-Id: ")
                .append(command.metadata().causationId());
        if (command.metadata().rationale() != null) {
            message.append("\nRationale: ")
                    .append(command.metadata().rationale());
        }
        return message.toString();
    }

    private CommandResult result(
            RepositoryContext context,
            String previousHead,
            String authoritativeHead,
            ChangeKind changeKind,
            boolean commitCreated,
            String causationId) {
        return new CommandResult(
                context.repositoryId(),
                context.workspaceId(),
                context.branch(),
                context.scope(),
                previousHead,
                authoritativeHead,
                changeKind,
                commitCreated,
                causationId);
    }

    private static void requireMutable(RepositoryContext context) {
        if (context.scope() == RepositoryScope.CENTRAL_READ) {
            throw new ReadOnlyRepositoryContextException(
                    "Relation commands require CENTRAL_WRITE, WORKSPACE or FORK scope");
        }
    }

    private static String normalizeExpectedHead(String expectedHeadCommit) {
        if (expectedHeadCommit == null) {
            return null;
        }
        String normalized = expectedHeadCommit.strip();
        if (normalized.isEmpty()) {
            throw new IllegalArgumentException(
                    "expectedHeadCommit must be null or a full commit ID");
        }
        ObjectId.fromString(normalized);
        return normalized;
    }

    public sealed interface RelationCommand
            permits UpsertRelation, RemoveRelation {
        RelationIdentity identity();
        CommandMetadata metadata();
    }

    public record UpsertRelation(
            RelationDefinition definition,
            CommandMetadata metadata) implements RelationCommand {
        public UpsertRelation {
            definition = Objects.requireNonNull(definition, "definition");
            metadata = Objects.requireNonNull(metadata, "metadata");
        }

        @Override
        public RelationIdentity identity() {
            return definition.identity();
        }
    }

    public record RemoveRelation(
            RelationIdentity identity,
            CommandMetadata metadata) implements RelationCommand {
        public RemoveRelation {
            identity = Objects.requireNonNull(identity, "identity");
            metadata = Objects.requireNonNull(metadata, "metadata");
        }
    }

    public record CommandMetadata(
            String causationId,
            String rationale) {
        public CommandMetadata {
            causationId = requireSingleLine(causationId, "causationId");
            rationale = normalizeRationale(rationale);
        }

        public CommandMetadata(String causationId) {
            this(causationId, null);
        }
    }

    public record CommandResult(
            String repositoryId,
            String workspaceId,
            String branch,
            RepositoryScope scope,
            String previousHeadCommit,
            String authoritativeCommitId,
            ChangeKind changeKind,
            boolean commitCreated,
            String causationId) {
        public CommandResult {
            repositoryId = Objects.requireNonNull(repositoryId, "repositoryId");
            branch = Objects.requireNonNull(branch, "branch");
            scope = Objects.requireNonNull(scope, "scope");
            authoritativeCommitId = Objects.requireNonNull(
                    authoritativeCommitId, "authoritativeCommitId");
            changeKind = Objects.requireNonNull(changeKind, "changeKind");
            causationId = Objects.requireNonNull(causationId, "causationId");
        }
    }

    public static final class ReadOnlyRepositoryContextException
            extends IllegalStateException {
        public ReadOnlyRepositoryContextException(String message) {
            super(message);
        }
    }

    public static final class MissingAuthoritativeHeadException
            extends IllegalStateException {
        public MissingAuthoritativeHeadException(String message) {
            super(message);
        }
    }

    private static String requireSingleLine(String value, String field) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(field + " must not be blank");
        }
        String normalized = value.strip();
        if (normalized.indexOf('\n') >= 0 || normalized.indexOf('\r') >= 0) {
            throw new IllegalArgumentException(field + " must be one line");
        }
        return normalized;
    }

    private static String normalizeRationale(String rationale) {
        if (rationale == null || rationale.isBlank()) {
            return null;
        }
        return rationale.strip().replaceAll("\\s+", " ");
    }
}