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-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-metamodel/invoker.properties b/bindings/java/plugin/src/it/workspace-inherit-group/invoker.properties similarity index 100% rename from bindings/java/plugin/src/it/workspace-metamodel/invoker.properties rename to bindings/java/plugin/src/it/workspace-inherit-group/invoker.properties 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-metamodel/project1/.project.json b/bindings/java/plugin/src/it/workspace-inherit-group/project1/.project.json similarity index 52% rename from bindings/java/plugin/src/it/workspace-metamodel/project1/.project.json rename to bindings/java/plugin/src/it/workspace-inherit-group/project1/.project.json index 5e527fff1..a97d00525 100644 --- a/bindings/java/plugin/src/it/workspace-metamodel/project1/.project.json +++ b/bindings/java/plugin/src/it/workspace-inherit-group/project1/.project.json @@ -1,5 +1,5 @@ { "name": "project1", - "version": "0.0.1", + "version": { "preset": "kerml" }, "usage": [] } diff --git a/bindings/java/plugin/src/it/workspace-metamodel/project1/a.sysml b/bindings/java/plugin/src/it/workspace-inherit-group/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-inherit-group/project1/a.sysml 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-metamodel/project1/.meta.json b/bindings/java/plugin/src/it/workspace-inherit-version/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-inherit-version/project1/.meta.json 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/src/lib.rs b/bindings/java/src/lib.rs index 797f4d08e..aa97db5bc 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,7 @@ fn handle_build_error(env: &mut JNIEnv<'_>, error: KParBuildError ), ); } - KParBuildError::WorkspaceMetamodelConflict { .. } => { + KParBuildError::WorkspaceInheritance(_) => { env.throw_exception(ExceptionKind::SysandException, error.to_string()); } } diff --git a/bindings/py/src/lib.rs b/bindings/py/src/lib.rs index 12744a491..4172fbad6 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()), }, })?; @@ -236,9 +240,7 @@ 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()), }) } diff --git a/core/src/commands/build.rs b/core/src/commands/build.rs index ce36b5905..6dd1b3740 100644 --- a/core/src/commands/build.rs +++ b/core/src/commands/build.rs @@ -14,7 +14,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 +153,8 @@ 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), } impl From for KParBuildError { @@ -249,14 +241,7 @@ pub fn do_build_kpar, Pr: ProjectRead>( canonicalise: bool, allow_path_usage: bool, ) -> Result> { - do_build_kpar_inner( - project, - path, - compression, - canonicalise, - allow_path_usage, - None, - ) + do_build_kpar_inner(project, path, compression, canonicalise, allow_path_usage) } fn do_build_kpar_inner, Pr: ProjectRead>( @@ -265,7 +250,6 @@ fn do_build_kpar_inner, Pr: ProjectRead>( compression: KparCompressionMethod, canonicalise: bool, allow_path_usage: bool, - workspace_metamodel: Option<&str>, ) -> Result> { use crate::project::local_src::LocalSrcProject; @@ -273,8 +257,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) = - LocalSrcProject::temporary_from_project(project)?; + let (_tmp, mut local_project, info, meta) = LocalSrcProject::temporary_from_project(project)?; match semver::Version::parse(&info.version) { Ok(_) => (), Err(e) => log::warn!( @@ -315,24 +298,6 @@ 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(), - }); - } - } else { - meta.metamodel = Some(ws_metamodel.to_string()); - use crate::project::ProjectMut; - local_project - .put_meta(&meta, true) - .map_err(KParBuildError::from)?; - } - } - if canonicalise { for path in meta.validate()?.source_paths(true) { use crate::include::do_include; @@ -421,24 +386,52 @@ pub fn do_build_workspace_kpars>( canonicalise: bool, allow_path_usage: bool, ) -> Result, KParBuildError> { - let ws_metamodel = workspace.metamodel().map(|iri| iri.as_str()); + use crate::workspace::{resolve_project_info, resolve_project_metadata}; 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), }; - let file_name = default_kpar_file_name(&project)?; + // 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 for the output filename. + let file_name = format!( + "{}-{}.kpar", + resolved_info + .name + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '_' }) + .collect::(), + resolved_info.version + ); let output_path = path.as_ref().join(file_name); + + // 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 kpar_project = do_build_kpar_inner( - &project, + &resolved_project, &output_path, compression, canonicalise, allow_path_usage, - ws_metamodel, )?; 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/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(()) +}