SLING-8169 Bundle Import/Export Analyser needs to take API Regions into account

Store bundle origins in a file called bundleOrigins.properties and the
region origins in a file called regionOrigins.properties. The Analyser
can use this input to check imports/exports wrt API regions.
diff --git a/pom.xml b/pom.xml
index 7cb13e0..84e398b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -73,6 +73,12 @@
             <scope>test</scope>
         </dependency>
         <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>2.23.4</version>
+            <scope>test</scope>
+        </dependency>        
+        <dependency>
             <groupId>org.apache.johnzon</groupId>
             <artifactId>johnzon-core</artifactId>
             <version>1.0.0</version>
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/APIRegionMergeHandler.java b/src/main/java/org/apache/sling/feature/extension/apiregions/APIRegionMergeHandler.java
index 17f032f..87c4289 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/APIRegionMergeHandler.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/APIRegionMergeHandler.java
@@ -16,18 +16,27 @@
  */
 package org.apache.sling.feature.extension.apiregions;
 
+import org.apache.sling.feature.Artifact;
 import org.apache.sling.feature.Extension;
 import org.apache.sling.feature.Feature;
 import org.apache.sling.feature.builder.HandlerContext;
 import org.apache.sling.feature.builder.MergeHandler;
 
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
 import java.io.StringReader;
 import java.io.StringWriter;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 import javax.json.Json;
 import javax.json.JsonArray;
@@ -54,6 +63,8 @@
         if (targetEx != null && !targetEx.getName().equals(API_REGIONS_NAME))
             return;
 
+        storeBundleOrigins(context, source, target);
+
         JsonReader srcJR = Json.createReader(new StringReader(sourceEx.getJSON()));
         JsonArray srcJA = srcJR.readArray();
 
@@ -83,6 +94,8 @@
             srcRegions.put(regionName, region);
         }
 
+        storeRegionOrigins(context, source, target, srcRegions.keySet());
+
         JsonArray tgtJA;
         if (targetEx != null) {
             JsonReader tgtJR = Json.createReader(new StringReader(targetEx.getJSON()));
@@ -153,6 +166,71 @@
         targetEx.setJSON(sw.toString());
     }
 
