001/*
002 * Copyright (C) 2022 - 2023, the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *    http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package io.github.ascopes.jct.junit;
017
018import io.github.ascopes.jct.workspaces.Workspace;
019import io.github.ascopes.jct.workspaces.Workspaces;
020import java.lang.reflect.Field;
021import java.lang.reflect.Modifier;
022import java.util.ArrayList;
023import java.util.List;
024import org.apiguardian.api.API;
025import org.apiguardian.api.API.Status;
026import org.jspecify.annotations.Nullable;
027import org.junit.jupiter.api.extension.AfterAllCallback;
028import org.junit.jupiter.api.extension.AfterEachCallback;
029import org.junit.jupiter.api.extension.BeforeAllCallback;
030import org.junit.jupiter.api.extension.BeforeEachCallback;
031import org.junit.jupiter.api.extension.Extension;
032import org.junit.jupiter.api.extension.ExtensionContext;
033import org.slf4j.Logger;
034import org.slf4j.LoggerFactory;
035
036/**
037 * Implicit extension that will manage the lifecycle of {@link Managed}-annotated {@link Workspace}
038 * fields within JUnit5 test classes.
039 *
040 * @author Ashley Scopes
041 * @since 0.4.0
042 */
043@API(since = "0.4.0", status = Status.STABLE)
044public final class JctExtension implements
045    Extension, BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback {
046
047  private static final Logger LOGGER = LoggerFactory.getLogger(JctExtension.class);
048
049  @Override
050  public void beforeAll(ExtensionContext context) throws Exception {
051    for (var field : getManagedStaticWorkspaceFields(context.getRequiredTestClass())) {
052      initWorkspaceForField(field, null);
053    }
054  }
055
056  @Override
057  public void beforeEach(ExtensionContext context) throws Exception {
058    for (var instance : context.getRequiredTestInstances().getAllInstances()) {
059      for (var field : getManagedInstanceWorkspaceFields(instance.getClass())) {
060        initWorkspaceForField(field, instance);
061      }
062    }
063  }
064
065  @Override
066  public void afterAll(ExtensionContext context) throws Exception {
067    for (var field : getManagedStaticWorkspaceFields(context.getRequiredTestClass())) {
068      closeWorkspaceForField(field, null);
069    }
070  }
071
072  @Override
073  public void afterEach(ExtensionContext context) throws Exception {
074    for (var instance : context.getRequiredTestInstances().getAllInstances()) {
075      for (var field : getManagedInstanceWorkspaceFields(instance.getClass())) {
076        closeWorkspaceForField(field, instance);
077      }
078    }
079  }
080
081  private List<Field> getManagedStaticWorkspaceFields(Class<?> clazz) {
082    // Do not recurse for static fields, as the state of any parent classes may be shared
083    // with other classes running in parallel. Need to look up how JUnit expects us to handle that
084    // case, if at all.
085
086    var fields = new ArrayList<Field>();
087
088    for (var field : clazz.getDeclaredFields()) {
089      if (isWorkspaceField(field) && Modifier.isStatic(field.getModifiers())) {
090        fields.add(field);
091      }
092    }
093
094    return fields;
095  }
096
097  private List<Field> getManagedInstanceWorkspaceFields(Class<?> clazz) {
098    // For instances, discover all the fields recursively in superclasses as well that are
099    // non-static.
100
101    var fields = new ArrayList<Field>();
102
103    while (clazz != null) {
104      for (var field : clazz.getDeclaredFields()) {
105        if (isWorkspaceField(field) && !Modifier.isStatic(field.getModifiers())) {
106          fields.add(field);
107        }
108      }
109      clazz = clazz.getSuperclass();
110    }
111
112    return fields;
113  }
114
115  private boolean isWorkspaceField(Field field) {
116    return field.getType().equals(Workspace.class)
117        && field.isAnnotationPresent(Managed.class);
118  }
119
120  private void initWorkspaceForField(Field field, @Nullable Object instance) throws Exception {
121    LOGGER
122        .atTrace()
123        .setMessage("Initialising workspace for field in {}: {} {} on instance {}")
124        .addArgument(() -> field.getDeclaringClass().getSimpleName())
125        .addArgument(() -> field.getType().getSimpleName())
126        .addArgument(field::getName)
127        .addArgument(instance)
128        .log();
129
130    field.setAccessible(true);
131    var managedWorkspace = field.getAnnotation(Managed.class);
132    var workspace = Workspaces.newWorkspace(managedWorkspace.pathStrategy());
133    field.set(instance, workspace);
134  }
135
136  private void closeWorkspaceForField(Field field, @Nullable Object instance) throws Exception {
137    LOGGER
138        .atTrace()
139        .setMessage("Closing workspace for field in {}: {} {} on instance {}")
140        .addArgument(() -> field.getDeclaringClass().getSimpleName())
141        .addArgument(() -> field.getType().getSimpleName())
142        .addArgument(field::getName)
143        .addArgument(instance)
144        .log();
145
146    field.setAccessible(true);
147    ((Workspace) field.get(instance)).close();
148  }
149}