SelectiveTransferService.java
package com.taxonomy.versioning.service;
import com.taxonomy.dsl.mapper.AstToModelMapper;
import com.taxonomy.dsl.mapper.ModelToAstMapper;
import com.taxonomy.dsl.model.ArchitectureElement;
import com.taxonomy.dsl.model.ArchitectureRelation;
import com.taxonomy.dsl.model.CanonicalArchitectureModel;
import com.taxonomy.dsl.parser.TaxDslParser;
import com.taxonomy.dsl.serializer.TaxDslSerializer;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.dto.TransferConflict;
import com.taxonomy.dto.TransferSelection;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import com.taxonomy.workspace.service.WorkspaceResolver;
/**
* Performs selective element/relation transfers between architecture contexts.
*
* <p>Unlike a full Git merge or cherry-pick, this service allows the user to
* pick individual elements and relations from one version and apply them to
* another. Conflict detection and preview are provided before any changes
* are committed.
*/
@Service
public class SelectiveTransferService {
private static final Logger log = LoggerFactory.getLogger(SelectiveTransferService.class);
private final DslGitRepositoryFactory repositoryFactory;
private final ContextNavigationService contextNavigationService;
private final WorkspaceResolver workspaceResolver;
private final TaxDslParser parser = new TaxDslParser();
private final AstToModelMapper astMapper = new AstToModelMapper();
private final ModelToAstMapper modelToAstMapper = new ModelToAstMapper();
private final TaxDslSerializer serializer = new TaxDslSerializer();
public SelectiveTransferService(DslGitRepositoryFactory repositoryFactory,
ContextNavigationService contextNavigationService,
WorkspaceResolver workspaceResolver) {
this.repositoryFactory = repositoryFactory;
this.contextNavigationService = contextNavigationService;
this.workspaceResolver = workspaceResolver;
}
/**
* Resolve the Git repository for the given workspace context.
*
* @param ctx the workspace context (use {@link WorkspaceContext#SHARED}
* for the system repository)
* @return the resolved DslGitRepository
*/
private DslGitRepository resolveRepository(WorkspaceContext ctx) {
return repositoryFactory.resolveRepository(ctx);
}
/**
* Resolve the current workspace context from the authenticated user.
* Falls back to {@link WorkspaceContext#SHARED} if resolution fails.
*/
private WorkspaceContext resolveContext() {
try {
return workspaceResolver.resolveCurrentContext();
} catch (Exception e) {
return WorkspaceContext.SHARED;
}
}
/**
* Preview a selective transfer without modifying any data.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #previewTransfer(TransferSelection, WorkspaceContext)} for workspace-aware resolution.
*
* @param selection the transfer selection
* @return list of conflicts (empty if no conflicts)
* @throws IOException if Git operations fail
*/
public List<TransferConflict> previewTransfer(TransferSelection selection) throws IOException {
return previewTransfer(selection, WorkspaceContext.SHARED);
}
/**
* Preview a selective transfer without modifying any data.
*
* @param selection the transfer selection
* @param ctx the workspace context for repository resolution
* @return list of conflicts (empty if no conflicts)
* @throws IOException if Git operations fail
*/
public List<TransferConflict> previewTransfer(TransferSelection selection,
WorkspaceContext ctx) throws IOException {
DslGitRepository repo = resolveRepository(ctx);
CanonicalArchitectureModel sourceModel = loadModel(repo, selection.sourceContextId());
CanonicalArchitectureModel targetModel = loadModel(repo, selection.targetContextId());
return detectConflicts(sourceModel, targetModel, selection);
}
/**
* Apply a selective transfer, merging selected elements and relations
* from the source context into the target context.
*
* <p>This is a request-bound operation — it resolves the current workspace
* context from the authenticated user via {@code WorkspaceResolver}.
*
* @param selection the transfer selection
* @return the commit ID of the resulting commit
* @throws IOException if Git operations fail
*/
public String applyTransfer(TransferSelection selection) throws IOException {
WorkspaceContext ctx = resolveContext();
DslGitRepository repo = resolveRepository(ctx);
CanonicalArchitectureModel sourceModel = loadModel(repo, selection.sourceContextId());
CanonicalArchitectureModel targetModel = loadModel(repo, selection.targetContextId());
// Merge selected elements
Map<String, ArchitectureElement> targetElements = targetModel.getElements().stream()
.collect(Collectors.toMap(ArchitectureElement::getId, Function.identity()));
for (ArchitectureElement sourceEl : sourceModel.getElements()) {
if (selection.selectedElementIds().contains(sourceEl.getId())) {
targetElements.put(sourceEl.getId(), sourceEl);
}
}
// Merge selected relations
Map<String, ArchitectureRelation> targetRelations = targetModel.getRelations().stream()
.collect(Collectors.toMap(this::relationKey, Function.identity()));
for (ArchitectureRelation sourceRel : sourceModel.getRelations()) {
String key = relationKey(sourceRel);
if (selection.selectedRelationIds().contains(key)) {
targetRelations.put(key, sourceRel);
}
}
// Build merged model
targetModel.getElements().clear();
targetModel.getElements().addAll(targetElements.values());
targetModel.getRelations().clear();
targetModel.getRelations().addAll(targetRelations.values());
// Serialize merged model back to DSL and commit
String targetDsl = repo.getDslAtCommit(selection.targetContextId());
var originalDoc = parser.parse(targetDsl);
String namespace = originalDoc.getMeta() != null
? originalDoc.getMeta().namespace() : "default";
var mergedDoc = modelToAstMapper.toDocument(targetModel, namespace);
String mergedDsl = serializer.serialize(mergedDoc);
String targetBranch = contextNavigationService.getCurrentContext(
workspaceResolver.resolveCurrentUsername()).branch();
String commitId = repo.commitDsl(
targetBranch, mergedDsl, "system",
"Selective transfer: " + selection.selectedElementIds().size() + " elements, "
+ selection.selectedRelationIds().size() + " relations");
log.info("Selective transfer applied: {} elements, {} relations → commit '{}'",
selection.selectedElementIds().size(),
selection.selectedRelationIds().size(),
commitId.substring(0, Math.min(7, commitId.length())));
return commitId;
}
// ── Internal helpers ────────────────────────────────────────────
private CanonicalArchitectureModel loadModel(DslGitRepository repo, String commitId) throws IOException {
String dsl = repo.getDslAtCommit(commitId);
if (dsl == null) {
throw new IOException("No DSL content at commit: " + commitId);
}
var doc = parser.parse(dsl);
return astMapper.map(doc);
}
List<TransferConflict> detectConflicts(
CanonicalArchitectureModel sourceModel,
CanonicalArchitectureModel targetModel,
TransferSelection selection) {
List<TransferConflict> conflicts = new ArrayList<>();
Map<String, ArchitectureElement> targetElements = targetModel.getElements().stream()
.collect(Collectors.toMap(ArchitectureElement::getId, Function.identity()));
for (ArchitectureElement sourceEl : sourceModel.getElements()) {
if (!selection.selectedElementIds().contains(sourceEl.getId())) {
continue;
}
ArchitectureElement existing = targetElements.get(sourceEl.getId());
if (existing != null && !elementsEqual(existing, sourceEl)) {
conflicts.add(new TransferConflict(
sourceEl.getId(),
existing.getTitle(),
sourceEl.getTitle(),
findViewsReferencing(targetModel, sourceEl.getId())));
}
}
Map<String, ArchitectureRelation> targetRelations = targetModel.getRelations().stream()
.collect(Collectors.toMap(this::relationKey, Function.identity()));
for (ArchitectureRelation sourceRel : sourceModel.getRelations()) {
String key = relationKey(sourceRel);
if (!selection.selectedRelationIds().contains(key)) {
continue;
}
ArchitectureRelation existing = targetRelations.get(key);
if (existing != null && !relationsEqual(existing, sourceRel)) {
conflicts.add(new TransferConflict(
key,
existing.getStatus(),
sourceRel.getStatus(),
List.of()));
}
}
return conflicts;
}
private boolean elementsEqual(ArchitectureElement a, ArchitectureElement b) {
return java.util.Objects.equals(a.getTitle(), b.getTitle())
&& java.util.Objects.equals(a.getDescription(), b.getDescription())
&& java.util.Objects.equals(a.getType(), b.getType());
}
private boolean relationsEqual(ArchitectureRelation a, ArchitectureRelation b) {
return java.util.Objects.equals(a.getStatus(), b.getStatus())
&& java.util.Objects.equals(a.getConfidence(), b.getConfidence());
}
private String relationKey(ArchitectureRelation rel) {
return rel.getSourceId() + " " + rel.getRelationType() + " " + rel.getTargetId();
}
private List<String> findViewsReferencing(CanonicalArchitectureModel model, String elementId) {
return model.getViews().stream()
.filter(v -> {
List<String> includes = v.getIncludes();
return includes != null && includes.contains(elementId);
})
.map(v -> v.getTitle())
.limit(5)
.toList();
}
}