diff --git a/bindings/java/java/src/main/java/com/sensmetry/sysand/Sysand.java b/bindings/java/java/src/main/java/com/sensmetry/sysand/Sysand.java
index 66dd58401..0d1febd6a 100644
--- a/bindings/java/java/src/main/java/com/sensmetry/sysand/Sysand.java
+++ b/bindings/java/java/src/main/java/com/sensmetry/sysand/Sysand.java
@@ -194,8 +194,12 @@ public static void setProjectIndex(java.nio.file.Path projectPath, java.util.Lin
*
* @param outputPath The path to the output file.
* @param projectPath The path to the project.
+ * @param compression The compression method.
+ * @param buildTag Optional pre-release build tag appended to the version
+ * (e.g. {@code "42"} turns {@code 1.2.3} into {@code 1.2.3-dev.42}).
+ * Pass {@code null} to leave the version unchanged.
*/
- private static native void buildProject(String outputPath, String projectPath, String compression)
+ private static native void buildProject(String outputPath, String projectPath, String compression, String buildTag)
throws com.sensmetry.sysand.exceptions.SysandException;
/**
@@ -204,31 +208,73 @@ private static native void buildProject(String outputPath, String projectPath, S
*
* @param outputPath The path to the output file.
* @param projectPath The path to the project.
+ * @param compression The compression method.
*/
public static void buildProject(java.nio.file.Path outputPath, java.nio.file.Path projectPath, com.sensmetry.sysand.model.CompressionMethod compression)
throws com.sensmetry.sysand.exceptions.SysandException {
- buildProject(outputPath.toString(), projectPath.toString(), compression.toString());
+ buildProject(outputPath.toString(), projectPath.toString(), compression.toString(), null);
}
/**
- * Build Model Project Interchange file (.kpar) from the workspace at the given
+ * Build Model Project Interchange file (.kpar) from the project at the given
* path.
*
+ *
Experimental: This API is subject to change in future releases.
+ *
* @param outputPath The path to the output file.
+ * @param projectPath The path to the project.
+ * @param compression The compression method.
+ * @param buildTag Pre-release build tag appended to the version
+ * (e.g. {@code "42"} turns {@code 1.2.3} into {@code 1.2.3-dev.42}).
+ */
+ public static void buildProject(java.nio.file.Path outputPath, java.nio.file.Path projectPath, com.sensmetry.sysand.model.CompressionMethod compression, String buildTag)
+ throws com.sensmetry.sysand.exceptions.SysandException {
+ buildProject(outputPath.toString(), projectPath.toString(), compression.toString(), buildTag);
+ }
+
+ /**
+ * Build Model Project Interchange file (.kpar) from the workspace at the given
+ * path.
+ *
+ * @param outputPath The path to the output directory.
* @param workspacePath The path to the workspace.
+ * @param compression The compression method.
+ * @param buildTag Optional pre-release build tag appended to each project's version
+ * (e.g. {@code "42"} turns {@code 1.2.3} into {@code 1.2.3-dev.42}).
+ * {@code versionConstraint} fields that exactly pin a sibling workspace
+ * project's version are updated to include the tag as well.
+ * Pass {@code null} to leave versions unchanged.
*/
- private static native void buildWorkspace(String outputPath, String workspacePath, String compression)
+ private static native void buildWorkspace(String outputPath, String workspacePath, String compression, String buildTag)
throws com.sensmetry.sysand.exceptions.SysandException;
/**
* Build Model Project Interchange file (.kpar) from the workspace at the given
* path.
*
- * @param outputPath The path to the output file.
+ * @param outputPath The path to the output directory.
* @param workspacePath The path to the workspace.
+ * @param compression The compression method.
*/
public static void buildWorkspace(java.nio.file.Path outputPath, java.nio.file.Path workspacePath, com.sensmetry.sysand.model.CompressionMethod compression)
throws com.sensmetry.sysand.exceptions.SysandException {
- buildWorkspace(outputPath.toString(), workspacePath.toString(), compression.toString());
+ buildWorkspace(outputPath.toString(), workspacePath.toString(), compression.toString(), null);
+ }
+
+ /**
+ * Build Model Project Interchange file (.kpar) from the workspace at the given
+ * path.
+ *
+ *
Experimental: This API is subject to change in future releases.
+ *
+ * @param outputPath The path to the output directory.
+ * @param workspacePath The path to the workspace.
+ * @param compression The compression method.
+ * @param buildTag Pre-release build tag appended to each project's version
+ * (e.g. {@code "42"} turns {@code 1.2.3} into {@code 1.2.3-dev.42}).
+ */
+ public static void buildWorkspace(java.nio.file.Path outputPath, java.nio.file.Path workspacePath, com.sensmetry.sysand.model.CompressionMethod compression, String buildTag)
+ throws com.sensmetry.sysand.exceptions.SysandException {
+ buildWorkspace(outputPath.toString(), workspacePath.toString(), compression.toString(), buildTag);
}
}
diff --git a/bindings/java/plugin/pom.xml b/bindings/java/plugin/pom.xml
index c6fd2be77..c6136d336 100644
--- a/bindings/java/plugin/pom.xml
+++ b/bindings/java/plugin/pom.xml
@@ -113,6 +113,7 @@ SPDX-FileCopyrightText: © 2025 Sysand contributors
true
true
true
+ verify
diff --git a/bindings/java/plugin/src/it/workspace-build-tag-env-set/.workspace.json b/bindings/java/plugin/src/it/workspace-build-tag-env-set/.workspace.json
new file mode 100644
index 000000000..5982d9bf9
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag-env-set/.workspace.json
@@ -0,0 +1,8 @@
+{
+ "projects": [
+ {
+ "path": "project1",
+ "iris": ["urn:kpar:project1"]
+ }
+ ]
+}
diff --git a/bindings/java/plugin/src/it/workspace-build-tag-env-set/invoker.properties b/bindings/java/plugin/src/it/workspace-build-tag-env-set/invoker.properties
new file mode 100644
index 000000000..c1c488661
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag-env-set/invoker.properties
@@ -0,0 +1,3 @@
+invoker.goals = -B -V package
+invoker.buildResult = success
+invoker.environmentVariables.BUILD_TAG = 99
diff --git a/bindings/java/plugin/src/it/workspace-build-tag-env-set/pom.xml b/bindings/java/plugin/src/it/workspace-build-tag-env-set/pom.xml
new file mode 100644
index 000000000..f12bde595
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag-env-set/pom.xml
@@ -0,0 +1,66 @@
+
+
+
+ 4.0.0
+ com.sensmetry.sysand.it
+ workspace-build-tag-env-set-project
+ 1.0.0
+ jar
+ workspace-build-tag-env-set-project
+
+ 1.8
+ 1.8
+ UTF-8
+
+
+
+
+
+ java-8-api
+
+ [9,)
+
+
+ 8
+
+
+
+ build-tag-from-env
+
+
+ env.BUILD_TAG
+
+
+
+ ${env.BUILD_TAG}
+
+
+
+
+
+
+ com.sensmetry
+ sysand-maven-plugin
+ @project.version@
+
+
+
+ build-kpar
+
+
+
+
+ ${project.basedir}
+ ${project.basedir}/output
+ ${sysand.buildTag}
+
+
+
+
+
diff --git a/bindings/java/plugin/src/it/workspace-metamodel/project1/.meta.json b/bindings/java/plugin/src/it/workspace-build-tag-env-set/project1/.meta.json
similarity index 100%
rename from bindings/java/plugin/src/it/workspace-metamodel/project1/.meta.json
rename to bindings/java/plugin/src/it/workspace-build-tag-env-set/project1/.meta.json
diff --git a/bindings/java/plugin/src/it/workspace-metamodel/project1/.project.json b/bindings/java/plugin/src/it/workspace-build-tag-env-set/project1/.project.json
similarity index 64%
rename from bindings/java/plugin/src/it/workspace-metamodel/project1/.project.json
rename to bindings/java/plugin/src/it/workspace-build-tag-env-set/project1/.project.json
index 5e527fff1..4489abeaa 100644
--- a/bindings/java/plugin/src/it/workspace-metamodel/project1/.project.json
+++ b/bindings/java/plugin/src/it/workspace-build-tag-env-set/project1/.project.json
@@ -1,5 +1,5 @@
{
"name": "project1",
- "version": "0.0.1",
+ "version": "1.0.0",
"usage": []
}
diff --git a/bindings/java/plugin/src/it/workspace-metamodel/project1/a.sysml b/bindings/java/plugin/src/it/workspace-build-tag-env-set/project1/a.sysml
similarity index 100%
rename from bindings/java/plugin/src/it/workspace-metamodel/project1/a.sysml
rename to bindings/java/plugin/src/it/workspace-build-tag-env-set/project1/a.sysml
diff --git a/bindings/java/plugin/src/it/workspace-build-tag-env-set/verify.groovy b/bindings/java/plugin/src/it/workspace-build-tag-env-set/verify.groovy
new file mode 100644
index 000000000..2a4e3df95
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag-env-set/verify.groovy
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// SPDX-FileCopyrightText: © 2026 Sysand contributors
+
+import java.util.zip.ZipFile
+import groovy.json.JsonSlurper
+
+def kparFile = new File(basedir, "output/project1-1.0.0-dev.99.kpar")
+assert kparFile.exists() : "Expected tagged kpar not found: ${kparFile}"
+
+def zip = new ZipFile(kparFile)
+try {
+ def infoEntry = zip.getEntry(".project.json")
+ assert infoEntry != null : ".project.json entry not found in kpar"
+
+ def infoJson = new JsonSlurper().parse(zip.getInputStream(infoEntry))
+ assert infoJson.version == "1.0.0-dev.99" :
+ "Expected version '1.0.0-dev.99' in kpar but got '${infoJson.version}'"
+} finally {
+ zip.close()
+}
+
+// Source .project.json must not have been modified by the build
+def sourceInfo = new JsonSlurper().parse(new File(basedir, "project1/.project.json"))
+assert sourceInfo.version == "1.0.0" :
+ "Source .project.json was unexpectedly modified: version is '${sourceInfo.version}'"
diff --git a/bindings/java/plugin/src/it/workspace-build-tag-env-unset/.workspace.json b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/.workspace.json
new file mode 100644
index 000000000..5982d9bf9
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/.workspace.json
@@ -0,0 +1,8 @@
+{
+ "projects": [
+ {
+ "path": "project1",
+ "iris": ["urn:kpar:project1"]
+ }
+ ]
+}
diff --git a/bindings/java/plugin/src/it/workspace-metamodel/invoker.properties b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/invoker.properties
similarity index 100%
rename from bindings/java/plugin/src/it/workspace-metamodel/invoker.properties
rename to bindings/java/plugin/src/it/workspace-build-tag-env-unset/invoker.properties
diff --git a/bindings/java/plugin/src/it/workspace-build-tag-env-unset/pom.xml b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/pom.xml
new file mode 100644
index 000000000..323c54c86
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/pom.xml
@@ -0,0 +1,66 @@
+
+
+
+ 4.0.0
+ com.sensmetry.sysand.it
+ workspace-build-tag-env-unset-project
+ 1.0.0
+ jar
+ workspace-build-tag-env-unset-project
+
+ 1.8
+ 1.8
+ UTF-8
+
+
+
+
+
+ java-8-api
+
+ [9,)
+
+
+ 8
+
+
+
+ build-tag-from-env
+
+
+ env.BUILD_TAG
+
+
+
+ ${env.BUILD_TAG}
+
+
+
+
+
+
+ com.sensmetry
+ sysand-maven-plugin
+ @project.version@
+
+
+
+ build-kpar
+
+
+
+
+ ${project.basedir}
+ ${project.basedir}/output
+ ${sysand.buildTag}
+
+
+
+
+
diff --git a/bindings/java/plugin/src/it/workspace-build-tag-env-unset/project1/.meta.json b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/project1/.meta.json
new file mode 100644
index 000000000..7c5ff8dc1
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/project1/.meta.json
@@ -0,0 +1,12 @@
+{
+ "index": {
+ "A": "a.sysml"
+ },
+ "created": "2025-10-31T17:01:00.414506000Z",
+ "checksum": {
+ "a.sysml": {
+ "value": "",
+ "algorithm": "NONE"
+ }
+ }
+}
diff --git a/bindings/java/plugin/src/it/workspace-build-tag-env-unset/project1/.project.json b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/project1/.project.json
new file mode 100644
index 000000000..4489abeaa
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/project1/.project.json
@@ -0,0 +1,5 @@
+{
+ "name": "project1",
+ "version": "1.0.0",
+ "usage": []
+}
diff --git a/bindings/java/plugin/src/it/workspace-build-tag-env-unset/project1/a.sysml b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/project1/a.sysml
new file mode 100644
index 000000000..9e9204faf
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/project1/a.sysml
@@ -0,0 +1 @@
+package A;
diff --git a/bindings/java/plugin/src/it/workspace-build-tag-env-unset/verify.groovy b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/verify.groovy
new file mode 100644
index 000000000..faf8c8f0d
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag-env-unset/verify.groovy
@@ -0,0 +1,11 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// SPDX-FileCopyrightText: © 2026 Sysand contributors
+
+import groovy.json.JsonSlurper
+
+// Without BUILD_TAG env var, only the untagged kpar should be produced
+def taggedKpar = new File(basedir, "output/project1-1.0.0-dev.99.kpar")
+assert !taggedKpar.exists() : "Unexpected tagged kpar found: ${taggedKpar}"
+
+def kparFile = new File(basedir, "output/project1-1.0.0.kpar")
+assert kparFile.exists() : "Expected untagged kpar not found: ${kparFile}"
diff --git a/bindings/java/plugin/src/it/workspace-build-tag/.workspace.json b/bindings/java/plugin/src/it/workspace-build-tag/.workspace.json
new file mode 100644
index 000000000..5982d9bf9
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag/.workspace.json
@@ -0,0 +1,8 @@
+{
+ "projects": [
+ {
+ "path": "project1",
+ "iris": ["urn:kpar:project1"]
+ }
+ ]
+}
diff --git a/bindings/java/plugin/src/it/workspace-build-tag/invoker.properties b/bindings/java/plugin/src/it/workspace-build-tag/invoker.properties
new file mode 100644
index 000000000..dd2e5301b
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag/invoker.properties
@@ -0,0 +1,2 @@
+invoker.goals = -B -V package
+invoker.buildResult = success
diff --git a/bindings/java/plugin/src/it/workspace-build-tag/pom.xml b/bindings/java/plugin/src/it/workspace-build-tag/pom.xml
new file mode 100644
index 000000000..9831685ab
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag/pom.xml
@@ -0,0 +1,54 @@
+
+
+
+ 4.0.0
+ com.sensmetry.sysand.it
+ workspace-build-tag-project
+ 1.0.0
+ jar
+ workspace-build-tag-project
+
+ 1.8
+ 1.8
+ UTF-8
+
+
+
+
+ java-8-api
+
+ [9,)
+
+
+ 8
+
+
+
+
+
+
+ com.sensmetry
+ sysand-maven-plugin
+ @project.version@
+
+
+
+ build-kpar
+
+
+
+
+ ${project.basedir}
+ ${project.basedir}/output
+ 42
+
+
+
+
+
diff --git a/bindings/java/plugin/src/it/workspace-build-tag/project1/.meta.json b/bindings/java/plugin/src/it/workspace-build-tag/project1/.meta.json
new file mode 100644
index 000000000..7c5ff8dc1
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag/project1/.meta.json
@@ -0,0 +1,12 @@
+{
+ "index": {
+ "A": "a.sysml"
+ },
+ "created": "2025-10-31T17:01:00.414506000Z",
+ "checksum": {
+ "a.sysml": {
+ "value": "",
+ "algorithm": "NONE"
+ }
+ }
+}
diff --git a/bindings/java/plugin/src/it/workspace-build-tag/project1/.project.json b/bindings/java/plugin/src/it/workspace-build-tag/project1/.project.json
new file mode 100644
index 000000000..4489abeaa
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag/project1/.project.json
@@ -0,0 +1,5 @@
+{
+ "name": "project1",
+ "version": "1.0.0",
+ "usage": []
+}
diff --git a/bindings/java/plugin/src/it/workspace-build-tag/project1/a.sysml b/bindings/java/plugin/src/it/workspace-build-tag/project1/a.sysml
new file mode 100644
index 000000000..9e9204faf
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag/project1/a.sysml
@@ -0,0 +1 @@
+package A;
diff --git a/bindings/java/plugin/src/it/workspace-build-tag/verify.groovy b/bindings/java/plugin/src/it/workspace-build-tag/verify.groovy
new file mode 100644
index 000000000..9a82fcdd4
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-build-tag/verify.groovy
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// SPDX-FileCopyrightText: © 2026 Sysand contributors
+
+import java.util.zip.ZipFile
+import groovy.json.JsonSlurper
+
+def kparFile = new File(basedir, "output/project1-1.0.0-dev.42.kpar")
+assert kparFile.exists() : "Expected tagged kpar not found: ${kparFile}"
+
+def zip = new ZipFile(kparFile)
+try {
+ def infoEntry = zip.getEntry(".project.json")
+ assert infoEntry != null : ".project.json entry not found in kpar"
+
+ def infoJson = new JsonSlurper().parse(zip.getInputStream(infoEntry))
+ assert infoJson.version == "1.0.0-dev.42" :
+ "Expected version '1.0.0-dev.42' in kpar but got '${infoJson.version}'"
+} finally {
+ zip.close()
+}
+
+// Source .project.json must not have been modified by the build
+def sourceInfo = new JsonSlurper().parse(new File(basedir, "project1/.project.json"))
+assert sourceInfo.version == "1.0.0" :
+ "Source .project.json was unexpectedly modified: version is '${sourceInfo.version}'"
diff --git a/bindings/java/plugin/src/it/workspace-inherit-group/.workspace.json b/bindings/java/plugin/src/it/workspace-inherit-group/.workspace.json
new file mode 100644
index 000000000..f5caf7060
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-inherit-group/.workspace.json
@@ -0,0 +1,18 @@
+{
+ "projects": [
+ {
+ "path": "project1",
+ "iris": ["urn:kpar:project1"]
+ }
+ ],
+ "presets": {
+ "kerml": {
+ "project": {
+ "version": "1.0.0"
+ },
+ "meta": {
+ "metamodel": "https://www.omg.org/spec/KerML/20250201"
+ }
+ }
+ }
+}
diff --git a/bindings/java/plugin/src/it/workspace-inherit-group/invoker.properties b/bindings/java/plugin/src/it/workspace-inherit-group/invoker.properties
new file mode 100644
index 000000000..dd2e5301b
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-inherit-group/invoker.properties
@@ -0,0 +1,2 @@
+invoker.goals = -B -V package
+invoker.buildResult = success
diff --git a/bindings/java/plugin/src/it/workspace-metamodel/pom.xml b/bindings/java/plugin/src/it/workspace-inherit-group/pom.xml
similarity index 93%
rename from bindings/java/plugin/src/it/workspace-metamodel/pom.xml
rename to bindings/java/plugin/src/it/workspace-inherit-group/pom.xml
index 6fdb0145b..0ed238e5d 100644
--- a/bindings/java/plugin/src/it/workspace-metamodel/pom.xml
+++ b/bindings/java/plugin/src/it/workspace-inherit-group/pom.xml
@@ -8,10 +8,10 @@ SPDX-FileCopyrightText: © 2025 Sysand contributors
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
4.0.0
com.sensmetry.sysand.it
- workspace-metamodel-project
+ workspace-inherit-group-project
1.0.0
jar
- workspace-metamodel-project
+ workspace-inherit-group-project
1.8
1.8
diff --git a/bindings/java/plugin/src/it/workspace-inherit-group/project1/.meta.json b/bindings/java/plugin/src/it/workspace-inherit-group/project1/.meta.json
new file mode 100644
index 000000000..dec982cf4
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-inherit-group/project1/.meta.json
@@ -0,0 +1,13 @@
+{
+ "index": {
+ "A": "a.sysml"
+ },
+ "created": "2025-10-31T17:01:00.414506000Z",
+ "metamodel": { "preset": "kerml" },
+ "checksum": {
+ "a.sysml": {
+ "value": "",
+ "algorithm": "NONE"
+ }
+ }
+}
diff --git a/bindings/java/plugin/src/it/workspace-inherit-group/project1/.project.json b/bindings/java/plugin/src/it/workspace-inherit-group/project1/.project.json
new file mode 100644
index 000000000..a97d00525
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-inherit-group/project1/.project.json
@@ -0,0 +1,5 @@
+{
+ "name": "project1",
+ "version": { "preset": "kerml" },
+ "usage": []
+}
diff --git a/bindings/java/plugin/src/it/workspace-inherit-group/project1/a.sysml b/bindings/java/plugin/src/it/workspace-inherit-group/project1/a.sysml
new file mode 100644
index 000000000..9e9204faf
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-inherit-group/project1/a.sysml
@@ -0,0 +1 @@
+package A;
diff --git a/bindings/java/plugin/src/it/workspace-metamodel/verify.groovy b/bindings/java/plugin/src/it/workspace-inherit-group/verify.groovy
similarity index 53%
rename from bindings/java/plugin/src/it/workspace-metamodel/verify.groovy
rename to bindings/java/plugin/src/it/workspace-inherit-group/verify.groovy
index 9cc0f28c9..504a62f83 100644
--- a/bindings/java/plugin/src/it/workspace-metamodel/verify.groovy
+++ b/bindings/java/plugin/src/it/workspace-inherit-group/verify.groovy
@@ -4,17 +4,24 @@
import java.util.zip.ZipFile
import groovy.json.JsonSlurper
-def kparFile = new File(basedir, "output/project1-0.0.1.kpar")
+def kparFile = new File(basedir, "output/project1-1.0.0.kpar")
assert kparFile.exists() : "Expected kpar file not found: ${kparFile}"
def zip = new ZipFile(kparFile)
try {
+ def infoEntry = zip.getEntry(".project.json")
+ assert infoEntry != null : ".project.json entry not found in kpar"
+
+ def infoJson = new JsonSlurper().parse(zip.getInputStream(infoEntry))
+ assert infoJson.version == "1.0.0" :
+ "Expected version '1.0.0' but got '${infoJson.version}'"
+
def metaEntry = zip.getEntry(".meta.json")
assert metaEntry != null : ".meta.json entry not found in kpar"
def metaJson = new JsonSlurper().parse(zip.getInputStream(metaEntry))
- assert metaJson.metamodel == "https://www.omg.org/spec/SysML/20250201" :
- "Expected metamodel 'https://www.omg.org/spec/SysML/20250201' but got '${metaJson.metamodel}'"
+ assert metaJson.metamodel == "https://www.omg.org/spec/KerML/20250201" :
+ "Expected metamodel 'https://www.omg.org/spec/KerML/20250201' but got '${metaJson.metamodel}'"
} finally {
zip.close()
}
diff --git a/bindings/java/plugin/src/it/workspace-metamodel/.workspace.json b/bindings/java/plugin/src/it/workspace-inherit-version/.workspace.json
similarity index 59%
rename from bindings/java/plugin/src/it/workspace-metamodel/.workspace.json
rename to bindings/java/plugin/src/it/workspace-inherit-version/.workspace.json
index faab16b2e..542268e26 100644
--- a/bindings/java/plugin/src/it/workspace-metamodel/.workspace.json
+++ b/bindings/java/plugin/src/it/workspace-inherit-version/.workspace.json
@@ -5,7 +5,7 @@
"iris": ["urn:kpar:project1"]
}
],
- "meta": {
- "metamodel": "https://www.omg.org/spec/SysML/20250201"
+ "project": {
+ "version": "2.0.0"
}
}
diff --git a/bindings/java/plugin/src/it/workspace-inherit-version/invoker.properties b/bindings/java/plugin/src/it/workspace-inherit-version/invoker.properties
new file mode 100644
index 000000000..dd2e5301b
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-inherit-version/invoker.properties
@@ -0,0 +1,2 @@
+invoker.goals = -B -V package
+invoker.buildResult = success
diff --git a/bindings/java/plugin/src/it/workspace-inherit-version/pom.xml b/bindings/java/plugin/src/it/workspace-inherit-version/pom.xml
new file mode 100644
index 000000000..b97d06074
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-inherit-version/pom.xml
@@ -0,0 +1,53 @@
+
+
+
+ 4.0.0
+ com.sensmetry.sysand.it
+ workspace-inherit-version-project
+ 1.0.0
+ jar
+ workspace-inherit-version-project
+
+ 1.8
+ 1.8
+ UTF-8
+
+
+
+
+ java-8-api
+
+ [9,)
+
+
+ 8
+
+
+
+
+
+
+ com.sensmetry
+ sysand-maven-plugin
+ @project.version@
+
+
+
+ build-kpar
+
+
+
+
+ ${project.basedir}
+ ${project.basedir}/output
+
+
+
+
+
diff --git a/bindings/java/plugin/src/it/workspace-inherit-version/project1/.meta.json b/bindings/java/plugin/src/it/workspace-inherit-version/project1/.meta.json
new file mode 100644
index 000000000..7c5ff8dc1
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-inherit-version/project1/.meta.json
@@ -0,0 +1,12 @@
+{
+ "index": {
+ "A": "a.sysml"
+ },
+ "created": "2025-10-31T17:01:00.414506000Z",
+ "checksum": {
+ "a.sysml": {
+ "value": "",
+ "algorithm": "NONE"
+ }
+ }
+}
diff --git a/bindings/java/plugin/src/it/workspace-inherit-version/project1/.project.json b/bindings/java/plugin/src/it/workspace-inherit-version/project1/.project.json
new file mode 100644
index 000000000..2a58dfcd4
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-inherit-version/project1/.project.json
@@ -0,0 +1,5 @@
+{
+ "name": "project1",
+ "version": { "preset": "default" },
+ "usage": []
+}
diff --git a/bindings/java/plugin/src/it/workspace-inherit-version/project1/a.sysml b/bindings/java/plugin/src/it/workspace-inherit-version/project1/a.sysml
new file mode 100644
index 000000000..9e9204faf
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-inherit-version/project1/a.sysml
@@ -0,0 +1 @@
+package A;
diff --git a/bindings/java/plugin/src/it/workspace-inherit-version/verify.groovy b/bindings/java/plugin/src/it/workspace-inherit-version/verify.groovy
new file mode 100644
index 000000000..a72e09d75
--- /dev/null
+++ b/bindings/java/plugin/src/it/workspace-inherit-version/verify.groovy
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// SPDX-FileCopyrightText: © 2025 Sysand contributors
+
+import java.util.zip.ZipFile
+import groovy.json.JsonSlurper
+
+def kparFile = new File(basedir, "output/project1-2.0.0.kpar")
+assert kparFile.exists() : "Expected kpar file not found: ${kparFile}"
+
+def zip = new ZipFile(kparFile)
+try {
+ def infoEntry = zip.getEntry(".project.json")
+ assert infoEntry != null : ".project.json entry not found in kpar"
+
+ def infoJson = new JsonSlurper().parse(zip.getInputStream(infoEntry))
+ assert infoJson.version == "2.0.0" :
+ "Expected version '2.0.0' but got '${infoJson.version}'"
+} finally {
+ zip.close()
+}
diff --git a/bindings/java/plugin/src/main/java/org/sysand/maven/SysandBuildKParMojo.java b/bindings/java/plugin/src/main/java/org/sysand/maven/SysandBuildKParMojo.java
index b0fe3e507..9de5dbce0 100644
--- a/bindings/java/plugin/src/main/java/org/sysand/maven/SysandBuildKParMojo.java
+++ b/bindings/java/plugin/src/main/java/org/sysand/maven/SysandBuildKParMojo.java
@@ -41,13 +41,27 @@ public class SysandBuildKParMojo extends AbstractMojo {
private String outputPath;
/**
- * KPAR compression method. Can be configured as
+ * KPAR compression method. Can be configured as
* {@code ... } or
* via {@code -Dsysand.compressionMethod=...}.
*/
@Parameter(property = "sysand.compressionMethod", required = false)
private String compressionMethod;
+ /**
+ * Experimental: This parameter is subject to change in future releases.
+ *
+ * Optional pre-release build tag appended to each built KPAR's version number.
+ * For example, setting {@code 42 } turns version {@code 1.2.3}
+ * into {@code 1.2.3-dev.42}. In workspace builds, {@code versionConstraint} fields
+ * that exactly pin a sibling project's version are updated to include the tag as well.
+ * Can be configured as
+ * {@code ... } or
+ * via {@code -Dsysand.buildTag=...}.
+ */
+ @Parameter(property = "sysand.buildTag", required = false)
+ private String buildTag;
+
@Override
public void execute() throws MojoExecutionException {
if (projectPath == null && workspacePath == null) {
@@ -59,15 +73,16 @@ public void execute() throws MojoExecutionException {
}
CompressionMethod compression = compressionMethod == null ? CompressionMethod.DEFLATED : CompressionMethod.valueOf(compressionMethod.toUpperCase());
+ String effectiveBuildTag = (buildTag == null || buildTag.isEmpty()) ? null : buildTag;
try {
if (workspacePath == null) {
getLog().info("Invoking Sysand.buildProject on: " + projectPath + " to " + outputPath + " with compression " + compressionMethod);
- com.sensmetry.sysand.Sysand.buildProject(Paths.get(outputPath), Paths.get(projectPath), compression);
+ com.sensmetry.sysand.Sysand.buildProject(Paths.get(outputPath), Paths.get(projectPath), compression, effectiveBuildTag);
getLog().info("Sysand.buildProject completed successfully.");
} else {
getLog().info("Invoking Sysand.buildWorkspace on: " + workspacePath + " to " + outputPath + " with compression " + compressionMethod);
- com.sensmetry.sysand.Sysand.buildWorkspace(Paths.get(outputPath), Paths.get(workspacePath), compression);
+ com.sensmetry.sysand.Sysand.buildWorkspace(Paths.get(outputPath), Paths.get(workspacePath), compression, effectiveBuildTag);
getLog().info("Sysand.buildWorkspace completed successfully.");
}
} catch (com.sensmetry.sysand.exceptions.SysandException e) {
diff --git a/bindings/java/src/lib.rs b/bindings/java/src/lib.rs
index 797f4d08e..5938badf9 100644
--- a/bindings/java/src/lib.rs
+++ b/bindings/java/src/lib.rs
@@ -108,6 +108,12 @@ pub extern "system" fn Java_com_sensmetry_sysand_Sysand_init<'local>(
LocalSrcError::MissingMeta => {
env.throw_exception(ExceptionKind::SysandException, suberror.to_string())
}
+ LocalSrcError::WorkspaceInheritance(_) => {
+ env.throw_exception(ExceptionKind::SysandException, suberror.to_string())
+ }
+ LocalSrcError::WorkspaceRead(_) => {
+ env.throw_exception(ExceptionKind::SysandException, suberror.to_string())
+ }
},
},
}
@@ -175,6 +181,12 @@ pub extern "system" fn Java_com_sensmetry_sysand_Sysand_env<'local>(
LocalWriteError::AddProject(subsuberror) => {
env.throw_exception(ExceptionKind::IOError, subsuberror.to_string())
}
+ LocalWriteError::WorkspaceInheritance(_) => {
+ env.throw_exception(ExceptionKind::SysandException, suberror.to_string())
+ }
+ LocalWriteError::WorkspaceRead(_) => {
+ env.throw_exception(ExceptionKind::SysandException, suberror.to_string())
+ }
},
},
}
@@ -431,7 +443,10 @@ fn handle_build_error(env: &mut JNIEnv<'_>, error: KParBuildError
),
);
}
- KParBuildError::WorkspaceMetamodelConflict { .. } => {
+ KParBuildError::WorkspaceInheritance(_) => {
+ env.throw_exception(ExceptionKind::SysandException, error.to_string());
+ }
+ KParBuildError::InvalidBuildTag { .. } => {
env.throw_exception(ExceptionKind::SysandException, error.to_string());
}
}
@@ -457,6 +472,7 @@ pub extern "system" fn Java_com_sensmetry_sysand_Sysand_buildProject<'local>(
output_path: JString<'local>,
project_path: JString<'local>,
compression: JString<'local>,
+ build_tag: JString<'local>,
) {
let Some(output_path) = env.get_str(&output_path, "outputPath") else {
return;
@@ -474,12 +490,18 @@ pub extern "system" fn Java_com_sensmetry_sysand_Sysand_buildProject<'local>(
let Some(compression) = compression_from_java_string(&mut env, compression) else {
return;
};
+ let build_tag_owned: Option = if build_tag.is_null() {
+ None
+ } else {
+ env.get_str(&build_tag, "buildTag")
+ };
let command_result = sysand_core::commands::build::do_build_kpar(
&project,
&output_path,
compression,
true,
false,
+ build_tag_owned.as_deref(),
);
match command_result {
Ok(_) => {}
@@ -494,6 +516,7 @@ pub extern "system" fn Java_com_sensmetry_sysand_Sysand_buildWorkspace<'local>(
output_path: JString<'local>,
workspace_path: JString<'local>,
compression: JString<'local>,
+ build_tag: JString<'local>,
) {
let Some(output_path) = env.get_str(&output_path, "outputPath") else {
return;
@@ -514,6 +537,11 @@ pub extern "system" fn Java_com_sensmetry_sysand_Sysand_buildWorkspace<'local>(
let Some(compression) = compression_from_java_string(&mut env, compression) else {
return;
};
+ let build_tag_owned: Option = if build_tag.is_null() {
+ None
+ } else {
+ env.get_str(&build_tag, "buildTag")
+ };
match wrapfs::create_dir_all(&output_path) {
Ok(_) => {}
Err(error) => {
@@ -528,6 +556,7 @@ pub extern "system" fn Java_com_sensmetry_sysand_Sysand_buildWorkspace<'local>(
compression,
true,
false,
+ build_tag_owned.as_deref(),
);
match command_result {
Ok(_) => {}
diff --git a/bindings/py/src/lib.rs b/bindings/py/src/lib.rs
index 12744a491..22a19e042 100644
--- a/bindings/py/src/lib.rs
+++ b/bindings/py/src/lib.rs
@@ -79,6 +79,8 @@ fn do_init_py_local_file(
PyValueError::new_err(error.to_string())
}
LocalSrcError::MissingMeta => PyFileNotFoundError::new_err(err.to_string()),
+ LocalSrcError::WorkspaceInheritance(_) => PyValueError::new_err(err.to_string()),
+ LocalSrcError::WorkspaceRead(_) => PyValueError::new_err(err.to_string()),
},
},
)?;
@@ -108,6 +110,8 @@ fn do_env_py_local_dir(path: String) -> PyResult<()> {
}
LocalWriteError::MissingMeta => PyFileNotFoundError::new_err(werr.to_string()),
LocalWriteError::AddProject(error) => PyIOError::new_err(error.to_string()),
+ LocalWriteError::WorkspaceInheritance(_) => PyValueError::new_err(werr.to_string()),
+ LocalWriteError::WorkspaceRead(_) => PyValueError::new_err(werr.to_string()),
},
})?;
@@ -220,7 +224,7 @@ fn do_build_py(
None => KparCompressionMethod::default(),
};
- do_build_kpar(&project, &output_path, compression, true, false)
+ do_build_kpar(&project, &output_path, compression, true, false, None)
.map(|_| ())
.map_err(|err| match err {
KParBuildError::ProjectRead(_) => PyRuntimeError::new_err(err.to_string()),
@@ -236,9 +240,8 @@ fn do_build_py(
KParBuildError::Serialize(..) => PyValueError::new_err(err.to_string()),
KParBuildError::WorkspaceRead(_) => PyRuntimeError::new_err(err.to_string()),
KParBuildError::PathUsage(_) => PyValueError::new_err(err.to_string()),
- KParBuildError::WorkspaceMetamodelConflict { .. } => {
- PyValueError::new_err(err.to_string())
- }
+ KParBuildError::WorkspaceInheritance(_) => PyValueError::new_err(err.to_string()),
+ KParBuildError::InvalidBuildTag { .. } => PyValueError::new_err(err.to_string()),
})
}
diff --git a/core/src/commands/build.rs b/core/src/commands/build.rs
index ce36b5905..b55079876 100644
--- a/core/src/commands/build.rs
+++ b/core/src/commands/build.rs
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-FileCopyrightText: © 2026 Sysand contributors
+use std::collections::HashMap;
+
use camino::{Utf8Path, Utf8PathBuf};
use thiserror::Error;
@@ -14,7 +16,7 @@ use crate::{
local_src::{LocalSrcError, LocalSrcProject},
utils::{FsIoError, ZipArchiveError},
},
- workspace::{Workspace, WorkspaceReadError},
+ workspace::{ResolvedProject, Workspace, WorkspaceInheritanceError, WorkspaceReadError},
};
#[derive(Default, Copy, Clone, Debug, PartialEq, Eq)]
@@ -153,16 +155,10 @@ pub enum KParBuildError {
which is unlikely to be available on other computers at the same path"
)]
PathUsage(String),
- #[error(
- "workspace sets metamodel `{workspace_metamodel}`, but project `{project_path}` \
- sets a different metamodel `{project_metamodel}` in `.meta.json`;\n\
- remove the metamodel from the project's `.meta.json` or from `.workspace.json`"
- )]
- WorkspaceMetamodelConflict {
- workspace_metamodel: String,
- project_metamodel: String,
- project_path: String,
- },
+ #[error(transparent)]
+ WorkspaceInheritance(#[from] WorkspaceInheritanceError),
+ #[error("invalid build tag `{tag}`: {error}")]
+ InvalidBuildTag { tag: String, error: semver::Error },
}
impl From for KParBuildError {
@@ -212,6 +208,16 @@ impl From>
}
}
+fn kpar_file_name(name: &str, version: &str) -> String {
+ format!(
+ "{}-{}.kpar",
+ name.chars()
+ .map(|c| if c.is_alphanumeric() { c } else { '_' })
+ .collect::(),
+ version
+ )
+}
+
pub fn default_kpar_path(
project: &Pr,
workspace: Option<&Workspace>,
@@ -231,15 +237,7 @@ pub fn default_kpar_file_name(
let Some(project_info) = project.get_info().map_err(KParBuildError::ProjectRead)? else {
return Err(KParBuildError::MissingInfo);
};
- Ok(format!(
- "{}-{}.kpar",
- project_info
- .name
- .chars()
- .map(|c| if c.is_alphanumeric() { c } else { '_' })
- .collect::(),
- project_info.version
- ))
+ Ok(kpar_file_name(&project_info.name, &project_info.version))
}
pub fn do_build_kpar, Pr: ProjectRead>(
@@ -248,6 +246,7 @@ pub fn do_build_kpar, Pr: ProjectRead>(
compression: KparCompressionMethod,
canonicalise: bool,
allow_path_usage: bool,
+ build_tag: Option<&str>,
) -> Result> {
do_build_kpar_inner(
project,
@@ -255,7 +254,8 @@ pub fn do_build_kpar, Pr: ProjectRead>(
compression,
canonicalise,
allow_path_usage,
- None,
+ build_tag,
+ &HashMap::new(),
)
}
@@ -265,7 +265,8 @@ fn do_build_kpar_inner, Pr: ProjectRead>(
compression: KparCompressionMethod,
canonicalise: bool,
allow_path_usage: bool,
- workspace_metamodel: Option<&str>,
+ build_tag: Option<&str>,
+ workspace_iri_versions: &HashMap,
) -> Result> {
use crate::project::local_src::LocalSrcProject;
@@ -273,7 +274,7 @@ fn do_build_kpar_inner, Pr: ProjectRead>(
let header = crate::style::get_style_config().header;
log::info!("{header}{building:>12}{header:#} kpar `{}`", path.as_ref());
- let (_tmp, mut local_project, info, mut meta) =
+ let (_tmp, mut local_project, mut info, meta) =
LocalSrcProject::temporary_from_project(project)?;
match semver::Version::parse(&info.version) {
Ok(_) => (),
@@ -315,22 +316,28 @@ fn do_build_kpar_inner, Pr: ProjectRead>(
}
}
- if let Some(ws_metamodel) = workspace_metamodel {
- if let Some(proj_metamodel) = &meta.metamodel {
- if proj_metamodel != ws_metamodel {
- return Err(KParBuildError::WorkspaceMetamodelConflict {
- workspace_metamodel: ws_metamodel.to_string(),
- project_metamodel: proj_metamodel.clone(),
- project_path: path.as_ref().to_string(),
- });
+ if let Some(tag) = build_tag
+ && let Ok(mut v) = semver::Version::parse(&info.version)
+ {
+ v.pre = semver::Prerelease::new(&format!("dev.{tag}")).map_err(|e| {
+ KParBuildError::InvalidBuildTag {
+ tag: tag.to_string(),
+ error: e,
+ }
+ })?;
+ info.version = v.to_string();
+ for usage in &mut info.usage {
+ if let Some(constraint) = &usage.version_constraint
+ && let Some(base_ver) = workspace_iri_versions.get(&usage.resource)
+ && constraint == &format!("={base_ver}")
+ {
+ usage.version_constraint = Some(format!("={base_ver}-dev.{tag}"));
}
- } else {
- meta.metamodel = Some(ws_metamodel.to_string());
- use crate::project::ProjectMut;
- local_project
- .put_meta(&meta, true)
- .map_err(KParBuildError::from)?;
}
+ use crate::project::ProjectMut;
+ local_project
+ .put_info(&info, true)
+ .map_err(KParBuildError::from)?;
}
if canonicalise {
@@ -420,25 +427,82 @@ pub fn do_build_workspace_kpars>(
compression: KparCompressionMethod,
canonicalise: bool,
allow_path_usage: bool,
+ build_tag: Option<&str>,
) -> Result, KParBuildError> {
- let ws_metamodel = workspace.metamodel().map(|iri| iri.as_str());
+ use crate::workspace::{resolve_project_info, resolve_project_metadata};
+
+ // Build IRI → base_version map for workspace projects so usage constraints can be updated.
+ let mut iri_versions: HashMap = HashMap::new();
+ if build_tag.is_some() {
+ for ws_project_info in workspace.projects() {
+ let project = LocalSrcProject {
+ nominal_path: None,
+ project_path: workspace.root_path().join(&ws_project_info.path),
+ };
+ let (raw_info, _) = project.get_project_with_inherit()?;
+ if let Some(raw_info) = raw_info {
+ let resolved = resolve_project_info(raw_info, workspace.info())?;
+ for iri in &ws_project_info.iris {
+ iri_versions.insert(iri.to_string(), resolved.version.clone());
+ }
+ }
+ }
+ }
let mut result = Vec::new();
- for project_root in workspace.projects() {
+ for ws_project_info in workspace.projects() {
let project = LocalSrcProject {
nominal_path: None,
- project_path: workspace.root_path().join(&project_root.path),
+ project_path: workspace.root_path().join(&ws_project_info.path),
+ };
+
+ // Read .project.json and .meta.json with workspace-inheritance support.
+ let (raw_info, raw_meta) = project.get_project_with_inherit()?;
+ let raw_info = raw_info.ok_or(KParBuildError::MissingInfo)?;
+ let raw_meta = raw_meta.ok_or(KParBuildError::MissingMeta)?;
+
+ // Resolve workspace references.
+ let resolved_info = resolve_project_info(raw_info, workspace.info())?;
+ let resolved_meta =
+ resolve_project_metadata(raw_meta, workspace.info(), &resolved_info.name)?;
+
+ // Use resolved version (possibly with build tag) for the output filename.
+ let file_version = if let Some(tag) = build_tag {
+ if let Ok(mut v) = semver::Version::parse(&resolved_info.version) {
+ v.pre = semver::Prerelease::new(&format!("dev.{tag}")).map_err(|e| {
+ KParBuildError::InvalidBuildTag {
+ tag: tag.to_string(),
+ error: e,
+ }
+ })?;
+ v.to_string()
+ } else {
+ resolved_info.version.clone()
+ }
+ } else {
+ resolved_info.version.clone()
+ };
+ let output_path = path
+ .as_ref()
+ .join(kpar_file_name(&resolved_info.name, &file_version));
+
+ // Wrap the project so that `temporary_from_project` (called inside
+ // `do_build_kpar_inner`) reads the resolved values rather than the
+ // raw files that may contain workspace inheritance placeholders.
+ let resolved_project = ResolvedProject {
+ inner: &project,
+ info: resolved_info,
+ meta: resolved_meta,
};
- let file_name = default_kpar_file_name(&project)?;
- let output_path = path.as_ref().join(file_name);
let kpar_project = do_build_kpar_inner(
- &project,
+ &resolved_project,
&output_path,
compression,
canonicalise,
allow_path_usage,
- ws_metamodel,
+ build_tag,
+ &iri_versions,
)?;
result.push(kpar_project);
}
diff --git a/core/src/env/local_directory/mod.rs b/core/src/env/local_directory/mod.rs
index 475babd4b..4b20d5c7a 100644
--- a/core/src/env/local_directory/mod.rs
+++ b/core/src/env/local_directory/mod.rs
@@ -384,6 +384,10 @@ pub enum LocalWriteError {
ImpossibleRelativePath(#[from] RelativizePathError),
#[error("project is missing metadata file `.meta.json`")]
MissingMeta,
+ #[error(transparent)]
+ WorkspaceInheritance(#[from] crate::workspace::WorkspaceInheritanceError),
+ #[error(transparent)]
+ WorkspaceRead(#[from] crate::workspace::WorkspaceReadError),
}
impl From for LocalWriteError {
@@ -411,6 +415,8 @@ impl From for LocalWriteError {
LocalSrcError::Serialize(error) => Self::Serialize(error),
LocalSrcError::ImpossibleRelativePath(err) => Self::ImpossibleRelativePath(err),
LocalSrcError::MissingMeta => LocalWriteError::MissingMeta,
+ LocalSrcError::WorkspaceInheritance(e) => LocalWriteError::WorkspaceInheritance(e),
+ LocalSrcError::WorkspaceRead(e) => LocalWriteError::WorkspaceRead(e),
}
}
}
diff --git a/core/src/model.rs b/core/src/model.rs
index fa3cd7961..64c6c8185 100644
--- a/core/src/model.rs
+++ b/core/src/model.rs
@@ -17,6 +17,21 @@ use crate::utils::lowercase_hex;
// pub struct ParsedIri(fluent_uri::Iri);
// pub struct NormalisedIri(fluent_uri::Iri);
+// Workspace inheritance types defined here so they are available without the
+// `filesystem` feature, which gates the `workspace` module.
+//
+/// A field value that is either a literal or a workspace preset inheritance
+/// placeholder (`{ "preset": "default" }` or `{ "preset": "name" }`).
+///
+/// Use `"default"` to inherit from the workspace root `project` defaults;
+/// use any other name to inherit from a named preset.
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+#[serde(untagged)]
+pub enum WorkspaceInherit {
+ Literal(T),
+ Preset { preset: String },
+}
+
pub const KNOWN_METAMODELS: [&str; 2] = [
"https://www.omg.org/spec/SysML/20250201",
"https://www.omg.org/spec/KerML/20250201",
@@ -129,6 +144,40 @@ pub type InterchangeProjectInfoRaw = InterchangeProjectInfoG, semver::Version, semver::VersionReq>;
+/// Deserialized form of `.project.json` where `version`, `publisher`, and
+/// `license` may carry workspace inheritance placeholders instead of literal
+/// values. Produced by `LocalSrcProject::get_project_with_inherit` and
+/// resolved via `crate::workspace::resolve_project_info`.
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct InterchangeProjectInfoWithInheritRaw {
+ pub name: String,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub publisher: Option>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub description: Option,
+
+ pub version: WorkspaceInherit,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub license: Option>,
+
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ #[serde(default)]
+ pub maintainer: Vec,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub website: Option,
+
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ #[serde(default)]
+ pub topic: Vec,
+
+ pub usage: Vec,
+}
+
impl From for InterchangeProjectInfoRaw {
fn from(value: InterchangeProjectInfo) -> Self {
InterchangeProjectInfoRaw {
@@ -415,6 +464,26 @@ pub struct InterchangeProjectMetadataG {
pub type InterchangeProjectMetadataRaw =
InterchangeProjectMetadataG;
+
+/// Deserialized form of `.meta.json` where `metamodel` may carry a workspace
+/// inheritance placeholder. Produced by
+/// `LocalSrcProject::get_project_with_inherit` and resolved via
+/// `crate::workspace::resolve_project_metadata`.
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct InterchangeProjectMetadataWithInheritRaw {
+ pub index: IndexMap,
+ pub created: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub metamodel: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub includes_derived: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub includes_implied: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub checksum: Option>,
+}
+
pub type InterchangeProjectMetadata = InterchangeProjectMetadataG<
fluent_uri::Iri,
Utf8UnixPathBuf,
diff --git a/core/src/project/gix_git_download.rs b/core/src/project/gix_git_download.rs
index 4b5570ddf..9ef14da4e 100644
--- a/core/src/project/gix_git_download.rs
+++ b/core/src/project/gix_git_download.rs
@@ -75,6 +75,8 @@ impl From for GixDownloadedError {
LocalSrcError::MissingMeta => GixDownloadedError::Other(
"project is missing metadata file `.meta.json`".to_string(),
),
+ LocalSrcError::WorkspaceInheritance(e) => GixDownloadedError::Other(e.to_string()),
+ LocalSrcError::WorkspaceRead(e) => GixDownloadedError::Other(e.to_string()),
}
}
}
diff --git a/core/src/project/local_src.rs b/core/src/project/local_src.rs
index 8808b098d..af529dd38 100644
--- a/core/src/project/local_src.rs
+++ b/core/src/project/local_src.rs
@@ -15,13 +15,21 @@ use indexmap::IndexMap;
use crate::{
context::ProjectContext,
+ discover::discover_workspace,
env::utils::{CloneError, clone_project},
lock::Source,
- model::{InterchangeProjectInfoRaw, InterchangeProjectMetadataRaw},
+ model::{
+ InterchangeProjectInfoRaw, InterchangeProjectInfoWithInheritRaw,
+ InterchangeProjectMetadataRaw, InterchangeProjectMetadataWithInheritRaw,
+ },
project::{
ProjectMut, ProjectRead,
utils::{RelativizePathError, ToPathBuf, ToUnixPathBuf, relativize_path, wrapfs},
},
+ workspace::{
+ WorkspaceInheritanceError, project_info_without_workspace,
+ project_metadata_without_workspace, resolve_project_info, resolve_project_metadata,
+ },
};
use super::utils::{FsIoError, ProjectDeserializationError, ProjectSerializationError};
@@ -224,11 +232,62 @@ impl LocalSrcProject {
// }
pub fn set_index(&mut self, new_index: IndexMap) -> Result<(), LocalSrcError> {
- let mut meta = self.get_meta()?.ok_or(LocalSrcError::MissingMeta)?;
- meta.index = new_index;
- self.put_meta(&meta, true)?;
+ let (_, raw_meta) = self.get_project_with_inherit()?;
+ let mut raw_meta = raw_meta.ok_or(LocalSrcError::MissingMeta)?;
+ raw_meta.index = new_index;
+
+ let meta_json_path = self.meta_path();
+ let mut file = wrapfs::File::create(&meta_json_path)?;
+ serde_json::to_writer_pretty(&mut file, &raw_meta).map_err(|e| {
+ ProjectSerializationError::new(
+ format!(
+ "failed to serialize and write project metadata to `{}`",
+ meta_json_path
+ ),
+ e,
+ )
+ })?;
+ file.write(b"\n")
+ .map_err(|e| FsIoError::WriteFile(meta_json_path, e))?;
+
Ok(())
}
+
+ /// Read `.project.json` and `.meta.json` into their workspace-inheritance
+ /// aware raw forms. Returns `None` for each file that does not exist.
+ pub fn get_project_with_inherit(
+ &self,
+ ) -> Result<
+ (
+ Option,
+ Option,
+ ),
+ LocalSrcError,
+ > {
+ let info_json_path = self.info_path();
+ let info = if info_json_path.exists() {
+ Some(
+ serde_json::from_reader(wrapfs::File::open(&info_json_path)?).map_err(|e| {
+ ProjectDeserializationError::new("failed to deserialize `.project.json`", e)
+ })?,
+ )
+ } else {
+ None
+ };
+
+ let meta_json_path = self.meta_path();
+ let meta = if meta_json_path.exists() {
+ Some(
+ serde_json::from_reader(wrapfs::File::open(&meta_json_path)?).map_err(|e| {
+ ProjectDeserializationError::new("failed to deserialize `.meta.json`", e)
+ })?,
+ )
+ } else {
+ None
+ };
+
+ Ok((info, meta))
+ }
}
impl ProjectMut for LocalSrcProject {
@@ -334,6 +393,10 @@ pub enum LocalSrcError {
{0}"
)]
ImpossibleRelativePath(#[from] RelativizePathError),
+ #[error(transparent)]
+ WorkspaceInheritance(#[from] WorkspaceInheritanceError),
+ #[error(transparent)]
+ WorkspaceRead(#[from] crate::workspace::WorkspaceReadError),
}
impl From for LocalSrcError {
@@ -376,7 +439,7 @@ impl ProjectRead for LocalSrcProject {
> {
let info_json_path = self.info_path();
- let info_json = if info_json_path.exists() {
+ let info_raw: Option = if info_json_path.exists() {
Some(
serde_json::from_reader(wrapfs::File::open(&info_json_path)?).map_err(|e| {
ProjectDeserializationError::new("failed to deserialize `.project.json`", e)
@@ -388,7 +451,8 @@ impl ProjectRead for LocalSrcProject {
let meta_json_path = self.meta_path();
- let meta_json = if meta_json_path.exists() {
+ let meta_raw: Option = if meta_json_path.exists()
+ {
Some(
serde_json::from_reader(wrapfs::File::open(&meta_json_path)?).map_err(|e| {
ProjectDeserializationError::new("failed to deserialize `.meta.json`", e)
@@ -398,6 +462,58 @@ impl ProjectRead for LocalSrcProject {
None
};
+ // Try to resolve without a workspace first; if any field carries a preset
+ // reference, auto-discover the workspace by walking up from project_path.
+ let resolve_without_workspace = || -> Result<_, WorkspaceInheritanceError> {
+ let info = info_raw
+ .clone()
+ .map(project_info_without_workspace)
+ .transpose()?;
+ let project_name = info
+ .as_ref()
+ .map(|i: &InterchangeProjectInfoRaw| i.name.as_str())
+ .unwrap_or("")
+ .to_string();
+ let meta = meta_raw
+ .clone()
+ .map(|m| project_metadata_without_workspace(m, &project_name))
+ .transpose()?;
+ Ok((info, meta))
+ };
+
+ match resolve_without_workspace() {
+ Ok((info_json, meta_json)) => return Ok((info_json, meta_json)),
+ Err(WorkspaceInheritanceError::NoWorkspace { .. }) => {}
+ Err(e) => return Err(LocalSrcError::WorkspaceInheritance(e)),
+ }
+
+ // At least one field uses a preset reference — try to locate .workspace.json.
+ let project_name_for_error = info_raw
+ .as_ref()
+ .map(|i| i.name.clone())
+ .unwrap_or_else(|| "".to_string());
+ let workspace = discover_workspace(&self.project_path)?.ok_or({
+ WorkspaceInheritanceError::NoWorkspace {
+ project: project_name_for_error,
+ }
+ })?;
+
+ let info_json = info_raw
+ .map(|r| resolve_project_info(r, workspace.info()))
+ .transpose()
+ .map_err(LocalSrcError::WorkspaceInheritance)?;
+
+ let project_name = info_json
+ .as_ref()
+ .map(|i: &InterchangeProjectInfoRaw| i.name.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let meta_json = meta_raw
+ .map(|m| resolve_project_metadata(m, workspace.info(), &project_name))
+ .transpose()
+ .map_err(LocalSrcError::WorkspaceInheritance)?;
+
Ok((info_json, meta_json))
}
diff --git a/core/src/workspace.rs b/core/src/workspace.rs
deleted file mode 100644
index 3e134eb51..000000000
--- a/core/src/workspace.rs
+++ /dev/null
@@ -1,171 +0,0 @@
-// SPDX-License-Identifier: MIT OR Apache-2.0
-// SPDX-FileCopyrightText: © 2026 Sysand contributors
-
-use camino::{Utf8Path, Utf8PathBuf};
-use fluent_uri::Iri;
-
-#[cfg(feature = "python")]
-use pyo3::{FromPyObject, IntoPyObject};
-use serde::{Deserialize, Serialize};
-use thiserror::Error;
-
-use crate::model::KNOWN_METAMODELS;
-use crate::project::utils::{FsIoError, wrapfs};
-
-#[derive(Eq, Clone, PartialEq, Serialize, Deserialize, Debug)]
-#[cfg_attr(feature = "python", derive(FromPyObject, IntoPyObject))]
-#[serde(rename_all = "camelCase")]
-pub struct WorkspaceProjectInfoG {
- pub path: String,
- pub iris: Vec,
-}
-
-#[derive(Eq, Clone, PartialEq, Serialize, Deserialize, Debug, Default)]
-#[cfg_attr(feature = "python", derive(FromPyObject, IntoPyObject))]
-#[serde(rename_all = "camelCase")]
-pub struct WorkspaceMetaG {
- #[serde(skip_serializing_if = "Option::is_none")]
- pub metamodel: Option,
-}
-
-pub type WorkspaceMetaRaw = WorkspaceMetaG;
-pub type WorkspaceMeta = WorkspaceMetaG>;
-
-#[derive(Error, Debug)]
-pub enum WorkspaceValidationError {
- #[error("failed to parse `{0}` as IRI: {1}")]
- InvalidIri(String, fluent_uri::ParseError),
-}
-
-#[derive(Eq, Clone, PartialEq, Serialize, Deserialize, Debug)]
-#[cfg_attr(feature = "python", derive(FromPyObject, IntoPyObject))]
-#[serde(rename_all = "camelCase")]
-pub struct WorkspaceInfoG {
- pub projects: Vec>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub meta: Option>,
-}
-
-pub type WorkspaceInfoRaw = WorkspaceInfoG;
-pub type WorkspaceInfo = WorkspaceInfoG>;
-pub type WorkspaceProjectInfoRaw = WorkspaceProjectInfoG;
-pub type WorkspaceProjectInfo = WorkspaceProjectInfoG>;
-
-impl TryFrom for WorkspaceInfo {
- type Error = WorkspaceValidationError;
-
- fn try_from(value: WorkspaceInfoRaw) -> Result {
- let mut projects = Vec::with_capacity(value.projects.len());
- for project in value.projects {
- let mut iris = Vec::with_capacity(project.iris.len());
- for iri in project.iris {
- let iri = Iri::parse(iri)
- .map_err(|(e, iri)| WorkspaceValidationError::InvalidIri(iri, e))?;
- iris.push(iri);
- }
- projects.push(WorkspaceProjectInfo {
- path: project.path,
- iris,
- });
- }
-
- let meta = value
- .meta
- .map(|raw_meta| {
- let metamodel = raw_meta
- .metamodel
- .map(|m| {
- if !KNOWN_METAMODELS.contains(&m.as_str()) {
- log::warn!("workspace uses an unknown metamodel `{m}`");
- }
- Iri::parse(m)
- .map_err(|(e, iri)| WorkspaceValidationError::InvalidIri(iri, e))
- })
- .transpose()?;
- Ok(WorkspaceMeta { metamodel })
- })
- .transpose()?;
-
- Ok(Self { projects, meta })
- }
-}
-
-#[derive(Error, Debug)]
-pub enum WorkspaceReadError {
- #[error(transparent)]
- Io(#[from] Box),
- #[error("failed to deserialize `.workspace.json`: {0}")]
- Deserialize(#[from] WorkspaceDeserializationError),
- #[error("invalid workspace configuration in `{0}`: {1}")]
- Validation(Utf8PathBuf, WorkspaceValidationError),
-}
-
-#[derive(Debug, Error)]
-#[error("workspace deserialization error: {msg}: {err}")]
-pub struct WorkspaceDeserializationError {
- msg: &'static str,
- err: serde_json::Error,
-}
-
-impl WorkspaceDeserializationError {
- pub fn new(msg: &'static str, err: serde_json::Error) -> Self {
- Self { msg, err }
- }
-}
-
-#[derive(Debug)]
-pub struct Workspace {
- root_dir: Utf8PathBuf,
- info: WorkspaceInfo,
-}
-
-impl Workspace {
- /// Read and parse workspace info file `.workspace.json` residing in `root_dir`
- pub fn new(root_dir: Utf8PathBuf) -> Result {
- let info_path = root_dir.join(".workspace.json");
- let raw_info: WorkspaceInfoRaw = serde_json::from_reader(wrapfs::File::open(&info_path)?)
- .map_err(|e| {
- WorkspaceDeserializationError::new("failed to deserialize `.workspace.json`", e)
- })?;
- match WorkspaceInfo::try_from(raw_info) {
- Ok(info) => Ok(Self { root_dir, info }),
- Err(e) => Err(WorkspaceReadError::Validation(info_path, e)),
- }
- }
-
- pub fn root_path(&self) -> &Utf8Path {
- &self.root_dir
- }
-
- pub fn info_path(&self) -> Utf8PathBuf {
- self.root_dir.join(".workspace.json")
- }
-
- pub fn info(&self) -> &WorkspaceInfo {
- &self.info
- }
-
- pub fn projects(&self) -> &[WorkspaceProjectInfo] {
- &self.info.projects
- }
-
- pub fn meta(&self) -> Option<&WorkspaceMeta> {
- self.info.meta.as_ref()
- }
-
- pub fn metamodel(&self) -> Option<&Iri> {
- self.info.meta.as_ref().and_then(|m| m.metamodel.as_ref())
- }
-
- pub fn absolute_project_paths(&self) -> Vec {
- self.info
- .projects
- .iter()
- .map(|p| self.root_dir.join(&p.path))
- .collect()
- }
-}
-
-#[cfg(test)]
-#[path = "./workspace_tests.rs"]
-mod tests;
diff --git a/core/src/workspace/inheritance.rs b/core/src/workspace/inheritance.rs
new file mode 100644
index 000000000..51d5a73c9
--- /dev/null
+++ b/core/src/workspace/inheritance.rs
@@ -0,0 +1,307 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// SPDX-FileCopyrightText: © 2026 Sysand contributors
+
+use thiserror::Error;
+
+use crate::model::{InterchangeProjectInfoRaw, InterchangeProjectMetadataRaw, WorkspaceInherit};
+
+use super::types::{WorkspaceInfo, WorkspacePresetEntryRaw};
+
+#[derive(Error, Debug)]
+pub enum WorkspaceInheritanceError {
+ #[error(
+ "project `{project}`: field `{field}` references preset `{preset}`, \
+ but no such preset exists in `.workspace.json`"
+ )]
+ UnknownPreset {
+ project: String,
+ field: &'static str,
+ preset: String,
+ },
+
+ #[error(
+ "project `{project}`: field `{field}` uses {{\"preset\": \"default\"}}, \
+ but `.workspace.json` has no `project.{field}` default"
+ )]
+ MissingRootDefault {
+ project: String,
+ field: &'static str,
+ },
+
+ #[error(
+ "project `{project}`: field `{field}` uses {{\"preset\": \"{preset}\"}}, \
+ but preset `{preset}` has no `project.{field}` default"
+ )]
+ MissingPresetDefault {
+ project: String,
+ field: &'static str,
+ preset: String,
+ },
+
+ #[error("project `{project}` uses workspace inheritance but no `.workspace.json` was found")]
+ NoWorkspace { project: String },
+}
+
+/// Resolve a single required `WorkspaceInherit` field.
+///
+/// * `Literal(v)` — returned as-is.
+/// * `{ "preset": "default" }` — calls `get_root_default(workspace)`; errors with
+/// [`WorkspaceInheritanceError::MissingRootDefault`] if the workspace has no
+/// value for this field.
+/// * `{ "preset": "name" }` — looks up the named preset, then calls
+/// `get_preset_default`; errors with [`WorkspaceInheritanceError::UnknownPreset`]
+/// or [`WorkspaceInheritanceError::MissingPresetDefault`] as appropriate.
+///
+/// Returns the resolved string value and, when a named preset was used, the
+/// preset name (used by callers that need to know which preset was resolved).
+fn resolve_field<'a>(
+ field: WorkspaceInherit,
+ field_name: &'static str,
+ project_name: &str,
+ workspace: &'a WorkspaceInfo,
+ get_root_default: impl Fn(&'a WorkspaceInfo) -> Option<&'a str>,
+ get_preset_default: impl Fn(&'a WorkspacePresetEntryRaw) -> Option<&'a str>,
+) -> Result<(String, Option), WorkspaceInheritanceError> {
+ match field {
+ WorkspaceInherit::Literal(v) => Ok((v, None)),
+ WorkspaceInherit::Preset { preset } => {
+ if preset == "default" {
+ let value = get_root_default(workspace).ok_or_else(|| {
+ WorkspaceInheritanceError::MissingRootDefault {
+ project: project_name.to_string(),
+ field: field_name,
+ }
+ })?;
+ Ok((value.to_string(), None))
+ } else {
+ let entry = workspace
+ .presets
+ .as_ref()
+ .and_then(|p| p.get(&preset))
+ .ok_or_else(|| WorkspaceInheritanceError::UnknownPreset {
+ project: project_name.to_string(),
+ field: field_name,
+ preset: preset.clone(),
+ })?;
+ let value = get_preset_default(entry).ok_or_else(|| {
+ WorkspaceInheritanceError::MissingPresetDefault {
+ project: project_name.to_string(),
+ field: field_name,
+ preset: preset.clone(),
+ }
+ })?;
+ Ok((value.to_string(), Some(preset)))
+ }
+ }
+ }
+}
+
+/// Like [`resolve_field`], but for optional fields: `None` is passed through as
+/// `(None, None)` without consulting the workspace.
+fn resolve_optional_field<'a>(
+ field: Option>,
+ field_name: &'static str,
+ project_name: &str,
+ workspace: &'a WorkspaceInfo,
+ get_root_default: impl Fn(&'a WorkspaceInfo) -> Option<&'a str>,
+ get_preset_default: impl Fn(&'a WorkspacePresetEntryRaw) -> Option<&'a str>,
+) -> Result<(Option, Option), WorkspaceInheritanceError> {
+ match field {
+ None => Ok((None, None)),
+ Some(f) => {
+ let (v, g) = resolve_field(
+ f,
+ field_name,
+ project_name,
+ workspace,
+ get_root_default,
+ get_preset_default,
+ )?;
+ Ok((Some(v), g))
+ }
+ }
+}
+
+/// Resolve all workspace-inherit references in `.project.json`.
+///
+/// Each of `version`, `publisher`, and `license` may carry a
+/// [`WorkspaceInherit`] value instead of a literal string. Root defaults are
+/// read from [`WorkspaceInfo::project`]; group defaults from
+/// [`WorkspaceGroupEntryRaw::project`].
+///
+/// Fields that are absent (`None`) are left as `None`; fields that carry a
+/// literal value are passed through unchanged.
+///
+/// # Errors
+///
+/// Returns [`WorkspaceInheritanceError`] if a referenced group does not exist,
+/// the requested default is absent, or `{ "workspace": false }` is used.
+pub fn resolve_project_info(
+ raw: crate::model::InterchangeProjectInfoWithInheritRaw,
+ workspace: &WorkspaceInfo,
+) -> Result {
+ let project_name = raw.name.clone();
+
+ macro_rules! resolve {
+ ($field:expr, $name:literal, $proj_fn:expr, $grp_fn:expr) => {{
+ let (v, _) = resolve_field($field, $name, &project_name, workspace, $proj_fn, $grp_fn)?;
+ v
+ }};
+ }
+
+ macro_rules! resolve_opt {
+ ($field:expr, $name:literal, $proj_fn:expr, $grp_fn:expr) => {{
+ let (v, _) =
+ resolve_optional_field($field, $name, &project_name, workspace, $proj_fn, $grp_fn)?;
+ v
+ }};
+ }
+
+ let version = resolve!(
+ raw.version,
+ "version",
+ |ws| ws.project.as_ref().and_then(|p| p.version.as_deref()),
+ |e| e.project.as_ref().and_then(|p| p.version.as_deref())
+ );
+ let publisher = resolve_opt!(
+ raw.publisher,
+ "publisher",
+ |ws| ws.project.as_ref().and_then(|p| p.publisher.as_deref()),
+ |e| e.project.as_ref().and_then(|p| p.publisher.as_deref())
+ );
+ let license = resolve_opt!(
+ raw.license,
+ "license",
+ |ws| ws.project.as_ref().and_then(|p| p.license.as_deref()),
+ |e| e.project.as_ref().and_then(|p| p.license.as_deref())
+ );
+
+ Ok(InterchangeProjectInfoRaw {
+ name: raw.name,
+ publisher,
+ description: raw.description,
+ version,
+ license,
+ maintainer: raw.maintainer,
+ website: raw.website,
+ topic: raw.topic,
+ usage: raw.usage,
+ })
+}
+
+/// Resolve the `metamodel` field of `.meta.json`.
+///
+/// `{ "workspace": true }` inherits from [`WorkspaceInfo::meta`]`.metamodel`;
+/// `{ "workspace": "group" }` inherits from
+/// [`WorkspaceGroupEntryRaw::meta`]`.metamodel`. A literal value or absent
+/// field is passed through unchanged.
+///
+/// `project_name` is the owning project's name (from `.project.json`) and is
+/// used only in error messages.
+///
+/// # Errors
+///
+/// Returns [`WorkspaceInheritanceError`] under the same conditions as
+/// [`resolve_project_info`].
+pub fn resolve_project_metadata(
+ raw: crate::model::InterchangeProjectMetadataWithInheritRaw,
+ workspace: &WorkspaceInfo,
+ project_name: &str,
+) -> Result {
+ let (metamodel, _) = resolve_optional_field(
+ raw.metamodel,
+ "metamodel",
+ project_name,
+ workspace,
+ |ws| {
+ ws.meta
+ .as_ref()
+ .and_then(|m| m.metamodel.as_ref().map(|i| i.as_str()))
+ },
+ |e| e.meta.as_ref().and_then(|m| m.metamodel.as_deref()),
+ )?;
+
+ Ok(InterchangeProjectMetadataRaw {
+ index: raw.index,
+ created: raw.created,
+ metamodel,
+ includes_derived: raw.includes_derived,
+ includes_implied: raw.includes_implied,
+ checksum: raw.checksum,
+ })
+}
+
+/// Convert `.project.json` inheritance fields to plain values when no workspace
+/// is available.
+///
+/// Literal fields are passed through; any `{ "workspace": ... }` value causes a
+/// [`WorkspaceInheritanceError::NoWorkspace`] error.
+pub fn project_info_without_workspace(
+ raw: crate::model::InterchangeProjectInfoWithInheritRaw,
+) -> Result {
+ let project_name = raw.name.clone();
+
+ fn no_ws(
+ field: WorkspaceInherit,
+ project_name: &str,
+ _field_name: &'static str,
+ ) -> Result {
+ match field {
+ WorkspaceInherit::Literal(v) => Ok(v),
+ WorkspaceInherit::Preset { .. } => Err(WorkspaceInheritanceError::NoWorkspace {
+ project: project_name.to_string(),
+ }),
+ }
+ }
+
+ fn no_ws_opt(
+ field: Option>,
+ project_name: &str,
+ ) -> Result, WorkspaceInheritanceError> {
+ field.map(|f| no_ws(f, project_name, "")).transpose()
+ }
+
+ Ok(InterchangeProjectInfoRaw {
+ name: raw.name,
+ publisher: no_ws_opt(raw.publisher, &project_name)?,
+ description: raw.description,
+ version: no_ws(raw.version, &project_name, "version")?,
+ license: no_ws_opt(raw.license, &project_name)?,
+ maintainer: raw.maintainer,
+ website: raw.website,
+ topic: raw.topic,
+ usage: raw.usage,
+ })
+}
+
+/// Convert `.meta.json` inheritance fields to plain values when no workspace
+/// is available.
+///
+/// A literal or absent `metamodel` is passed through; `{ "workspace": ... }`
+/// causes a [`WorkspaceInheritanceError::NoWorkspace`] error.
+///
+/// `project_name` is the owning project's name and is used only in error
+/// messages.
+pub fn project_metadata_without_workspace(
+ raw: crate::model::InterchangeProjectMetadataWithInheritRaw,
+ project_name: &str,
+) -> Result {
+ let metamodel = match raw.metamodel {
+ None => None,
+ Some(WorkspaceInherit::Literal(v)) => Some(v),
+ Some(WorkspaceInherit::Preset { .. }) => {
+ return Err(WorkspaceInheritanceError::NoWorkspace {
+ project: project_name.to_string(),
+ });
+ }
+ };
+
+ Ok(InterchangeProjectMetadataRaw {
+ index: raw.index,
+ created: raw.created,
+ metamodel,
+ includes_derived: raw.includes_derived,
+ includes_implied: raw.includes_implied,
+ checksum: raw.checksum,
+ })
+}
diff --git a/core/src/workspace/mod.rs b/core/src/workspace/mod.rs
new file mode 100644
index 000000000..0e00af7d8
--- /dev/null
+++ b/core/src/workspace/mod.rs
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// SPDX-FileCopyrightText: © 2026 Sysand contributors
+
+pub mod inheritance;
+pub mod resolved_project;
+pub mod types;
+
+pub use inheritance::*;
+pub use resolved_project::*;
+pub use types::*;
+
+use camino::{Utf8Path, Utf8PathBuf};
+use serde_json;
+use thiserror::Error;
+
+use crate::project::utils::{FsIoError, wrapfs};
+
+#[derive(Debug, Error)]
+#[error("workspace deserialization error: {msg}: {err}")]
+pub struct WorkspaceDeserializationError {
+ msg: &'static str,
+ err: serde_json::Error,
+}
+
+impl WorkspaceDeserializationError {
+ pub fn new(msg: &'static str, err: serde_json::Error) -> Self {
+ Self { msg, err }
+ }
+}
+
+#[derive(Error, Debug)]
+pub enum WorkspaceReadError {
+ #[error(transparent)]
+ Io(#[from] Box),
+ #[error("failed to deserialize `.workspace.json`: {0}")]
+ Deserialize(#[from] WorkspaceDeserializationError),
+ #[error("invalid workspace configuration in `{0}`: {1}")]
+ Validation(Utf8PathBuf, WorkspaceValidationError),
+}
+
+#[derive(Debug)]
+pub struct Workspace {
+ root_dir: Utf8PathBuf,
+ info: WorkspaceInfo,
+}
+
+impl Workspace {
+ /// Read and parse workspace info file `.workspace.json` residing in `root_dir`
+ pub fn new(root_dir: Utf8PathBuf) -> Result {
+ let info_path = root_dir.join(".workspace.json");
+ let raw_info: WorkspaceInfoRaw = serde_json::from_reader(wrapfs::File::open(&info_path)?)
+ .map_err(|e| {
+ WorkspaceDeserializationError::new("failed to deserialize `.workspace.json`", e)
+ })?;
+ match WorkspaceInfo::try_from(raw_info) {
+ Ok(info) => Ok(Self { root_dir, info }),
+ Err(e) => Err(WorkspaceReadError::Validation(info_path, e)),
+ }
+ }
+
+ pub fn root_path(&self) -> &Utf8Path {
+ &self.root_dir
+ }
+
+ pub fn info_path(&self) -> Utf8PathBuf {
+ self.root_dir.join(".workspace.json")
+ }
+
+ pub fn info(&self) -> &WorkspaceInfo {
+ &self.info
+ }
+
+ pub fn projects(&self) -> &[WorkspaceProjectInfo] {
+ &self.info.projects
+ }
+
+ pub fn absolute_project_paths(&self) -> Vec {
+ self.info
+ .projects
+ .iter()
+ .map(|p| self.root_dir.join(&p.path))
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/core/src/workspace/resolved_project.rs b/core/src/workspace/resolved_project.rs
new file mode 100644
index 000000000..071a95196
--- /dev/null
+++ b/core/src/workspace/resolved_project.rs
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// SPDX-FileCopyrightText: © 2026 Sysand contributors
+
+use crate::model::{InterchangeProjectInfoRaw, InterchangeProjectMetadataRaw};
+
+/// Wraps a `ProjectRead` and overrides `get_project()` to return a pair of
+/// already-resolved `(InterchangeProjectInfoRaw, InterchangeProjectMetadataRaw)`
+/// values. All other `ProjectRead` methods delegate to the inner project.
+///
+/// Used in workspace builds so that `temporary_from_project` (which calls
+/// `get_project()`) sees the resolved values rather than raw files that may
+/// contain workspace inheritance placeholders.
+pub struct ResolvedProject<'a, P> {
+ pub inner: &'a P,
+ pub info: InterchangeProjectInfoRaw,
+ pub meta: InterchangeProjectMetadataRaw,
+}
+
+impl<'a, P: crate::project::ProjectRead> crate::project::ProjectRead for ResolvedProject<'a, P> {
+ type Error = P::Error;
+
+ fn get_project(
+ &self,
+ ) -> Result<
+ (
+ Option,
+ Option,
+ ),
+ Self::Error,
+ > {
+ Ok((Some(self.info.clone()), Some(self.meta.clone())))
+ }
+
+ type SourceReader<'b>
+ = P::SourceReader<'b>
+ where
+ Self: 'b;
+
+ fn read_source>(
+ &self,
+ path: Q,
+ ) -> Result, Self::Error> {
+ self.inner.read_source(path)
+ }
+
+ fn sources(
+ &self,
+ ctx: &crate::context::ProjectContext,
+ ) -> Result, Self::Error> {
+ self.inner.sources(ctx)
+ }
+
+ fn project_root(&self) -> Option<&camino::Utf8Path> {
+ self.inner.project_root()
+ }
+}
diff --git a/core/src/workspace/tests.rs b/core/src/workspace/tests.rs
new file mode 100644
index 000000000..6245c84eb
--- /dev/null
+++ b/core/src/workspace/tests.rs
@@ -0,0 +1,493 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// SPDX-FileCopyrightText: © 2026 Sysand contributors
+
+use super::*;
+use crate::model::{
+ InterchangeProjectInfoWithInheritRaw, InterchangeProjectMetadataWithInheritRaw,
+ WorkspaceInherit,
+};
+
+// ---------------------------------------------------------------------------
+// Existing deserialization tests
+// ---------------------------------------------------------------------------
+
+#[test]
+fn deserialize_with_meta_metamodel() {
+ let json = r#"{
+ "projects": [
+ {"path": "p1", "iris": ["urn:test:p1"]}
+ ],
+ "meta": {
+ "metamodel": "https://www.omg.org/spec/SysML/20250201"
+ }
+ }"#;
+ let raw: WorkspaceInfoRaw = serde_json::from_str(json).unwrap();
+ let info = WorkspaceInfo::try_from(raw).unwrap();
+ assert!(info.meta.is_some());
+ assert_eq!(
+ info.meta.unwrap().metamodel.unwrap().as_str(),
+ "https://www.omg.org/spec/SysML/20250201"
+ );
+}
+
+#[test]
+fn deserialize_without_meta() {
+ let json = r#"{
+ "projects": [
+ {"path": "p1", "iris": ["urn:test:p1"]}
+ ]
+ }"#;
+ let raw: WorkspaceInfoRaw = serde_json::from_str(json).unwrap();
+ let info = WorkspaceInfo::try_from(raw).unwrap();
+ assert!(info.meta.is_none());
+}
+
+#[test]
+fn deserialize_invalid_metamodel_iri() {
+ let json = r#"{
+ "projects": [],
+ "meta": {
+ "metamodel": "not a valid iri {"
+ }
+ }"#;
+ let raw: WorkspaceInfoRaw = serde_json::from_str(json).unwrap();
+ let result = WorkspaceInfo::try_from(raw);
+ assert!(result.is_err());
+ let err = result.unwrap_err();
+ assert!(matches!(err, WorkspaceValidationError::InvalidIri(..)));
+}
+
+// ---------------------------------------------------------------------------
+// Workspace project defaults and groups deserialization
+// ---------------------------------------------------------------------------
+
+#[test]
+fn deserialize_with_project_defaults() {
+ let json = r#"{
+ "projects": [],
+ "project": {
+ "version": "1.2.3",
+ "publisher": "Acme",
+ "license": "MIT"
+ }
+ }"#;
+ let raw: WorkspaceInfoRaw = serde_json::from_str(json).unwrap();
+ let info = WorkspaceInfo::try_from(raw).unwrap();
+ let proj = info.project.as_ref().unwrap();
+ assert_eq!(proj.version.as_deref(), Some("1.2.3"));
+ assert_eq!(proj.publisher.as_deref(), Some("Acme"));
+ assert_eq!(proj.license.as_deref(), Some("MIT"));
+}
+
+#[test]
+fn deserialize_with_presets() {
+ let json = r#"{
+ "projects": [],
+ "presets": {
+ "kerml": {
+ "project": { "version": "1.0.0" },
+ "meta": { "metamodel": "https://www.omg.org/spec/KerML/20250201" }
+ },
+ "sysml": {
+ "project": { "version": "2.0.0" },
+ "meta": { "metamodel": "https://www.omg.org/spec/SysML/20250201" }
+ }
+ }
+ }"#;
+ let raw: WorkspaceInfoRaw = serde_json::from_str(json).unwrap();
+ let info = WorkspaceInfo::try_from(raw).unwrap();
+ let presets = info.presets.as_ref().unwrap();
+ assert_eq!(presets.len(), 2);
+ let kerml = presets.get("kerml").unwrap();
+ assert_eq!(
+ kerml.project.as_ref().unwrap().version.as_deref(),
+ Some("1.0.0")
+ );
+ assert_eq!(
+ kerml.meta.as_ref().unwrap().metamodel.as_deref(),
+ Some("https://www.omg.org/spec/KerML/20250201")
+ );
+}
+
+// ---------------------------------------------------------------------------
+// WorkspaceInherit serde round-trips
+// ---------------------------------------------------------------------------
+
+#[test]
+fn workspace_inherit_literal_deserializes() {
+ let json = r#""1.0.0""#;
+ let val: WorkspaceInherit = serde_json::from_str(json).unwrap();
+ assert_eq!(val, WorkspaceInherit::Literal("1.0.0".to_string()));
+}
+
+#[test]
+fn workspace_inherit_default_preset_deserializes() {
+ let json = r#"{"preset": "default"}"#;
+ let val: WorkspaceInherit = serde_json::from_str(json).unwrap();
+ assert!(matches!(val, WorkspaceInherit::Preset { ref preset } if preset == "default"));
+}
+
+#[test]
+fn workspace_inherit_named_preset_deserializes() {
+ let json = r#"{"preset": "kerml"}"#;
+ let val: WorkspaceInherit = serde_json::from_str(json).unwrap();
+ assert!(matches!(val, WorkspaceInherit::Preset { ref preset } if preset == "kerml"));
+}
+
+// ---------------------------------------------------------------------------
+// resolve_project_info
+// ---------------------------------------------------------------------------
+
+fn make_workspace_info(
+ root_version: Option<&str>,
+ presets: &[(&str, &str, Option<&str>)], // (name, version, metamodel)
+ root_metamodel: Option<&str>,
+) -> WorkspaceInfo {
+ let project = root_version.map(|v| WorkspaceProjectDefaultsRaw {
+ version: Some(v.to_string()),
+ publisher: None,
+ license: None,
+ });
+
+ let presets_map: Option> =
+ if presets.is_empty() {
+ None
+ } else {
+ Some(
+ presets
+ .iter()
+ .map(|(name, version, metamodel)| {
+ (
+ name.to_string(),
+ WorkspacePresetEntryRaw {
+ project: Some(WorkspaceProjectDefaultsRaw {
+ version: Some(version.to_string()),
+ publisher: None,
+ license: None,
+ }),
+ meta: metamodel.map(|m| WorkspaceMetaRaw {
+ metamodel: Some(m.to_string()),
+ }),
+ },
+ )
+ })
+ .collect(),
+ )
+ };
+
+ let meta = root_metamodel.map(|m| {
+ use fluent_uri::Iri;
+ WorkspaceMeta {
+ metamodel: Some(Iri::parse(m.to_string()).unwrap()),
+ }
+ });
+
+ WorkspaceInfo {
+ projects: vec![],
+ meta,
+ project,
+ presets: presets_map,
+ }
+}
+
+fn make_project_info_raw(
+ version: WorkspaceInherit,
+) -> InterchangeProjectInfoWithInheritRaw {
+ InterchangeProjectInfoWithInheritRaw {
+ name: "my-project".to_string(),
+ publisher: None,
+ description: None,
+ version,
+ license: None,
+ maintainer: vec![],
+ website: None,
+ topic: vec![],
+ usage: vec![],
+ }
+}
+
+#[test]
+fn resolve_project_info_literal_version() {
+ let ws = make_workspace_info(None, &[], None);
+ let raw = make_project_info_raw(WorkspaceInherit::Literal("0.5.0".to_string()));
+ let resolved = resolve_project_info(raw, &ws).unwrap();
+ assert_eq!(resolved.version, "0.5.0");
+}
+
+#[test]
+fn resolve_project_info_default_preset() {
+ let ws = make_workspace_info(Some("3.0.0"), &[], None);
+ let raw = make_project_info_raw(WorkspaceInherit::Preset {
+ preset: "default".to_string(),
+ });
+ let resolved = resolve_project_info(raw, &ws).unwrap();
+ assert_eq!(resolved.version, "3.0.0");
+}
+
+#[test]
+fn resolve_project_info_named_preset() {
+ let ws = make_workspace_info(
+ None,
+ &[(
+ "kerml",
+ "1.0.0",
+ Some("https://www.omg.org/spec/KerML/20250201"),
+ )],
+ None,
+ );
+ let raw = make_project_info_raw(WorkspaceInherit::Preset {
+ preset: "kerml".to_string(),
+ });
+ let resolved = resolve_project_info(raw, &ws).unwrap();
+ assert_eq!(resolved.version, "1.0.0");
+}
+
+#[test]
+fn resolve_project_info_mixed_presets() {
+ // version from "sysml", publisher from "kerml" — both resolve independently
+ let ws_info = WorkspaceInfo {
+ projects: vec![],
+ meta: None,
+ project: None,
+ presets: Some({
+ let mut m = indexmap::IndexMap::new();
+ m.insert(
+ "kerml".to_string(),
+ WorkspacePresetEntryRaw {
+ project: Some(WorkspaceProjectDefaultsRaw {
+ version: Some("1.0.0".to_string()),
+ publisher: Some("KerML Corp".to_string()),
+ license: None,
+ }),
+ meta: None,
+ },
+ );
+ m.insert(
+ "sysml".to_string(),
+ WorkspacePresetEntryRaw {
+ project: Some(WorkspaceProjectDefaultsRaw {
+ version: Some("2.0.0".to_string()),
+ publisher: None,
+ license: None,
+ }),
+ meta: None,
+ },
+ );
+ m
+ }),
+ };
+ let raw = InterchangeProjectInfoWithInheritRaw {
+ name: "my-project".to_string(),
+ publisher: Some(WorkspaceInherit::Preset {
+ preset: "kerml".to_string(),
+ }),
+ description: None,
+ version: WorkspaceInherit::Preset {
+ preset: "sysml".to_string(),
+ },
+ license: None,
+ maintainer: vec![],
+ website: None,
+ topic: vec![],
+ usage: vec![],
+ };
+ let resolved = resolve_project_info(raw, &ws_info).unwrap();
+ assert_eq!(resolved.version, "2.0.0");
+ assert_eq!(resolved.publisher.as_deref(), Some("KerML Corp"));
+}
+
+#[test]
+fn resolve_project_info_unknown_preset_error() {
+ let ws = make_workspace_info(None, &[], None);
+ let raw = make_project_info_raw(WorkspaceInherit::Preset {
+ preset: "nonexistent".to_string(),
+ });
+ let err = resolve_project_info(raw, &ws).unwrap_err();
+ assert!(matches!(
+ err,
+ WorkspaceInheritanceError::UnknownPreset { .. }
+ ));
+}
+
+#[test]
+fn resolve_project_info_missing_root_default_error() {
+ let ws = make_workspace_info(None, &[], None); // no project defaults
+ let raw = make_project_info_raw(WorkspaceInherit::Preset {
+ preset: "default".to_string(),
+ });
+ let err = resolve_project_info(raw, &ws).unwrap_err();
+ assert!(matches!(
+ err,
+ WorkspaceInheritanceError::MissingRootDefault { .. }
+ ));
+}
+
+#[test]
+fn resolve_project_info_missing_preset_default_error() {
+ // Preset exists but has no version
+ let ws_info = WorkspaceInfo {
+ projects: vec![],
+ meta: None,
+ project: None,
+ presets: Some({
+ let mut m = indexmap::IndexMap::new();
+ m.insert(
+ "kerml".to_string(),
+ WorkspacePresetEntryRaw {
+ project: None, // no project defaults
+ meta: None,
+ },
+ );
+ m
+ }),
+ };
+ let raw = make_project_info_raw(WorkspaceInherit::Preset {
+ preset: "kerml".to_string(),
+ });
+ let err = resolve_project_info(raw, &ws_info).unwrap_err();
+ assert!(matches!(
+ err,
+ WorkspaceInheritanceError::MissingPresetDefault { .. }
+ ));
+}
+
+#[test]
+fn project_info_without_workspace_literal_passes() {
+ let raw = make_project_info_raw(WorkspaceInherit::Literal("1.0.0".to_string()));
+ let resolved = crate::workspace::project_info_without_workspace(raw).unwrap();
+ assert_eq!(resolved.version, "1.0.0");
+}
+
+#[test]
+fn project_info_without_workspace_ref_errors() {
+ let raw = make_project_info_raw(WorkspaceInherit::Preset {
+ preset: "default".to_string(),
+ });
+ let err = crate::workspace::project_info_without_workspace(raw).unwrap_err();
+ assert!(matches!(err, WorkspaceInheritanceError::NoWorkspace { .. }));
+}
+
+// ---------------------------------------------------------------------------
+// resolve_project_metadata
+// ---------------------------------------------------------------------------
+
+fn make_meta_raw(
+ metamodel: Option>,
+) -> InterchangeProjectMetadataWithInheritRaw {
+ InterchangeProjectMetadataWithInheritRaw {
+ index: indexmap::IndexMap::new(),
+ created: "2026-01-01T00:00:00Z".to_string(),
+ metamodel,
+ includes_derived: None,
+ includes_implied: None,
+ checksum: None,
+ }
+}
+
+#[test]
+fn resolve_project_metadata_no_metamodel() {
+ let ws = make_workspace_info(None, &[], None);
+ let raw = make_meta_raw(None);
+ let resolved = crate::workspace::resolve_project_metadata(raw, &ws, "my-project").unwrap();
+ assert!(resolved.metamodel.is_none());
+}
+
+#[test]
+fn resolve_project_metadata_literal_metamodel() {
+ let ws = make_workspace_info(None, &[], None);
+ let raw = make_meta_raw(Some(WorkspaceInherit::Literal(
+ "https://www.omg.org/spec/KerML/20250201".to_string(),
+ )));
+ let resolved = crate::workspace::resolve_project_metadata(raw, &ws, "my-project").unwrap();
+ assert_eq!(
+ resolved.metamodel.as_deref(),
+ Some("https://www.omg.org/spec/KerML/20250201")
+ );
+}
+
+#[test]
+fn resolve_project_metadata_default_preset() {
+ let ws = make_workspace_info(None, &[], Some("https://www.omg.org/spec/SysML/20250201"));
+ let raw = make_meta_raw(Some(WorkspaceInherit::Preset {
+ preset: "default".to_string(),
+ }));
+ let resolved = crate::workspace::resolve_project_metadata(raw, &ws, "my-project").unwrap();
+ assert_eq!(
+ resolved.metamodel.as_deref(),
+ Some("https://www.omg.org/spec/SysML/20250201")
+ );
+}
+
+#[test]
+fn resolve_project_metadata_named_preset() {
+ let ws = make_workspace_info(
+ None,
+ &[(
+ "kerml",
+ "1.0.0",
+ Some("https://www.omg.org/spec/KerML/20250201"),
+ )],
+ None,
+ );
+ let raw = make_meta_raw(Some(WorkspaceInherit::Preset {
+ preset: "kerml".to_string(),
+ }));
+ let resolved = crate::workspace::resolve_project_metadata(raw, &ws, "my-project").unwrap();
+ assert_eq!(
+ resolved.metamodel.as_deref(),
+ Some("https://www.omg.org/spec/KerML/20250201")
+ );
+}
+
+#[test]
+fn project_metadata_without_workspace_literal_passes() {
+ let raw = make_meta_raw(Some(WorkspaceInherit::Literal("some_iri".to_string())));
+ let resolved = crate::workspace::project_metadata_without_workspace(raw, "my-project").unwrap();
+ assert_eq!(resolved.metamodel.as_deref(), Some("some_iri"));
+}
+
+#[test]
+fn project_metadata_without_workspace_ref_errors() {
+ let raw = make_meta_raw(Some(WorkspaceInherit::Preset {
+ preset: "default".to_string(),
+ }));
+ let err = crate::workspace::project_metadata_without_workspace(raw, "my-project").unwrap_err();
+ assert!(matches!(err, WorkspaceInheritanceError::NoWorkspace { .. }));
+}
+
+// ---------------------------------------------------------------------------
+// Workspace validation: reserved preset name and root+preset conflict
+// ---------------------------------------------------------------------------
+
+#[test]
+fn reserved_preset_name_default_is_rejected() {
+ let json = r#"{
+ "projects": [],
+ "presets": {
+ "default": { "project": { "version": "1.0.0" } }
+ }
+ }"#;
+ let raw: WorkspaceInfoRaw = serde_json::from_str(json).unwrap();
+ let err = WorkspaceInfo::try_from(raw).unwrap_err();
+ assert!(matches!(err, WorkspaceValidationError::ReservedPresetName));
+}
+
+#[test]
+fn root_and_preset_version_conflict_is_rejected() {
+ let json = r#"{
+ "projects": [],
+ "project": { "version": "1.0.0" },
+ "presets": {
+ "kerml": { "project": { "version": "2.0.0" } }
+ }
+ }"#;
+ let raw: WorkspaceInfoRaw = serde_json::from_str(json).unwrap();
+ let err = WorkspaceInfo::try_from(raw).unwrap_err();
+ assert!(matches!(
+ err,
+ WorkspaceValidationError::RootAndPresetConflict {
+ field: "version",
+ ..
+ }
+ ));
+}
diff --git a/core/src/workspace/types.rs b/core/src/workspace/types.rs
new file mode 100644
index 000000000..6e5d52770
--- /dev/null
+++ b/core/src/workspace/types.rs
@@ -0,0 +1,175 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// SPDX-FileCopyrightText: © 2026 Sysand contributors
+
+use fluent_uri::Iri;
+use indexmap::IndexMap;
+
+#[cfg(feature = "python")]
+use pyo3::{FromPyObject, IntoPyObject};
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use crate::model::KNOWN_METAMODELS;
+
+/// Workspace-level defaults for inheritable `.project.json` fields.
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[cfg_attr(feature = "python", derive(FromPyObject, IntoPyObject))]
+#[serde(rename_all = "camelCase")]
+pub struct WorkspaceProjectDefaultsRaw {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub version: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub publisher: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub license: Option,
+}
+
+/// A named workspace preset: project-level defaults and optional meta defaults.
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[cfg_attr(feature = "python", derive(FromPyObject, IntoPyObject))]
+#[serde(rename_all = "camelCase")]
+pub struct WorkspacePresetEntryRaw {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub project: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub meta: Option,
+}
+
+#[derive(Eq, Clone, PartialEq, Serialize, Deserialize, Debug)]
+#[cfg_attr(feature = "python", derive(FromPyObject, IntoPyObject))]
+#[serde(rename_all = "camelCase")]
+pub struct WorkspaceProjectInfoG {
+ pub path: String,
+ pub iris: Vec,
+}
+
+#[derive(Eq, Clone, PartialEq, Serialize, Deserialize, Debug, Default)]
+#[cfg_attr(feature = "python", derive(FromPyObject, IntoPyObject))]
+#[serde(rename_all = "camelCase")]
+pub struct WorkspaceMetaG {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub metamodel: Option,
+}
+
+pub type WorkspaceMetaRaw = WorkspaceMetaG;
+pub type WorkspaceMeta = WorkspaceMetaG>;
+
+#[derive(Error, Debug)]
+pub enum WorkspaceValidationError {
+ #[error("failed to parse `{0}` as IRI: {1}")]
+ InvalidIri(String, fluent_uri::ParseError),
+
+ #[error(
+ "preset name `default` is reserved for workspace root defaults \
+ and cannot be used as an explicit preset name"
+ )]
+ ReservedPresetName,
+
+ #[error(
+ "workspace field `{field}` is defined in both `project` (root defaults) \
+ and preset `{preset}` — a field may appear in at most one of these"
+ )]
+ RootAndPresetConflict { field: &'static str, preset: String },
+}
+
+#[derive(Eq, Clone, PartialEq, Serialize, Deserialize, Debug)]
+#[cfg_attr(feature = "python", derive(FromPyObject, IntoPyObject))]
+#[serde(rename_all = "camelCase")]
+pub struct WorkspaceInfoG {
+ pub projects: Vec>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub meta: Option>,
+ /// Workspace-level defaults for inheritable project fields.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub project: Option,
+ /// Named project presets, each with their own project defaults and meta.
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub presets: Option>,
+}
+
+pub type WorkspaceInfoRaw = WorkspaceInfoG;
+pub type WorkspaceInfo = WorkspaceInfoG>;
+pub type WorkspaceProjectInfoRaw = WorkspaceProjectInfoG;
+pub type WorkspaceProjectInfo = WorkspaceProjectInfoG>;
+
+impl TryFrom for WorkspaceInfo {
+ type Error = WorkspaceValidationError;
+
+ fn try_from(value: WorkspaceInfoRaw) -> Result {
+ let mut projects = Vec::with_capacity(value.projects.len());
+ for project in value.projects {
+ let mut iris = Vec::with_capacity(project.iris.len());
+ for iri in project.iris {
+ let iri = Iri::parse(iri)
+ .map_err(|(e, iri)| WorkspaceValidationError::InvalidIri(iri, e))?;
+ iris.push(iri);
+ }
+ projects.push(WorkspaceProjectInfo {
+ path: project.path,
+ iris,
+ });
+ }
+
+ let meta = value
+ .meta
+ .map(|raw_meta| {
+ let metamodel = raw_meta
+ .metamodel
+ .map(|m| {
+ if !KNOWN_METAMODELS.contains(&m.as_str()) {
+ log::warn!("workspace uses an unknown metamodel `{m}`");
+ }
+ Iri::parse(m)
+ .map_err(|(e, iri)| WorkspaceValidationError::InvalidIri(iri, e))
+ })
+ .transpose()?;
+ Ok(WorkspaceMeta { metamodel })
+ })
+ .transpose()?;
+
+ // Validate presets: "default" is reserved and root+preset field conflicts are illegal.
+ if let Some(ref presets) = value.presets {
+ if presets.contains_key("default") {
+ return Err(WorkspaceValidationError::ReservedPresetName);
+ }
+ if let Some(ref root_project) = value.project {
+ for (preset_name, preset_entry) in presets {
+ if let Some(ref preset_project) = preset_entry.project {
+ for (field, root_set, preset_set) in [
+ (
+ "version",
+ root_project.version.is_some(),
+ preset_project.version.is_some(),
+ ),
+ (
+ "publisher",
+ root_project.publisher.is_some(),
+ preset_project.publisher.is_some(),
+ ),
+ (
+ "license",
+ root_project.license.is_some(),
+ preset_project.license.is_some(),
+ ),
+ ] {
+ if root_set && preset_set {
+ return Err(WorkspaceValidationError::RootAndPresetConflict {
+ field,
+ preset: preset_name.clone(),
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // `project` and `presets` fields use only String-based types; pass through unchanged.
+ Ok(Self {
+ projects,
+ meta,
+ project: value.project,
+ presets: value.presets,
+ })
+ }
+}
diff --git a/core/src/workspace_tests.rs b/core/src/workspace_tests.rs
deleted file mode 100644
index 0fd365860..000000000
--- a/core/src/workspace_tests.rs
+++ /dev/null
@@ -1,50 +0,0 @@
-// SPDX-License-Identifier: MIT OR Apache-2.0
-// SPDX-FileCopyrightText: © 2026 Sysand contributors
-
-use super::*;
-
-#[test]
-fn deserialize_with_meta_metamodel() {
- let json = r#"{
- "projects": [
- {"path": "p1", "iris": ["urn:test:p1"]}
- ],
- "meta": {
- "metamodel": "https://www.omg.org/spec/SysML/20250201"
- }
- }"#;
- let raw: WorkspaceInfoRaw = serde_json::from_str(json).unwrap();
- let info = WorkspaceInfo::try_from(raw).unwrap();
- assert!(info.meta.is_some());
- assert_eq!(
- info.meta.unwrap().metamodel.unwrap().as_str(),
- "https://www.omg.org/spec/SysML/20250201"
- );
-}
-
-#[test]
-fn deserialize_without_meta() {
- let json = r#"{
- "projects": [
- {"path": "p1", "iris": ["urn:test:p1"]}
- ]
- }"#;
- let raw: WorkspaceInfoRaw = serde_json::from_str(json).unwrap();
- let info = WorkspaceInfo::try_from(raw).unwrap();
- assert!(info.meta.is_none());
-}
-
-#[test]
-fn deserialize_invalid_metamodel_iri() {
- let json = r#"{
- "projects": [],
- "meta": {
- "metamodel": "not a valid iri {"
- }
- }"#;
- let raw: WorkspaceInfoRaw = serde_json::from_str(json).unwrap();
- let result = WorkspaceInfo::try_from(raw);
- assert!(result.is_err());
- let err = result.unwrap_err();
- assert!(matches!(err, WorkspaceValidationError::InvalidIri(..)));
-}
diff --git a/docs/src/workspaces.md b/docs/src/workspaces.md
index 500e5e532..e8bcc4074 100644
--- a/docs/src/workspaces.md
+++ b/docs/src/workspaces.md
@@ -39,11 +39,13 @@ the workspace.
used to refer to the project from other projects in the workspace
instead of using `file://` URLs
- `meta` (optional): An object containing workspace-level metadata:
- - `metamodel` (optional): An IRI specifying the metamodel for all projects
- in the workspace. When set, individual projects must **not** also set
- `metamodel` in their `.meta.json` — doing so will produce an error.
- During build, the workspace metamodel is injected into each project
- that does not already have one set.
+ - `metamodel` (optional): An IRI specifying a default metamodel that can
+ be referenced from project `.meta.json` files using
+ `{ "preset": "default" }`. See [Inheriting fields from workspace defaults](#inheriting-fields-from-workspace-defaults).
+- `project` (optional): An object with default values for inheritable
+ project fields. See [Inheriting fields from workspace defaults](#inheriting-fields-from-workspace-defaults).
+- `presets` (optional): A map of named presets, each with their own
+ `project` and/or `meta` defaults. See [Inheriting fields from workspace defaults](#inheriting-fields-from-workspace-defaults).
## Example
@@ -64,9 +66,108 @@ An example `.workspace.json` file:
"path": "project3",
"iris": ["urn:local:project3"]
}
- ],
- "meta": {
- "metamodel": "https://www.omg.org/spec/SysML/20250201"
+ ]
+}
+```
+
+## Inheriting fields from workspace defaults
+
+When many projects in a workspace share the same version, publisher, license,
+or metamodel, you can define these values once in `.workspace.json` and
+reference them from each project instead of repeating them.
+
+### Root defaults
+
+Define a `project` object at the top level of `.workspace.json`:
+
+```json
+{
+ "projects": [...],
+ "project": {
+ "version": "2.0.0",
+ "publisher": "Acme Corp",
+ "license": "MIT"
}
}
```
+
+Reference a root default in `.project.json` using `{ "preset": "default" }`:
+
+```json
+{
+ "name": "my-project",
+ "version": { "preset": "default" },
+ "publisher": { "preset": "default" },
+ "usage": []
+}
+```
+
+To inherit the workspace-level `metamodel` in `.meta.json`:
+
+```json
+{
+ "index": { ... },
+ "created": "...",
+ "metamodel": { "preset": "default" }
+}
+```
+
+### Named presets
+
+For workspaces with projects that fall into distinct categories (for example
+KerML vs SysML projects), you can define named presets under the `presets` key.
+Each preset may have a `project` section (for inheritable `.project.json`
+fields) and/or a `meta` section (for the `metamodel` field).
+
+```json
+{
+ "projects": [...],
+ "presets": {
+ "kerml": {
+ "project": { "version": "1.0.0" },
+ "meta": { "metamodel": "https://www.omg.org/spec/KerML/20250201" }
+ },
+ "sysml": {
+ "project": { "version": "2.0.0" },
+ "meta": { "metamodel": "https://www.omg.org/spec/SysML/20250201" }
+ }
+ }
+}
+```
+
+Reference a named preset in `.project.json`:
+
+```json
+{
+ "name": "my-kerml-project",
+ "version": { "preset": "kerml" },
+ "usage": []
+}
+```
+
+Reference a named preset's `metamodel` in `.meta.json`:
+
+```json
+{
+ "index": { ... },
+ "created": "...",
+ "metamodel": { "preset": "kerml" }
+}
+```
+
+### Inheritable fields
+
+| File | Field |
+| --------------- | --------------------------------- |
+| `.project.json` | `version`, `publisher`, `license` |
+| `.meta.json` | `metamodel` |
+
+### Conflict rules
+
+- A field may be defined either in the root `project` section **or** in a
+ preset — not both. For example, if `project.version` is set, no preset may
+ also set `project.version`.
+- Two sibling presets may both define the same field independently (projects
+ choose at most one preset per field).
+- The preset name `"default"` is reserved; it refers to the root `project`
+ defaults and cannot be used as a named preset key.
diff --git a/sysand/src/commands/build.rs b/sysand/src/commands/build.rs
index 12c525d96..4d833b479 100644
--- a/sysand/src/commands/build.rs
+++ b/sysand/src/commands/build.rs
@@ -15,7 +15,14 @@ pub fn command_build_for_project>(
current_project: LocalSrcProject,
allow_path_usage: bool,
) -> Result<()> {
- match do_build_kpar(¤t_project, &path, compression, true, allow_path_usage) {
+ match do_build_kpar(
+ ¤t_project,
+ &path,
+ compression,
+ true,
+ allow_path_usage,
+ None,
+ ) {
Ok(_) => Ok(()),
Err(err) => match err {
KParBuildError::PathUsage(_) => bail!(
@@ -39,7 +46,7 @@ pub fn command_build_for_workspace>(
releases. For the status of this feature, see\n\
https://github.com/sensmetry/sysand/issues/101."
);
- do_build_workspace_kpars(&workspace, &path, compression, true, allow_path_usage)?;
+ do_build_workspace_kpars(&workspace, &path, compression, true, allow_path_usage, None)?;
Ok(())
}
diff --git a/sysand/tests/cli_build.rs b/sysand/tests/cli_build.rs
index b7509ffde..e0fe0cc1d 100644
--- a/sysand/tests/cli_build.rs
+++ b/sysand/tests/cli_build.rs
@@ -207,9 +207,10 @@ fn workspace_build() -> Result<(), Box> {
Ok(())
}
-/// Workspace with `meta.metamodel` set — projects without metamodel get it injected
+/// Workspace `meta.metamodel` is NOT auto-injected — projects must explicitly
+/// inherit via `{ "workspace": true }` in `.meta.json`.
#[test]
-fn workspace_build_with_metamodel() -> Result<(), Box> {
+fn workspace_build_metamodel_not_auto_injected() -> Result<(), Box> {
let (_temp_dir, cwd) = new_temp_cwd()?;
let project1_cwd = cwd.join("project1");
@@ -255,75 +256,17 @@ fn workspace_build_with_metamodel() -> Result<(), Box> {
let (Some(_), Some(meta)) = kpar_project.get_project()? else {
panic!("failed to get built project info/meta");
};
- assert_eq!(
- meta.metamodel.as_deref(),
- Some("https://www.omg.org/spec/SysML/20250201")
- );
-
- Ok(())
-}
-
-/// Workspace with unknown `meta.metamodel` — build succeeds with a warning
-#[test]
-fn workspace_build_with_unknown_metamodel() -> Result<(), Box> {
- let (_temp_dir, cwd) = new_temp_cwd()?;
- let project1_cwd = cwd.join("project1");
-
- std::fs::write(
- cwd.join(".workspace.json"),
- br#"{
- "projects": [
- {"path": "project1", "iris": ["urn:kpar:project1"]}
- ],
- "meta": {
- "metamodel": "https://www.omg.org/spec/SysML/20251201"
- }
- }"#,
- )?;
-
- std::fs::create_dir(&project1_cwd)?;
- let out = run_sysand_in(
- &project1_cwd,
- ["init", "--version", "1.0.0", "--name", "project1"],
- None,
- )?;
- out.assert().success();
-
- std::fs::write(project1_cwd.join("test.sysml"), b"package P;\n")?;
- let out = run_sysand_in(
- &project1_cwd,
- ["include", "--no-index-symbols", "test.sysml"],
- None,
- )?;
- out.assert().success();
-
- let out = run_sysand_in(&cwd, ["build"], None)?;
- out.assert()
- .success()
- .stderr(predicate::str::contains("unknown metamodel"));
-
- let kpar_path = cwd.join("output").join("project1-1.0.0.kpar");
assert!(
- kpar_path.is_file(),
- "kpar file does not exist: {}",
- kpar_path
- );
-
- let kpar_project = LocalKParProject::new_guess_root(kpar_path)?;
- let (Some(_), Some(meta)) = kpar_project.get_project()? else {
- panic!("failed to get built project info/meta");
- };
- assert_eq!(
- meta.metamodel.as_deref(),
- Some("https://www.omg.org/spec/SysML/20251201")
+ meta.metamodel.is_none(),
+ "metamodel should be None when project does not explicitly inherit it"
);
Ok(())
}
-/// Workspace with `meta.metamodel` + project that also has metamodel — build fails
+/// Project inherits metamodel from workspace root via `{ "workspace": true }` in `.meta.json`.
#[test]
-fn workspace_build_metamodel_conflict() -> Result<(), Box> {
+fn workspace_inherit_metamodel_from_root_in_meta_json() -> Result<(), Box> {
let (_temp_dir, cwd) = new_temp_cwd()?;
let project1_cwd = cwd.join("project1");
@@ -355,144 +298,32 @@ fn workspace_build_metamodel_conflict() -> Result<(), Box
)?;
out.assert().success();
- // Set metamodel in the project's .meta.json to create a conflict
+ // Explicitly inherit metamodel from workspace root
let meta_path = project1_cwd.join(".meta.json");
let meta_content = std::fs::read_to_string(&meta_path)?;
- let mut meta: serde_json::Value = serde_json::from_str(&meta_content)?;
- meta["metamodel"] =
- serde_json::Value::String("https://www.omg.org/spec/KerML/20250201".to_string());
- std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
+ let mut meta_json: serde_json::Value = serde_json::from_str(&meta_content)?;
+ meta_json["metamodel"] = serde_json::json!({"preset": "default"});
+ std::fs::write(&meta_path, serde_json::to_string_pretty(&meta_json)?)?;
- let out = run_sysand_in(&cwd, ["build"], None)?;
- out.assert()
- .failure()
- .stderr(predicate::str::contains("sets a different metamodel"));
-
- Ok(())
-}
-
-/// Workspace and project set the **same** metamodel — no conflict, build succeeds
-#[test]
-fn workspace_build_metamodel_same_no_conflict() -> Result<(), Box> {
- let (_temp_dir, cwd) = new_temp_cwd()?;
- let project1_cwd = cwd.join("project1");
-
- std::fs::write(
- cwd.join(".workspace.json"),
- br#"{
- "projects": [
- {"path": "project1", "iris": ["urn:kpar:project1"]}
- ],
- "meta": {
- "metamodel": "https://www.omg.org/spec/SysML/20250201"
- }
- }"#,
- )?;
-
- std::fs::create_dir(&project1_cwd)?;
- let out = run_sysand_in(
- &project1_cwd,
- ["init", "--version", "1.0.0", "--name", "project1"],
- None,
- )?;
- out.assert().success();
-
- std::fs::write(project1_cwd.join("test.sysml"), b"package P;\n")?;
- let out = run_sysand_in(
- &project1_cwd,
- ["include", "--no-index-symbols", "test.sysml"],
- None,
- )?;
- out.assert().success();
-
- // Set the same metamodel in the project's .meta.json — should NOT conflict
- let meta_path = project1_cwd.join(".meta.json");
- let meta_content = std::fs::read_to_string(&meta_path)?;
- let mut meta: serde_json::Value = serde_json::from_str(&meta_content)?;
- meta["metamodel"] =
- serde_json::Value::String("https://www.omg.org/spec/SysML/20250201".to_string());
- std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
-
- let out = run_sysand_in(&cwd, ["build"], None)?;
- out.assert().success();
-
- Ok(())
-}
-
-/// Building a workspace with `meta.metamodel` twice must succeed both times.
-/// This verifies that `put_meta` only writes to the temp directory, not the
-/// original project directory, so the conflict check sees the same unmodified
-/// `.meta.json` on both runs.
-#[test]
-fn workspace_build_metamodel_idempotent() -> Result<(), Box> {
- let (_temp_dir, cwd) = new_temp_cwd()?;
- let project1_cwd = cwd.join("project1");
-
- std::fs::write(
- cwd.join(".workspace.json"),
- br#"{
- "projects": [
- {"path": "project1", "iris": ["urn:kpar:project1"]}
- ],
- "meta": {
- "metamodel": "https://www.omg.org/spec/SysML/20250201"
- }
- }"#,
- )?;
-
- std::fs::create_dir(&project1_cwd)?;
- let out = run_sysand_in(
- &project1_cwd,
- ["init", "--version", "1.0.0", "--name", "project1"],
- None,
- )?;
- out.assert().success();
-
- std::fs::write(project1_cwd.join("test.sysml"), b"package P;\n")?;
- let out = run_sysand_in(
- &project1_cwd,
- ["include", "--no-index-symbols", "test.sysml"],
- None,
- )?;
- out.assert().success();
-
- // First build
let out = run_sysand_in(&cwd, ["build"], None)?;
out.assert().success();
let kpar_path = cwd.join("output").join("project1-1.0.0.kpar");
- assert!(kpar_path.is_file());
-
- let kpar_project = LocalKParProject::new_guess_root(&kpar_path)?;
- let (Some(_), Some(meta)) = kpar_project.get_project()? else {
- panic!("failed to get built project info/meta");
- };
- assert_eq!(
- meta.metamodel.as_deref(),
- Some("https://www.omg.org/spec/SysML/20250201")
+ assert!(
+ kpar_path.is_file(),
+ "kpar file does not exist: {}",
+ kpar_path
);
- // Second build — must also succeed (no conflict from first build's injection)
- let out = run_sysand_in(&cwd, ["build"], None)?;
- out.assert().success();
-
- let kpar_project = LocalKParProject::new_guess_root(&kpar_path)?;
+ let kpar_project = LocalKParProject::new_guess_root(kpar_path)?;
let (Some(_), Some(meta)) = kpar_project.get_project()? else {
- panic!("failed to get built project info/meta on second build");
+ panic!("failed to get built project info/meta");
};
assert_eq!(
meta.metamodel.as_deref(),
Some("https://www.omg.org/spec/SysML/20250201")
);
- // Verify original project .meta.json was NOT modified
- let original_meta_content = std::fs::read_to_string(project1_cwd.join(".meta.json"))?;
- let original_meta: serde_json::Value = serde_json::from_str(&original_meta_content)?;
- assert!(
- original_meta.get("metamodel").is_none(),
- "original project .meta.json should not have metamodel set"
- );
-
Ok(())
}
@@ -1059,3 +890,290 @@ fn compression_method(compression: Option<&str>) -> Result<(), Box Result<(), Box> {
+ std::fs::create_dir(project_cwd)?;
+ std::fs::write(project_cwd.join("test.sysml"), b"package P;\n")?;
+
+ // Init with a placeholder version so `include` can read a valid .project.json.
+ run_sysand_in(
+ project_cwd,
+ ["init", "--version", "0.0.0", "--name", project_name],
+ None,
+ )?
+ .assert()
+ .success();
+
+ run_sysand_in(
+ project_cwd,
+ ["include", "--no-index-symbols", "test.sysml"],
+ None,
+ )?
+ .assert()
+ .success();
+
+ // Now overwrite with the final .project.json (may contain workspace refs).
+ std::fs::write(project_cwd.join(".project.json"), final_project_json)?;
+ Ok(())
+}
+
+/// Workspace project inherits version from workspace root `project` defaults
+/// using `"version": { "preset": "default" }`.
+#[test]
+fn workspace_inherit_version_from_root() -> Result<(), Box> {
+ let (_temp_dir, cwd) = new_temp_cwd()?;
+ let project1_cwd = cwd.join("project1");
+
+ std::fs::write(
+ cwd.join(".workspace.json"),
+ br#"{
+ "projects": [
+ {"path": "project1", "iris": ["urn:kpar:project1"]}
+ ],
+ "project": {
+ "version": "3.0.0"
+ }
+ }"#,
+ )?;
+
+ setup_workspace_project(
+ &project1_cwd,
+ "project1",
+ br#"{"name": "project1", "version": {"preset": "default"}, "usage": []}"#,
+ )?;
+
+ let out = run_sysand_in(&cwd, ["build"], None)?;
+ out.assert().success();
+
+ let kpar_path = cwd.join("output").join("project1-3.0.0.kpar");
+ assert!(kpar_path.is_file(), "expected output/project1-3.0.0.kpar");
+
+ let kpar_project = LocalKParProject::new_guess_root(&kpar_path)?;
+ let (Some(info), _) = kpar_project.get_project()? else {
+ panic!("failed to get project info");
+ };
+ assert_eq!(info.version, "3.0.0");
+
+ Ok(())
+}
+
+/// Workspace project inherits version from a named preset. Metamodel is NOT
+/// implicitly inherited — it must be explicitly referenced in `.meta.json`.
+#[test]
+fn workspace_inherit_version_from_group() -> Result<(), Box> {
+ let (_temp_dir, cwd) = new_temp_cwd()?;
+ let project1_cwd = cwd.join("project1");
+
+ std::fs::write(
+ cwd.join(".workspace.json"),
+ br#"{
+ "projects": [
+ {"path": "project1", "iris": ["urn:kpar:project1"]}
+ ],
+ "presets": {
+ "kerml": {
+ "project": { "version": "1.0.0" },
+ "meta": { "metamodel": "https://www.omg.org/spec/KerML/20250201" }
+ }
+ }
+ }"#,
+ )?;
+
+ setup_workspace_project(
+ &project1_cwd,
+ "project1",
+ br#"{"name": "project1", "version": {"preset": "kerml"}, "usage": []}"#,
+ )?;
+
+ let out = run_sysand_in(&cwd, ["build"], None)?;
+ out.assert().success();
+
+ let kpar_path = cwd.join("output").join("project1-1.0.0.kpar");
+ assert!(kpar_path.is_file(), "expected output/project1-1.0.0.kpar");
+
+ let kpar_project = LocalKParProject::new_guess_root(&kpar_path)?;
+ let (Some(info), Some(meta)) = kpar_project.get_project()? else {
+ panic!("failed to get project info/meta");
+ };
+ assert_eq!(info.version, "1.0.0");
+ assert!(
+ meta.metamodel.is_none(),
+ "metamodel should be None when not explicitly inherited"
+ );
+
+ Ok(())
+}
+
+/// `.meta.json` with `"metamodel": { "preset": "kerml" }` resolves the
+/// metamodel from the named preset.
+#[test]
+fn workspace_inherit_metamodel_from_group_in_meta_json() -> Result<(), Box> {
+ let (_temp_dir, cwd) = new_temp_cwd()?;
+ let project1_cwd = cwd.join("project1");
+
+ std::fs::write(
+ cwd.join(".workspace.json"),
+ br#"{
+ "projects": [
+ {"path": "project1", "iris": ["urn:kpar:project1"]}
+ ],
+ "presets": {
+ "kerml": {
+ "project": { "version": "1.0.0" },
+ "meta": { "metamodel": "https://www.omg.org/spec/KerML/20250201" }
+ }
+ }
+ }"#,
+ )?;
+
+ // Version is literal; only metamodel inherits from the preset via .meta.json
+ setup_workspace_project(
+ &project1_cwd,
+ "project1",
+ br#"{"name": "project1", "version": "2.0.0", "usage": []}"#,
+ )?;
+ // Overwrite the generated .meta.json with a preset metamodel reference
+ let meta_path = project1_cwd.join(".meta.json");
+ let meta_content = std::fs::read_to_string(&meta_path)?;
+ let mut meta_json: serde_json::Value = serde_json::from_str(&meta_content)?;
+ meta_json["metamodel"] = serde_json::json!({"preset": "kerml"});
+ std::fs::write(&meta_path, serde_json::to_string_pretty(&meta_json)?)?;
+
+ let out = run_sysand_in(&cwd, ["build"], None)?;
+ out.assert().success();
+
+ let kpar_path = cwd.join("output").join("project1-2.0.0.kpar");
+ assert!(kpar_path.is_file(), "expected output/project1-2.0.0.kpar");
+
+ let kpar_project = LocalKParProject::new_guess_root(&kpar_path)?;
+ let (_, Some(meta)) = kpar_project.get_project()? else {
+ panic!("failed to get project meta");
+ };
+ assert_eq!(
+ meta.metamodel.as_deref(),
+ Some("https://www.omg.org/spec/KerML/20250201")
+ );
+
+ Ok(())
+}
+
+/// Referencing an unknown workspace preset reports a clear error.
+#[test]
+fn workspace_inherit_unknown_group_error() -> Result<(), Box> {
+ let (_temp_dir, cwd) = new_temp_cwd()?;
+ let project1_cwd = cwd.join("project1");
+
+ std::fs::write(
+ cwd.join(".workspace.json"),
+ br#"{
+ "projects": [
+ {"path": "project1", "iris": ["urn:kpar:project1"]}
+ ]
+ }"#,
+ )?;
+
+ setup_workspace_project(
+ &project1_cwd,
+ "project1",
+ br#"{"name": "project1", "version": {"preset": "nonexistent"}, "usage": []}"#,
+ )?;
+
+ let out = run_sysand_in(&cwd, ["build"], None)?;
+ out.assert()
+ .failure()
+ .stderr(predicate::str::contains("nonexistent"));
+
+ Ok(())
+}
+
+/// Workspace project with inherited publisher and license from root defaults.
+#[test]
+fn workspace_inherit_publisher_and_license_from_root() -> Result<(), Box> {
+ let (_temp_dir, cwd) = new_temp_cwd()?;
+ let project1_cwd = cwd.join("project1");
+
+ std::fs::write(
+ cwd.join(".workspace.json"),
+ br#"{
+ "projects": [
+ {"path": "project1", "iris": ["urn:kpar:project1"]}
+ ],
+ "project": {
+ "version": "1.0.0",
+ "publisher": "Acme Corp",
+ "license": "Apache-2.0"
+ }
+ }"#,
+ )?;
+
+ setup_workspace_project(
+ &project1_cwd,
+ "project1",
+ br#"{"name": "project1", "version": {"preset": "default"}, "publisher": {"preset": "default"}, "license": {"preset": "default"}, "usage": []}"#,
+ )?;
+
+ let out = run_sysand_in(&cwd, ["build"], None)?;
+ out.assert().success();
+
+ let kpar_path = cwd.join("output").join("project1-1.0.0.kpar");
+ assert!(kpar_path.is_file(), "expected output/project1-1.0.0.kpar");
+
+ let kpar_project = LocalKParProject::new_guess_root(&kpar_path)?;
+ let (Some(info), _) = kpar_project.get_project()? else {
+ panic!("failed to get project info");
+ };
+ assert_eq!(info.version, "1.0.0");
+ assert_eq!(info.publisher.as_deref(), Some("Acme Corp"));
+ assert_eq!(info.license.as_deref(), Some("Apache-2.0"));
+
+ Ok(())
+}
+
+/// Workspace inheritance is idempotent — building twice succeeds.
+#[test]
+fn workspace_inherit_version_idempotent() -> Result<(), Box> {
+ let (_temp_dir, cwd) = new_temp_cwd()?;
+ let project1_cwd = cwd.join("project1");
+
+ std::fs::write(
+ cwd.join(".workspace.json"),
+ br#"{
+ "projects": [
+ {"path": "project1", "iris": ["urn:kpar:project1"]}
+ ],
+ "project": { "version": "5.0.0" }
+ }"#,
+ )?;
+
+ setup_workspace_project(
+ &project1_cwd,
+ "project1",
+ br#"{"name": "project1", "version": {"preset": "default"}, "usage": []}"#,
+ )?;
+
+ // First build
+ run_sysand_in(&cwd, ["build"], None)?.assert().success();
+ // Second build — must also succeed
+ run_sysand_in(&cwd, ["build"], None)?.assert().success();
+
+ // Verify original .project.json was NOT modified (still has preset ref)
+ let original_content = std::fs::read_to_string(project1_cwd.join(".project.json"))?;
+ let original: serde_json::Value = serde_json::from_str(&original_content)?;
+ assert_eq!(
+ original["version"],
+ serde_json::json!({"preset": "default"}),
+ "original .project.json should still contain the preset reference"
+ );
+
+ Ok(())
+}