+    private void storeRegionOrigins(HandlerContext context, Feature source, Feature target, Set<String> regions) {
+        try {
+            File f = getFeatureDataFile(context, target, "regionOrigins.properties");
+
+            Properties p = new Properties();
+            if (f.isFile()) {
+                try (FileInputStream fis = new FileInputStream(f)) {
+                    p.load(fis);
+                }
+            }
+
+            String fid = source.getId().toMvnId();
+            p.put(fid, regions.stream().collect(Collectors.joining(",")));
+
+            try (FileOutputStream fos = new FileOutputStream(f)) {
+                p.store(fos, "Mapping from feature ID to regions that the feature is a member of");
+            }
+        } catch (IOException e) {
+            throw new IllegalStateException("Problem storing region origin information", e);
+        }
+    }
+
+    private void storeBundleOrigins(HandlerContext context, Feature source, Feature target) {
+        try {
+            File f = getFeatureDataFile(context, target, "bundleOrigins.properties");
+
+            String featureId = source.getId().toMvnId();
+            Properties p = new Properties();
+            if (f.isFile()) {
+                try (FileInputStream fis = new FileInputStream(f)) {
+                    p.load(fis);
+                }
+            }
+
+            for (Artifact b : source.getBundles()) {
+                String bundleId = b.getId().toMvnId();
+                String org = p.getProperty(bundleId);
+                String newVal;
+                if (org != null) {
+                    List<String> l = Arrays.asList(org.split(","));
+                    if (!l.contains(featureId))
+                        newVal = org + "," + featureId;
+                    else
+                        newVal = org;
+                } else {
+                    newVal = featureId;
+                }
+                p.setProperty(bundleId, newVal);
+            }
+
+            try (FileOutputStream fos = new FileOutputStream(f)) {
+                p.store(fos, "Mapping from bundle artifact IDs to features that contained the bundle.");
+            }
+        } catch (IOException e) {
+            throw new IllegalStateException("Problem storing bundle origin information", e);
+        }
+    }
+
+    private File getFeatureDataFile(HandlerContext context, Feature target, String fileName) throws IOException {
+        String featureName = target.getId().toMvnId().replaceAll("[^a-zA-Z0-9\\.\\-]", "_");
+        File f = AbstractHandler.getDataFile(context, featureName, fileName);
+        f.getParentFile().mkdirs();
+        return f;
+    }
+
     private static List<String> readJsonArray(JsonArray jsonArray) {
         List<String> l = new ArrayList<>();
         for (JsonValue jv : jsonArray) {
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/AbstractHandler.java b/src/main/java/org/apache/sling/feature/extension/apiregions/AbstractHandler.java
index a67ceb6..356e48a 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/AbstractHandler.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/AbstractHandler.java
@@ -37,24 +37,36 @@
     static final String NAME_KEY = "name";
     static final String EXPORTS_KEY = "exports";
 
-    private static final String FILE_PREFIX = "apiregions.";
-    private static final String FILE_STORAGE_DIR_KEY = "fileStorage";
+    static final String FILE_PREFIX = "apiregions.";
+    static final String FILE_STORAGE_DIR_KEY = "fileStorage";
 
-    protected File getDataFile(HandlerContext context, String name) throws IOException {
+    protected static File getDataFile(HandlerContext context, String directory, String name) throws IOException {
         String stg = context.getConfiguration().get(FILE_STORAGE_DIR_KEY);
-        Path p;
+        File f;
         if (stg != null) {
-            p = new File(stg, name).toPath();
+            File dir;
+            if (directory != null) {
+                dir = new File(stg, directory);
+                dir.mkdirs();
+            } else {
+                dir = new File(stg);
+            }
+            f = new File(dir, name);
         } else {
-            p = Files.createTempFile(FILE_PREFIX, name);
+            // If we store in the temp space we don't use the directory
+            Path p = Files.createTempFile(FILE_PREFIX, name);
+            f = p.toFile();
+            f.deleteOnExit();
         }
-        File f = p.toFile();
-        f.deleteOnExit();
 
         System.setProperty(FILE_PREFIX + name, f.getCanonicalPath());
         return f;
     }
 
+    protected static File getDataFile(HandlerContext context, String name) throws IOException {
+        return getDataFile(context, null, name);
+    }
+
     protected Properties loadProperties(File file) throws IOException, FileNotFoundException {
         Properties map = new Properties();
         if (file.exists()) {
diff --git a/src/main/java/org/apache/sling/feature/extension/apiregions/BundleArtifactFeatureHandler.java b/src/main/java/org/apache/sling/feature/extension/apiregions/BundleArtifactFeatureHandler.java
index a8b9052..9bd4182 100644
--- a/src/main/java/org/apache/sling/feature/extension/apiregions/BundleArtifactFeatureHandler.java
+++ b/src/main/java/org/apache/sling/feature/extension/apiregions/BundleArtifactFeatureHandler.java
@@ -103,9 +103,11 @@
                 if (packages != null) {
                     packageSet.addAll(Arrays.asList(packages.split(",")));
                 }
-                JsonArray eja = jo.getJsonArray(EXPORTS_KEY);
-                for (int i=0; i < eja.size(); i++) {
-                    packageSet.add(eja.getString(i));
+                if (jo.containsKey(EXPORTS_KEY)) {
+                    JsonArray eja = jo.getJsonArray(EXPORTS_KEY);
+                    for (int i=0; i < eja.size(); i++) {
+                        packageSet.add(eja.getString(i));
+                    }
                 }
                 rpMap.put(region, packageSet.stream().collect(Collectors.joining(",")));
             }
diff --git a/src/test/java/org/apache/sling/feature/extension/apiregions/APIRegionMergeHandlerTest.java b/src/test/java/org/apache/sling/feature/extension/apiregions/APIRegionMergeHandlerTest.java
index 488df88..fa965e8 100644
--- a/src/test/java/org/apache/sling/feature/extension/apiregions/APIRegionMergeHandlerTest.java
+++ b/src/test/java/org/apache/sling/feature/extension/apiregions/APIRegionMergeHandlerTest.java
@@ -16,13 +16,26 @@
  */
 package org.apache.sling.feature.extension.apiregions;
 
+import org.apache.sling.feature.Artifact;
 import org.apache.sling.feature.ArtifactId;
 import org.apache.sling.feature.Extension;
 import org.apache.sling.feature.ExtensionType;
 import org.apache.sling.feature.Feature;
+import org.apache.sling.feature.builder.HandlerContext;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
+import org.mockito.Mockito;
 
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
 import java.io.StringReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Properties;
 
 import javax.json.Json;
 import javax.json.JsonArray;
@@ -33,6 +46,22 @@
 import static org.junit.Assert.assertTrue;
 
 public class APIRegionMergeHandlerTest {
+    private Path tempDir;
+
+    @Before
+    public void setUp() throws IOException {
+        tempDir = Files.createTempDirectory(getClass().getSimpleName());
+    }
+
+    @After
+    public void tearDown() throws IOException {
+        // Delete the temp dir again
+        Files.walk(tempDir)
+            .sorted(Comparator.reverseOrder())
+            .map(Path::toFile)
+            .forEach(File::delete);
+    }
+
     @Test
     public void testCanMerge() {
         APIRegionMergeHandler armh = new APIRegionMergeHandler();
@@ -63,7 +92,8 @@
                 + "\"exports\": [\"a.ha\"],"
                 + "\"my-key\": \"my-val\"}]");
 
-        armh.merge(null, tf, sf, tgEx, srEx);
+        HandlerContext hc = Mockito.mock(HandlerContext.class);
+        armh.merge(hc, tf, sf, tgEx, srEx);
 
         String expectedJSON = "[{\"name\":\"global\","
                 + "\"exports\": [\"a.b.c\",\"d.e.f\", \"test\"]},"
@@ -83,7 +113,7 @@
 
 
     @Test
-    public void testRegionExportsInheritance() throws Exception {
+    public void testRegionExportsNoInheritance() throws Exception {
         APIRegionMergeHandler armh = new APIRegionMergeHandler();
 
         Feature tf = new Feature(ArtifactId.fromMvnId("x:t:1"));
@@ -99,7 +129,8 @@
                 + "{\"name\":\"forbidden\","
                 + "\"exports\":[\"abc\",\"klm\"]}]");
 
-        armh.merge(null, tf, sf, null, srEx);
+        HandlerContext hc = Mockito.mock(HandlerContext.class);
+        armh.merge(hc, tf, sf, null, srEx);
 
         Extension tgEx = tf.getExtensions().iterator().next();
 
@@ -114,4 +145,92 @@
 
         assertEquals(ea, aa);
     }
+
+    @Test
+    public void testStoreBundleOrigins() throws Exception {
+        HandlerContext hc = Mockito.mock(HandlerContext.class);
+        Mockito.when(hc.getConfiguration()).thenReturn(
+                Collections.singletonMap(AbstractHandler.FILE_STORAGE_DIR_KEY,
+                        tempDir.toString()));
+
+        APIRegionMergeHandler armh = new APIRegionMergeHandler();
+
+        Feature tf = new Feature(ArtifactId.fromMvnId("g:t:1"));
+        Feature sf1 = new Feature(ArtifactId.fromMvnId("g:s1:1"));
+        Extension sf1Ex = new Extension(ExtensionType.JSON, "api-regions", false);
+        sf1Ex.setJSON("[]");
+
+        sf1.getBundles().add(new Artifact(ArtifactId.fromMvnId("a:b1:1")));
+        sf1.getBundles().add(new Artifact(ArtifactId.fromMvnId("a:b2:1")));
+
+        armh.merge(hc, tf, sf1, null, sf1Ex);
+
+        Feature sf2 = new Feature(ArtifactId.fromMvnId("g:s2:1"));
+        Extension sf2Ex = new Extension(ExtensionType.JSON, "api-regions", false);
+        sf2Ex.setJSON("[]");
+
+        sf2.getBundles().add(new Artifact(ArtifactId.fromMvnId("a:b2:1")));
+        sf2.getBundles().add(new Artifact(ArtifactId.fromMvnId("a:b3:1")));
+        sf2.getBundles().add(new Artifact(ArtifactId.fromMvnId("a:b2:1")));
+
+        armh.merge(hc, tf, sf2, tf.getExtensions().getByName("api-regions"), sf2Ex);
+
+        Feature sf3 = new Feature(ArtifactId.fromMvnId("g:s3:1"));
+        Extension sf3Ex = new Extension(ExtensionType.JSON, "api-regions", false);
+        sf3Ex.setJSON("[]");
+
+        sf3.getBundles().add(new Artifact(ArtifactId.fromMvnId("a:b2:1")));
+
+        armh.merge(hc, tf, sf3, tf.getExtensions().getByName("api-regions"), sf3Ex);
+
+        Properties bo = new Properties();
+        bo.load(new FileInputStream(new File(tempDir.toFile(), "g_t_1/bundleOrigins.properties")));
+        assertEquals(3, bo.size());
+
+        assertEquals("g:s1:1", bo.get("a:b1:1"));
+        assertEquals("g:s1:1,g:s2:1,g:s3:1", bo.get("a:b2:1"));
+        assertEquals("g:s2:1", bo.get("a:b3:1"));
+    }
+
+    @Test
+    public void testStoreRegionOrigins() throws Exception {
+        HandlerContext hc = Mockito.mock(HandlerContext.class);
+        Mockito.when(hc.getConfiguration()).thenReturn(
+                Collections.singletonMap(AbstractHandler.FILE_STORAGE_DIR_KEY,
+                        tempDir.toString()));
+
+        APIRegionMergeHandler armh = new APIRegionMergeHandler();
+
+        Feature tf = new Feature(ArtifactId.fromMvnId("x:t:1"));
+        Feature sf1 = new Feature(ArtifactId.fromMvnId("y:s:2"));
+
+        Extension sr1Ex = new Extension(ExtensionType.JSON, "api-regions", false);
+        sr1Ex.setJSON("[{\"name\":\"global\","
+                + "\"exports\": [\"a.b.c\",\"d.e.f\"]},"
+                + "{\"name\":\"deprecated\","
+                + "\"exports\":[\"klm\",\"#ignored\",\"qrs\"]},"
+                + "{\"name\":\"internal\","
+                + "\"exports\":[\"xyz\"]},"
+                + "{\"name\":\"forbidden\","
+                + "\"exports\":[\"abc\",\"klm\"]}]");
+
+        armh.merge(hc, tf, sf1, null, sr1Ex);
+
+        Feature sf2 = new Feature(ArtifactId.fromMvnId("z:s:1"));
+
+        Extension sr2Ex = new Extension(ExtensionType.JSON, "api-regions", false);
+        sr2Ex.setJSON("[{\"name\":\"global\","
+                + "\"exports\": [\"g.h.i\"]},"
+                + "{\"name\":\"internal\","
+                + "\"exports\":[]},"
+                + "{\"name\":\"somethingelse\","
+                + "\"exports\":[\"qqq\"]}]");
+        armh.merge(hc, tf, sf2, tf.getExtensions().getByName("api-regions"), sr2Ex);
+
+        Properties ro = new Properties();
+        ro.load(new FileInputStream(new File(tempDir.toFile(), "x_t_1/regionOrigins.properties")));
+        assertEquals(2, ro.size());
+        assertEquals("global,deprecated,internal,forbidden", ro.get("y:s:2"));
+        assertEquals("global,internal,somethingelse", ro.get("z:s:1"));
+    }
 }