diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 97f1a2bd1..dee30f556 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -19,7 +19,7 @@ jobs: # Try to use a cached set of packages for Unit Testing - name: Check for a FHIR package cache id: cache-fhir-packages-test - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.fhir key: cache-unit-test-fhir-packages-20250219 @@ -47,7 +47,7 @@ jobs: # If there is no cache, save the downloaded packages - name: Cache FHIR packages - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 if: ${{ steps.cache-fhir-packages-test.outputs.cache-hit != 'true' }} continue-on-error: true with: @@ -65,12 +65,12 @@ jobs: os: [ubuntu-latest, windows-latest, macOS-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # Try to use a cached set of packages for Unit Testing - name: Restore FHIR package cache id: cache-fhir-packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.fhir key: cache-unit-test-fhir-packages-20250219 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 8e9074f1b..eecbf7017 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -33,8 +33,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 with: dotnet-version: ${{ env.DOTNET_VERSION }} @@ -55,11 +55,11 @@ jobs: - name: Configure Pages if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload Pages artifact if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: docfx/_site @@ -72,4 +72,4 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/src/Fhir.CodeGen.Common/Utils/FileSystemUtils.cs b/src/Fhir.CodeGen.Common/Utils/FileSystemUtils.cs index aa23f1a20..7fc83129d 100644 --- a/src/Fhir.CodeGen.Common/Utils/FileSystemUtils.cs +++ b/src/Fhir.CodeGen.Common/Utils/FileSystemUtils.cs @@ -40,6 +40,34 @@ public static string FindRelativeDir( return dirName; } + if (dirName.StartsWith('~')) + { + string profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(profile)) + { + profile = Path.GetDirectoryName(AppContext.BaseDirectory) ?? "."; + } + + string relative = dirName.Length >= 2 && (dirName[1] == '/' || dirName[1] == '\\') + ? dirName.Substring(2) + : dirName.Substring(1); + + string expanded = string.IsNullOrEmpty(relative) + ? profile + : Path.GetFullPath(Path.Combine(profile, relative)); + + if (Directory.Exists(expanded)) + { + return expanded; + } + + if (throwIfNotFound) + { + throw new DirectoryNotFoundException($"Could not find directory {dirName}!"); + } + + return string.Empty; + } string currentDir = startDir switch { @@ -52,16 +80,6 @@ public static string FindRelativeDir( _ => startDir, }; - if (dirName.StartsWith('~')) - { - currentDir = Path.GetDirectoryName(Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? Path.GetDirectoryName(AppContext.BaseDirectory) ?? ".", - dirName[2..])) - ?? currentDir; - - dirName = dirName[2..]; - } - if (currentDir.StartsWith('~')) { currentDir = Path.GetDirectoryName(Path.Combine( diff --git a/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs index 408693891..571600187 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/IgExporter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data; using System.Linq; @@ -48,7 +48,8 @@ public class XVerIgExportTrackingRecord public XVerIgFileRecord? IgIndexFile { get; set; } = null; public string? PageContentDir { get; set; } = null; - public List SdPageContentFiles { get; set; } = []; + public List ResourceLookupFiles { get; set; } = []; + public List TypeLookupFiles { get; set; } = []; public List VsPageContentFiles { get; set; } = []; public List XVerSourcePageContentFiles { get; set; } = []; @@ -68,6 +69,7 @@ public class XVerIgExportTrackingRecord public string? ResourceMapDir { get; set; } = null; public List ResourceMapFiles { get; set; } = []; + public List TypeMapFiles { get; set; } = []; public string? ElementMapDir { get; set; } = null; public List ElementMapFiles { get; set; } = []; @@ -117,6 +119,7 @@ public PackageContents AsPackageContents() files.AddRange(ExtensionFiles.Select(f => f.AsPackageFile())); files.AddRange(ProfileFiles.Select(f => f.AsPackageFile())); files.AddRange(ResourceMapFiles.Select(f => f.AsPackageFile())); + files.AddRange(TypeMapFiles.Select(f => f.AsPackageFile())); files.AddRange(ElementMapFiles.Select(f => f.AsPackageFile())); return new PackageContents() @@ -793,7 +796,10 @@ private void writeMenuXml(XVerIgExportTrackingRecord igTr) Home
  • - Structure Lookup + Resource Lookup +
  • +
  • + Type Lookup
  • ValueSet Lookup @@ -847,6 +853,7 @@ private void writeIgJsonR4(ValidationIgExportTrackingRecord vTr, XVerExportTrack HashSet skipPages = [ "index", "lookup-sd", + "lookup-sd-types", "lookup-vs", "downloads", "changelog", @@ -987,6 +994,7 @@ private void writeIgJsonR5(ValidationIgExportTrackingRecord vTr, XVerExportTrack HashSet skipPages = [ "index", "lookup-sd", + "lookup-sd-types", "lookup-vs", "downloads", "changelog", @@ -1238,6 +1246,24 @@ private void writeIgJsonR4(XVerIgExportTrackingRecord igTr) """); } + // process type concept maps + foreach (XVerIgFileRecord fileRec in igTr.TypeMapFiles) + { + resourceDefinitions.Add($$$""" + { + "extension" : [{ + "url" : "http://hl7.org/fhir/tools/StructureDefinition/resource-information", + "valueString" : "ConceptMap" + }], + "reference" : { + "reference" : "ConceptMap/{{{fileRec.Id}}}" + }, + "name" : "{{{fileRec.Name}}}", + "description" : "{{{FhirSanitizationUtils.SanitizeForJsonValue(fileRec.Description)}}}" + } + """); + } + // process element concept maps foreach (XVerIgFileRecord fileRec in igTr.ElementMapFiles) { @@ -1259,6 +1285,7 @@ private void writeIgJsonR4(XVerIgExportTrackingRecord igTr) HashSet skipPages = [ "index", "lookup-sd", + "lookup-sd-types", "lookup-vs", "downloads", "changelog", @@ -1269,31 +1296,56 @@ private void writeIgJsonR4(XVerIgExportTrackingRecord igTr) pageBuilder.AppendLine(""" { "nameUrl" : "index.html", "title" : "Home", "generation" : "markdown" , "page" : [ """); //pageBuilder.AppendLine(""" { "nameUrl" : "faqs.html", "title" : "FAQs", "generation" : "markdown" },"""); - if (igTr.SdPageContentFiles.Count == 1) + if (igTr.ResourceLookupFiles.Count == 1) { - XVerIgFileRecord sdp = igTr.SdPageContentFiles[0]; + XVerIgFileRecord sdp = igTr.ResourceLookupFiles[0]; pageBuilder.AppendLine( $$$""" { "nameUrl" : "{{{sdp.FileNameWithoutExtension}}}.html", "title" : "{{{sdp.Description}}}", "generation" : "markdown" }, """); } - else if (igTr.SdPageContentFiles.Count > 1) + else if (igTr.ResourceLookupFiles.Count > 1) { - XVerIgFileRecord sdp = igTr.SdPageContentFiles[0]; + XVerIgFileRecord sdp = igTr.ResourceLookupFiles[0]; pageBuilder.AppendLine( $$$""" { "nameUrl" : "{{{sdp.FileNameWithoutExtension}}}.html", "title" : "{{{sdp.Description}}}", "generation" : "markdown" , "page" : [ """); - foreach (XVerIgFileRecord fileRec in igTr.SdPageContentFiles[1..^1]) + foreach (XVerIgFileRecord fileRec in igTr.ResourceLookupFiles[1..^1]) { pageBuilder.AppendLine( $$$""" { "nameUrl" : "{{{fileRec.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{fileRec.Name}}}", "generation" : "markdown" },"""); } - XVerIgFileRecord last = igTr.SdPageContentFiles[^1]; + XVerIgFileRecord last = igTr.ResourceLookupFiles[^1]; pageBuilder.AppendLine( $$$""" { "nameUrl" : "{{{last.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{last.Name}}}", "generation" : "markdown" }"""); pageBuilder.AppendLine("""]},"""); // close lookup } + if (igTr.TypeLookupFiles.Count == 1) + { + XVerIgFileRecord tlp = igTr.TypeLookupFiles[0]; + pageBuilder.AppendLine( + $$$""" { "nameUrl" : "{{{tlp.FileNameWithoutExtension}}}.html", "title" : "{{{tlp.Description}}}", "generation" : "markdown" }, """); + } + else if (igTr.TypeLookupFiles.Count > 1) + { + XVerIgFileRecord tlp = igTr.TypeLookupFiles[0]; + pageBuilder.AppendLine( + $$$""" { "nameUrl" : "{{{tlp.FileNameWithoutExtension}}}.html", "title" : "{{{tlp.Description}}}", "generation" : "markdown" , "page" : [ """); + + foreach (XVerIgFileRecord fileRec in igTr.TypeLookupFiles[1..^1]) + { + pageBuilder.AppendLine( + $$$""" { "nameUrl" : "{{{fileRec.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{fileRec.Name}}}", "generation" : "markdown" },"""); + } + + XVerIgFileRecord last = igTr.TypeLookupFiles[^1]; + pageBuilder.AppendLine( + $$$""" { "nameUrl" : "{{{last.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{last.Name}}}", "generation" : "markdown" }"""); + + pageBuilder.AppendLine("""]},"""); // close type lookup + } + if (igTr.VsPageContentFiles.Count == 1) { XVerIgFileRecord sdp = igTr.VsPageContentFiles[0]; @@ -1405,7 +1457,7 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) .Select(d => d.AsIgDependsOn(igTr.PackagePair.TargetFhirSequence)) .ToList(); - if (igTr.SdPageContentFiles.Count < 1) + if (igTr.ResourceLookupFiles.Count < 1) { throw new Exception($"No StructureDefinition page content files found for IG '{igTr.PackageId}'"); } @@ -1413,12 +1465,13 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) HashSet skipPages = [ "index", "lookup-sd", + "lookup-sd-types", "lookup-vs", "downloads", "changelog", ]; - XVerIgFileRecord sdLookupFileRec = igTr.SdPageContentFiles[0]; + XVerIgFileRecord sdLookupFileRec = igTr.ResourceLookupFiles[0]; ImplementationGuide.PageComponent sdLookupPage = new() { @@ -1429,7 +1482,7 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) Page = [], }; - foreach (XVerIgFileRecord fileRec in igTr.SdPageContentFiles) + foreach (XVerIgFileRecord fileRec in igTr.ResourceLookupFiles) { if (skipPages.Contains(fileRec.FileNameWithoutExtension)) { @@ -1445,6 +1498,37 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) }); } + // build the optional type lookup page tree (may be empty if no complex-type outcomes) + ImplementationGuide.PageComponent? typeLookupPage = null; + if (igTr.TypeLookupFiles.Count > 0) + { + XVerIgFileRecord typeLookupIndexFileRec = igTr.TypeLookupFiles[0]; + typeLookupPage = new() + { + Source = new FhirUrl(typeLookupIndexFileRec.FileName), + Name = typeLookupIndexFileRec.FileNameWithoutExtension + ".html", + Title = typeLookupIndexFileRec.Description, + Generation = ImplementationGuide.GuidePageGeneration.Markdown, + Page = [], + }; + + foreach (XVerIgFileRecord fileRec in igTr.TypeLookupFiles) + { + if (skipPages.Contains(fileRec.FileNameWithoutExtension)) + { + continue; + } + + typeLookupPage.Page.Add(new() + { + Source = new FhirUrl(fileRec.FileName), + Name = $"{fileRec.FileNameWithoutExtension}.html", + Title = $"Lookup for {fileRec.Name}", + Generation = ImplementationGuide.GuidePageGeneration.Markdown, + }); + } + } + if (igTr.VsPageContentFiles.Count < 1) { throw new Exception($"No ValueSet page content files found for IG '{igTr.PackageId}'"); @@ -1483,33 +1567,46 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) Name = "index.html", Title = "Home", Generation = ImplementationGuide.GuidePageGeneration.Markdown, - Page = [ - //new() - //{ - // Source = new FhirUrl("faqs.md"), - // Name = "faqs.html", - // Title = "FAQs", - // Generation = ImplementationGuide.GuidePageGeneration.Markdown, - - //}, - sdLookupPage, - vsLookupPage, - new() - { - Source = new FhirUrl("downloads.md"), - Name = "downloads.html", - Title = "Downloads", - Generation = ImplementationGuide.GuidePageGeneration.Markdown, + Page = typeLookupPage is null + ? [ + sdLookupPage, + vsLookupPage, + new() + { + Source = new FhirUrl("downloads.md"), + Name = "downloads.html", + Title = "Downloads", + Generation = ImplementationGuide.GuidePageGeneration.Markdown, - }, - new() - { - Source = new FhirUrl("changelog.md"), - Name = "changelog.html", - Title = "Change Log", - Generation = ImplementationGuide.GuidePageGeneration.Markdown, - } - ], + }, + new() + { + Source = new FhirUrl("changelog.md"), + Name = "changelog.html", + Title = "Change Log", + Generation = ImplementationGuide.GuidePageGeneration.Markdown, + } + ] + : [ + sdLookupPage, + typeLookupPage, + vsLookupPage, + new() + { + Source = new FhirUrl("downloads.md"), + Name = "downloads.html", + Title = "Downloads", + Generation = ImplementationGuide.GuidePageGeneration.Markdown, + + }, + new() + { + Source = new FhirUrl("changelog.md"), + Name = "changelog.html", + Title = "Change Log", + Generation = ImplementationGuide.GuidePageGeneration.Markdown, + } + ], }; List igParams = _xverIgParameters @@ -1700,6 +1797,24 @@ private void writeIgJsonR5(XVerIgExportTrackingRecord igTr) }); } + // add our type concept maps + foreach (XVerIgFileRecord fileRec in igTr.TypeMapFiles) + { + ig.Definition.Resource.Add(new() + { + Reference = new ResourceReference($"ConceptMap/{fileRec.Id}"), + Name = fileRec.Name, + Description = FhirSanitizationUtils.SanitizeForJsonValue(fileRec.Description), + Extension = [ + new() + { + Url = "http://hl7.org/fhir/tools/StructureDefinition/resource-information", + Value = new FhirString("ConceptMap"), + }, + ], + }); + } + // add our element concept maps foreach (XVerIgFileRecord fileRec in igTr.ElementMapFiles) { diff --git a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs index d698c8f11..76d7c49ef 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/StructureFhirExporter.cs @@ -96,12 +96,30 @@ private Dictionary< (FhirReleases.FhirSequenceCodes source, FhirReleases.FhirSequenceCodes target), PackagePairStructureMappingTracker> _resourceReferenceLookup = []; - private static readonly HashSet _exportExclusions = [ - "Base", - "BackboneType", - "BackboneElement", - "Element", - ]; + private static readonly FhirArtifactClassEnum[] _xverSourceArtifactClasses = + [FhirArtifactClassEnum.Resource, FhirArtifactClassEnum.ComplexType]; + + private static bool shouldExportStructureOutcome( + DbStructureOutcome sdOutcome, + FhirArtifactClassEnum sourceArtifactClass) => + (sdOutcome.SourceArtifactClass == sourceArtifactClass) && + !StructurePageExporter.StructureExportExclusions.Contains(sdOutcome.SourceId) && + !StructurePageExporter.StructureExportExclusions.Contains(sdOutcome.SourceName); + + private static bool hasDirectTargetForSource(DbStructureOutcome sdOutcome) + { + if (sdOutcome.TargetId is null) + { + return false; + } + + if (sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.ComplexType) + { + return sdOutcome.TargetArtifactClass == FhirArtifactClassEnum.ComplexType; + } + + return true; + } public StructureFhirExporter( XVerExporter exporter, @@ -186,29 +204,46 @@ public void Export(XVerExportTrackingRecord tr) foreach (DbStructureOutcome sdOutcome in structureOutcomes) { - string sdTargetName = sdOutcome.TargetName ?? "Basic"; - string sdTargetUrl = sdOutcome.TargetCanonicalUnversioned ?? "http://hl7.org/fhir/StructureDefinition/Basic"; + bool hasDirectTarget = hasDirectTargetForSource(sdOutcome); + string? sdTargetName = hasDirectTarget + ? sdOutcome.TargetName ?? sdOutcome.TargetId + : null; + string? sdTargetUrl = hasDirectTarget + ? sdOutcome.TargetCanonicalUnversioned + : null; - if (!structureMappingTracker.TargetStructuresByName.TryGetValue(sdOutcome.SourceName, out List? sdNameTargets)) + if (sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.Resource) { - sdNameTargets = []; - structureMappingTracker.TargetStructuresByName[sdOutcome.SourceName] = sdNameTargets; + sdTargetName ??= "Basic"; + sdTargetUrl ??= "http://hl7.org/fhir/StructureDefinition/Basic"; } - if (!sdNameTargets.Contains(sdTargetName)) + if (sdTargetName is not null) { - sdNameTargets.Add(sdTargetName); - } + if (!structureMappingTracker.TargetStructuresByName.TryGetValue(sdOutcome.SourceName, out List? sdNameTargets)) + { + sdNameTargets = []; + structureMappingTracker.TargetStructuresByName[sdOutcome.SourceName] = sdNameTargets; + } - if (!structureMappingTracker.TargetStructuresByUrl.TryGetValue(sdOutcome.SourceCanonicalUnversioned, out List? sdUrlTargets)) - { - sdUrlTargets = []; - structureMappingTracker.TargetStructuresByName[sdOutcome.SourceCanonicalUnversioned] = sdUrlTargets; + if (!sdNameTargets.Contains(sdTargetName)) + { + sdNameTargets.Add(sdTargetName); + } } - if (!sdUrlTargets.Contains(sdTargetUrl)) + if (sdTargetUrl is not null) { - sdUrlTargets.Add(sdTargetUrl); + if (!structureMappingTracker.TargetStructuresByUrl.TryGetValue(sdOutcome.SourceCanonicalUnversioned, out List? sdUrlTargets)) + { + sdUrlTargets = []; + structureMappingTracker.TargetStructuresByName[sdOutcome.SourceCanonicalUnversioned] = sdUrlTargets; + } + + if (!sdUrlTargets.Contains(sdTargetUrl)) + { + sdUrlTargets.Add(sdTargetUrl); + } } string profileTarget = sdOutcome.GenUrl!; @@ -242,11 +277,12 @@ public void Export(XVerExportTrackingRecord tr) // export profiles exportProfiles(igTr); - // TODO: decide if we are exporting type maps - // export resource maps exportResourceMaps(igTr); + // export type maps + exportTypeMaps(igTr); + // export element maps exportElementMaps(igTr); } @@ -279,15 +315,26 @@ private void exportElementMaps(XVerIgExportTrackingRecord igTr) List exported = []; - // get the source resources - List sourceSds = DbStructureDefinition.SelectList( - _db, - FhirPackageKey: igTr.PackagePair.SourcePackageKey, - ArtifactClass: FhirArtifactClassEnum.Resource); + // get the source structures (Resources + ComplexTypes) + List sourceSds = []; + foreach (FhirArtifactClassEnum artifactClass in _xverSourceArtifactClasses) + { + sourceSds.AddRange(DbStructureDefinition.SelectList( + _db, + FhirPackageKey: igTr.PackagePair.SourcePackageKey, + ArtifactClass: artifactClass)); + } // iterate over our source structures foreach (DbStructureDefinition sourceSd in sourceSds) { + // skip abstract bases that don't need element-level maps + if (StructurePageExporter.StructureExportExclusions.Contains(sourceSd.Id) || + StructurePageExporter.StructureExportExclusions.Contains(sourceSd.Name)) + { + continue; + } + // get the structure outcomes for this structure List sdOutcomes = DbStructureOutcome.SelectList( _db, @@ -594,7 +641,13 @@ private ConceptMap createElementConceptMap( XVerIgExportTrackingRecord igTr, DbStructureOutcome sdOutcome) { - string targetId = sdOutcome.TargetId ?? "Basic"; + string? directTargetId = hasDirectTargetForSource(sdOutcome) + ? sdOutcome.TargetId + : null; + string targetId = directTargetId + ?? (sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.Resource + ? "Basic" + : "no direct target type"); string targetVersion = sdOutcome.TargetVersion ?? igTr.PackagePair.TargetPackage.PackageVersion; (_, string name) = igTr.GetName(sdOutcome.ElementConceptMapName!, sdOutcome.ElementConceptMapLongId!); @@ -662,6 +715,11 @@ private void exportResourceMaps(XVerIgExportTrackingRecord igTr) // iterate over the outcomes foreach (DbStructureOutcome sdOutcome in sdOutcomes) { + if (!shouldExportStructureOutcome(sdOutcome, FhirArtifactClassEnum.Resource)) + { + continue; + } + // check if we need a new source element if ((currentSourceElement is null) || (lastSourceId != sdOutcome.SourceId)) @@ -788,6 +846,193 @@ private ConceptMap createResourceConceptMap( return vsCm; } + private ConceptMap createTypeConceptMap( + XVerIgExportTrackingRecord igTr) + { + string id = $"{igTr.PackagePair.SourcePackageShortName}-type-map-to-{igTr.PackagePair.TargetPackageShortName}"; + string name = + $"{igTr.PackagePair.SourcePackageShortName.ToPascalCase()}" + + $"TypeMapTo" + + $"{igTr.PackagePair.TargetPackageShortName.ToPascalCase()}"; + + (_, name) = igTr.GetName(name, id); + + ConceptMap vsCm = new() + { + Id = id, + Url = $"{XVerProcessor._canonicalRootCrossVersion}ConceptMap/{id}", + Name = name, + Version = _exporter._crossDefinitionVersion, + DateElement = new FhirDateTime(DateTimeOffset.Now), + Title = $"Cross-version ConceptMap for FHIR {igTr.PackagePair.SourceFhirSequence} types in FHIR {igTr.PackagePair.TargetFhirSequence}", + Description = $"This ConceptMap represents the cross-version mapping of complex types from FHIR {igTr.PackagePair.SourceFhirSequence} for use in FHIR {igTr.PackagePair.TargetFhirSequence}.", + Status = PublicationStatus.Active, + Experimental = false, + SourceScope = new FhirUri($"http://hl7.org/fhir/{igTr.PackagePair.SourceFhirVersionShort}/ValueSet/data-types"), + TargetScope = new FhirUri($"http://hl7.org/fhir/{igTr.PackagePair.TargetFhirVersionShort}/ValueSet/data-types"), + Group = [ + new() + { + Source = $"http://hl7.org/fhir/{igTr.PackagePair.SourceFhirVersionShort}/data-types", + Target = $"http://hl7.org/fhir/{igTr.PackagePair.TargetFhirVersionShort}/data-types", + Element = [], + } + ], + }; + + return vsCm; + } + + private void exportTypeMaps(XVerIgExportTrackingRecord igTr) + { + CrossVersionExporter.ConceptMapToR3? exporterR3 = (_exporter._versionSpecificExport == XVerExporter.VersionSpecificExportCodes.TargetVersion) && + (igTr.PackagePair.TargetFhirSequence < FhirReleases.FhirSequenceCodes.R4) + ? new() + : null; + + CrossVersionExporter.ConceptMapToR4? exporterR4 = (_exporter._versionSpecificExport == XVerExporter.VersionSpecificExportCodes.TargetVersion) && + (igTr.PackagePair.TargetFhirSequence < FhirReleases.FhirSequenceCodes.R5) + ? new() + : null; + + if (igTr.ResourceMapDir is null) + { + throw new Exception("ResourceMapDir is null"); + } + + string dir = igTr.ResourceMapDir; + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + _logger.LogInformation($"Writing type maps for `{igTr.PackageId}`..."); + + List exported = []; + + ConceptMap cm = createTypeConceptMap(igTr); + + // get the structure outcomes for this pair, filtered to ComplexType sources + List sdOutcomes = DbStructureOutcome.SelectList( + _db, + SourceFhirPackageKey: igTr.PackagePair.SourcePackageKey, + TargetFhirPackageKey: igTr.PackagePair.TargetPackageKey, + orderByProperties: [nameof(DbStructureOutcome.SourceName), nameof(DbStructureOutcome.TargetName)]); + + string? lastSourceId = null; + + ConceptMap.SourceElementComponent? currentSourceElement = null; + + // iterate over the outcomes + foreach (DbStructureOutcome sdOutcome in sdOutcomes) + { + if (!shouldExportStructureOutcome(sdOutcome, FhirArtifactClassEnum.ComplexType)) + { + continue; + } + + // check if we need a new source element + if ((currentSourceElement is null) || + (lastSourceId != sdOutcome.SourceId)) + { + // create a new source element + currentSourceElement = new() + { + Code = sdOutcome.SourceId, + Display = sdOutcome.SourceName, + Target = [], + }; + cm.Group[0].Element.Add(currentSourceElement); + lastSourceId = sdOutcome.SourceId; + } + + if (sdOutcome.TargetId is null) + { + continue; + } + + CMR relationship; + if (sdOutcome.IsIdentical || sdOutcome.IsEquivalent) + { + relationship = CMR.Equivalent; + } + else if (sdOutcome.IsBroaderThanTarget) + { + relationship = CMR.SourceIsBroaderThanTarget; + } + else if (sdOutcome.IsNarrowerThanTarget) + { + relationship = CMR.SourceIsNarrowerThanTarget; + } + else + { + relationship = CMR.RelatedTo; + } + + // create our target element + ConceptMap.TargetElementComponent targetElement = new() + { + Code = sdOutcome.TargetId, + Display = sdOutcome.TargetName ?? sdOutcome.TargetId, + Relationship = relationship, + Comment = sdOutcome.Comments, + }; + + currentSourceElement.Target.Add(targetElement); + } + + // if there are no mapped types, skip writing the file + if (cm.Group[0].Element.Count == 0) + { + _logger.LogInformation($"No complex-type outcomes for `{igTr.PackageId}`; skipping type map"); + igTr.TypeMapFiles = exported; + return; + } + + // write the type map to a file + string filename; + if (cm.Id.StartsWith("ConceptMap", StringComparison.OrdinalIgnoreCase)) + { + filename = cm.Id; + } + else if (cm.Id.StartsWith("Map", StringComparison.OrdinalIgnoreCase)) + { + filename = "Concept" + cm.Id; + } + else + { + filename = cm.Id; + } + string path = Path.Combine(dir, filename + ".json"); + if (exporterR3 is not null) + { + File.WriteAllText(path, exporterR3.ToJson(cm, new SerializerSettings() { Pretty = true })); + } + else if (exporterR4 is not null) + { + File.WriteAllText(path, exporterR4.ToJson(cm, new SerializerSettings() { Pretty = true })); + } + else + { + File.WriteAllText(path, cm.ToJson(new FhirJsonSerializationSettings() { Pretty = true })); + } + exported.Add(new() + { + FileName = filename + ".json", + FileNameWithoutExtension = filename, + IsPageContentFile = false, + Name = cm.Name, + Id = cm.Id, + Url = cm.Url, + ResourceType = Hl7.Fhir.Model.FHIRAllTypes.ConceptMap.GetLiteral(), + Version = cm.Version, + Description = cm.Description ?? cm.Title ?? $"ConceptMap: {cm.Url}", + }); + + _logger.LogInformation($"Wrote {exported.Count} type maps for `{igTr.PackageId}`"); + igTr.TypeMapFiles = exported; + } + private void exportProfiles(XVerIgExportTrackingRecord igTr) { @@ -811,15 +1056,26 @@ private void exportProfiles(XVerIgExportTrackingRecord igTr) List exported = []; - // get the structures defined in the source package - List sourceStructures = DbStructureDefinition.SelectList( - _db, - FhirPackageKey: igTr.PackagePair.SourcePackageKey, - ArtifactClass: FhirArtifactClassEnum.Resource); + // get the structures defined in the source package (Resources + ComplexTypes) + List sourceStructures = []; + foreach (FhirArtifactClassEnum artifactClass in _xverSourceArtifactClasses) + { + sourceStructures.AddRange(DbStructureDefinition.SelectList( + _db, + FhirPackageKey: igTr.PackagePair.SourcePackageKey, + ArtifactClass: artifactClass)); + } // iterate over all structures - each will get a profile foreach (DbStructureDefinition sourceSd in sourceStructures) { + // skip abstract bases that don't get profiled + if (StructurePageExporter.StructureExportExclusions.Contains(sourceSd.Id) || + StructurePageExporter.StructureExportExclusions.Contains(sourceSd.Name)) + { + continue; + } + // get the structure outcomes for this source structure List sdOutcomes = DbStructureOutcome.SelectList( _db, @@ -830,13 +1086,10 @@ private void exportProfiles(XVerIgExportTrackingRecord igTr) // iterate over the outcomes and create profiles for each target foreach (DbStructureOutcome sdOutcome in sdOutcomes) { - // resolve the target structure (if any) - DbStructureDefinition? targetSd = sdOutcome.TargetStructureKey is null - ? null - : DbStructureDefinition.SelectSingle( - _db, - FhirPackageKey: igTr.PackagePair.TargetPackageKey, - Key: sdOutcome.TargetStructureKey.Value); + DbStructureDefinition targetSd = resolveProfileTargetSd( + igTr, + sourceSd, + sdOutcome); // build the initial structure definition for the extension StructureDefinition profileSd = createProfileSd( @@ -911,10 +1164,74 @@ private void exportProfiles(XVerIgExportTrackingRecord igTr) igTr.ProfileFiles = exported; } + private DbStructureDefinition resolveProfileTargetSd( + XVerIgExportTrackingRecord igTr, + DbStructureDefinition sourceSd, + DbStructureOutcome sdOutcome) + { + DbStructureDefinition? targetSd = sdOutcome.TargetStructureKey is null + ? null + : DbStructureDefinition.SelectSingle( + _db, + FhirPackageKey: igTr.PackagePair.TargetPackageKey, + Key: sdOutcome.TargetStructureKey.Value); + + if (sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.Resource) + { + return targetSd ?? resolveRequiredProfileBase( + igTr, + "Basic", + FhirArtifactClassEnum.Resource, + sourceSd); + } + + if (sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.ComplexType) + { + if (targetSd?.ArtifactClass == FhirArtifactClassEnum.ComplexType) + { + return targetSd; + } + + return resolveRequiredProfileBase( + igTr, + "Element", + FhirArtifactClassEnum.ComplexType, + sourceSd); + } + + if (targetSd is not null) + { + return targetSd; + } + + throw new Exception($"No target profile base could be resolved for `{sourceSd.Name}` in `{igTr.PackageId}`."); + } + + private DbStructureDefinition resolveRequiredProfileBase( + XVerIgExportTrackingRecord igTr, + string targetName, + FhirArtifactClassEnum artifactClass, + DbStructureDefinition sourceSd) + { + DbStructureDefinition? targetSd = DbStructureDefinition.SelectSingle( + _db, + FhirPackageKey: igTr.PackagePair.TargetPackageKey, + Name: targetName, + ArtifactClass: artifactClass); + + if (targetSd is null) + { + throw new Exception( + $"Required `{targetName}` {artifactClass} profile base was not found in target package `{igTr.PackagePair.TargetPackage.PackageId}` for source `{sourceSd.Name}`."); + } + + return targetSd; + } + private void addContentForMappedProfile( XVerIgExportTrackingRecord igTr, DbStructureDefinition sourceSd, - DbStructureDefinition? targetSd, + DbStructureDefinition targetSd, DbStructureOutcome sdOutcome, StructureDefinition profileSd) { @@ -931,13 +1248,7 @@ private void addContentForMappedProfile( .ToLookup(x => x.Context, x => x.Outcome); // we need to traverse the elements in the order of the target structure - List targetElements = targetSd is null - ? DbElement.SelectList( - _db, - FhirPackageKey: igTr.PackagePair.TargetPackageKey, - Id: "Basic", - orderByProperties: [nameof(DbElement.ResourceFieldOrder)]) - : DbElement.SelectList( + List targetElements = DbElement.SelectList( _db, StructureKey: targetSd.Key, orderByProperties: [nameof(DbElement.ResourceFieldOrder)]); @@ -1614,11 +1925,19 @@ private void addContentForBasicProfile( private StructureDefinition createProfileSd( XVerIgExportTrackingRecord igTr, DbStructureDefinition sourceSd, - DbStructureDefinition? targetSd, + DbStructureDefinition targetSd, DbStructureOutcome sdOutcome) { - string targetStructureName = targetSd?.Name ?? "Basic"; + string targetStructureName = targetSd.Name; string profileId = sdOutcome.GenShortId!; + bool isResourceProfile = sdOutcome.SourceArtifactClass == FhirArtifactClassEnum.Resource; + StructureDefinition.StructureDefinitionKind profileKind = isResourceProfile + ? StructureDefinition.StructureDefinitionKind.Resource + : StructureDefinition.StructureDefinitionKind.ComplexType; + string sourceArtifactLabel = isResourceProfile ? "resource" : "complex type"; + string representationLabel = isResourceProfile + ? $"via FHIR {igTr.PackagePair.TargetFhirSequence} {targetStructureName} resources." + : $"via a FHIR {igTr.PackagePair.TargetFhirSequence} {targetStructureName} complex-type profile."; (_, string name) = igTr.GetName(sdOutcome.GenName!, profileId); @@ -1630,16 +1949,16 @@ private StructureDefinition createProfileSd( Version = _exporter._crossDefinitionVersion, FhirVersion = EnumUtility.ParseLiteral(igTr.PackagePair.TargetPackage.PackageVersion) ?? FHIRVersion.N5_0_0, DateElement = new FhirDateTime(DateTimeOffset.Now), - Title = $"Cross-version Profile for {igTr.PackagePair.SourceFhirSequence}.{sourceSd.Name} for use in FHIR {igTr.PackagePair.TargetFhirSequence}", + Title = $"Cross-version Profile for {igTr.PackagePair.SourceFhirSequence}.{sourceSd.Name} {sourceArtifactLabel} for use in FHIR {igTr.PackagePair.TargetFhirSequence}", Description = $"This cross-version profile allows" + - $" {igTr.PackagePair.SourceFhirSequence} {sourceSd.Name} content to be represented" + - $" via FHIR {igTr.PackagePair.TargetFhirSequence} {targetStructureName} resources.", + $" {igTr.PackagePair.SourceFhirSequence} {sourceSd.Name} {sourceArtifactLabel} content to be represented " + + representationLabel, Status = PublicationStatus.Active, Experimental = false, - Kind = StructureDefinition.StructureDefinitionKind.Resource, + Kind = profileKind, Abstract = false, Type = targetStructureName, - BaseDefinition = $"http://hl7.org/fhir/StructureDefinition/{targetStructureName}", + BaseDefinition = targetSd.UnversionedUrl, Derivation = StructureDefinition.TypeDerivationRule.Constraint, Differential = [], }; @@ -1750,8 +2069,8 @@ private void exportExtensions(XVerIgExportTrackingRecord igTr) continue; } - if (_exportExclusions.Contains(edOutcome.SourceId) || - _exportExclusions.Contains(sourceSd.Name)) + if (StructurePageExporter.StructureExportExclusions.Contains(edOutcome.SourceId) || + StructurePageExporter.StructureExportExclusions.Contains(sourceSd.Name)) { continue; } diff --git a/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs b/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs index 4409f6909..41a9cc59a 100644 --- a/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs +++ b/src/Fhir.CodeGen.Comparison/Exporter/StructurePageExporter.cs @@ -35,7 +35,7 @@ public class StructurePageExporter private ILoggerFactory _loggerFactory; private ILogger _logger; - private static readonly HashSet _exportExclusions = [ + internal static readonly HashSet StructureExportExclusions = [ "Base", "BackboneType", "BackboneElement", @@ -58,15 +58,59 @@ public void Export(XVerExportTrackingRecord tr) // iterate over the XVer IGs foreach (XVerIgExportTrackingRecord igTr in tr.XVerIgs) { - // export package structure index page - exportStructureIndexPage(igTr); - - // export individual structure lookup pages - exportStructureLookupPages(igTr); + // export resource lookup index + per-resource pages + exportLookupIndexPage( + igTr, + FhirArtifactClassEnum.Resource, + "lookup-sd.md", + "Resource Lookup", + igTr.ResourceLookupFiles); + exportLookupPages( + igTr, + FhirArtifactClassEnum.Resource, + igTr.ResourceLookupFiles); + + // export type lookup index + per-type pages + exportLookupIndexPage( + igTr, + FhirArtifactClassEnum.ComplexType, + "lookup-sd-types.md", + "Type Lookup", + igTr.TypeLookupFiles); + exportLookupPages( + igTr, + FhirArtifactClassEnum.ComplexType, + igTr.TypeLookupFiles); } } - private void exportStructureLookupPages(XVerIgExportTrackingRecord igTr) + private static string getLookupId(DbStructureOutcome sdOutcome) + { + string id = sdOutcome.GenShortId!; + id = id.StartsWith("profile-", StringComparison.OrdinalIgnoreCase) + ? id["profile-".Length..] + : id.StartsWith("profile", StringComparison.OrdinalIgnoreCase) + ? id["profile".Length..] + : id.StartsWith("prfl-", StringComparison.OrdinalIgnoreCase) + ? id["prfl-".Length..] + : id.StartsWith("prfl", StringComparison.OrdinalIgnoreCase) + ? id["prfl".Length..] + : id; + + return id; + } + + private static bool hasDirectTarget( + DbStructureOutcome sdOutcome, + FhirArtifactClassEnum sourceArtifactClass) => + (sdOutcome.TargetId is not null) && + ((sourceArtifactClass != FhirArtifactClassEnum.ComplexType) || + (sdOutcome.TargetArtifactClass == FhirArtifactClassEnum.ComplexType)); + + private void exportLookupPages( + XVerIgExportTrackingRecord igTr, + FhirArtifactClassEnum sourceArtifactClass, + List trackerList) { if (igTr.PageContentDir is null) { @@ -79,7 +123,7 @@ private void exportStructureLookupPages(XVerIgExportTrackingRecord igTr) Directory.CreateDirectory(dir); } - _logger.LogInformation($"Writing structure lookup pages for `{igTr.PackageId}`..."); + _logger.LogInformation($"Writing structure lookup pages for `{igTr.PackageId}` ({sourceArtifactClass})..."); List exported = []; @@ -96,51 +140,79 @@ private void exportStructureLookupPages(XVerIgExportTrackingRecord igTr) // iterate over the outcomes to create lookup pages foreach (DbStructureOutcome sdOutcome in sdOutcomes) { - if ((sdOutcome.SourceArtifactClass != FhirArtifactClassEnum.Resource) || - _exportExclusions.Contains(sdOutcome.SourceId) || - _exportExclusions.Contains(sdOutcome.SourceName)) + if ((sdOutcome.SourceArtifactClass != sourceArtifactClass) || + StructureExportExclusions.Contains(sdOutcome.SourceId) || + StructureExportExclusions.Contains(sdOutcome.SourceName)) { continue; } - string id = sdOutcome.GenShortId!; - id = id.StartsWith("profile-", StringComparison.OrdinalIgnoreCase) - ? id["profile-".Length..] - : id.StartsWith("profile", StringComparison.OrdinalIgnoreCase) - ? id["profile".Length..] - : id.StartsWith("prfl-", StringComparison.OrdinalIgnoreCase) - ? id["prfl-".Length..] - : id.StartsWith("prfl", StringComparison.OrdinalIgnoreCase) - ? id["prfl".Length..] - : id; + string id = getLookupId(sdOutcome); + bool isResourceLookup = sourceArtifactClass == FhirArtifactClassEnum.Resource; + string sourceArtifactLabel = isResourceLookup ? "resource" : "complex type"; + string targetArtifactLabel = isResourceLookup ? "resource" : "complex type"; + bool hasDirectTargetStructure = hasDirectTarget(sdOutcome, sourceArtifactClass); + string targetId = hasDirectTargetStructure + ? sdOutcome.TargetId! + : isResourceLookup + ? "Basic" + : "no direct target type"; // create the lookup file string filename = Path.Combine(dir, $"lookup-sd-{id}.md"); using ExportStreamWriter mdWriter = createMarkdownWriter(filename); - string targetId = sdOutcome.TargetId ?? "Basic"; - //string elementCmId = $"ConceptMap-{igTr.PackagePair.SourcePackageShortName}-{sdOutcome.SourceId}-elements-for-{igTr.PackagePair.TargetPackageShortName}-{targetId}"; - //string elementCmId = $"conceptmap-{igTr.PackagePair.SourcePackageShortName}-{sdOutcome.SourceId}-elements-for-{igTr.PackagePair.TargetPackageShortName}-{targetId}"; - // write a header mdWriter.WriteLine( $"### Lookup for [FHIR {igTr.PackagePair.SourceFhirSequence}]({sourceBaseUrl})" + $" [{sdOutcome.SourceName}]({sourceBaseUrl}{sdOutcome.SourceName}.html)" + $" for use in [FHIR {igTr.PackagePair.TargetFhirSequence}]({targetBaseUrl})"); mdWriter.WriteLine(); - mdWriter.WriteLine( - $"The FHIR {igTr.PackagePair.SourceFhirSequence} resource is represented in" + - $" FHIR {igTr.PackagePair.TargetFhirSequence} via the {targetId} resource."); + + if (isResourceLookup) + { + mdWriter.WriteLine( + $"The FHIR {igTr.PackagePair.SourceFhirSequence} resource is represented in" + + $" FHIR {igTr.PackagePair.TargetFhirSequence} via the {targetId} resource."); + } + else if (hasDirectTargetStructure) + { + mdWriter.WriteLine( + $"The FHIR {igTr.PackagePair.SourceFhirSequence} complex type is represented in" + + $" FHIR {igTr.PackagePair.TargetFhirSequence} via the {targetId} complex type."); + } + else + { + mdWriter.WriteLine( + $"The FHIR {igTr.PackagePair.SourceFhirSequence} complex type has no direct target type" + + $" in FHIR {igTr.PackagePair.TargetFhirSequence}; use the generated profile and extension representation instead."); + } mdWriter.WriteLine(); - mdWriter.WriteLine( - $"Note that there is a profile defined to simplify use of this cross-version resource representation:" + - $"[Profile: {id}]({sdOutcome.GenFileName}.html)"); + if (sdOutcome.GenFileName is not null) + { + mdWriter.WriteLine( + $"Note that there is a profile defined to simplify use of this cross-version {sourceArtifactLabel} representation:" + + $"[Profile: {id}]({sdOutcome.GenFileName}.html)"); + } + else + { + mdWriter.WriteLine($"No profile generated for this cross-version {sourceArtifactLabel} representation."); + } mdWriter.WriteLine(); - mdWriter.WriteLine( - $"A computable version of the following element information is available in:" + - $" [{sdOutcome.ElementConceptMapName}]({sdOutcome.ElementConceptMapFileName!}.html)"); + bool hasElementConceptMap = (sdOutcome.ElementConceptMapFileName is not null) && + igTr.ElementMapFiles.Any(file => file.FileNameWithoutExtension == sdOutcome.ElementConceptMapFileName); + if (hasElementConceptMap) + { + mdWriter.WriteLine( + $"A computable version of the following element information is available in:" + + $" [{sdOutcome.ElementConceptMapName}]({sdOutcome.ElementConceptMapFileName}.html)"); + } + else + { + mdWriter.WriteLine($"No element-level ConceptMap was generated for this {sourceArtifactLabel}."); + } mdWriter.WriteLine(); mdWriter.WriteLine($"| Source Element (FHIR {igTr.PackagePair.SourceFhirSequence}) | Target(s) | Comments |"); mdWriter.WriteLine("| -------------- | ---- | -------- |"); @@ -169,7 +241,7 @@ private void exportStructureLookupPages(XVerIgExportTrackingRecord igTr) mdWriter.WriteLine(); mdWriter.WriteLine( $"Note that the FHIR {igTr.PackagePair.SourceFhirSequence} {sdOutcome.SourceId}" + - $" maps to multiple resources in FHIR {igTr.PackagePair.TargetFhirSequence}." + + $" maps to multiple {targetArtifactLabel}s in FHIR {igTr.PackagePair.TargetFhirSequence}." + $" The following table contains the the combined lookup information for reference."); mdWriter.WriteLine(); mdWriter.WriteLine($"| Source Element (FHIR {igTr.PackagePair.SourceFhirSequence}) | Target(s) | Comments |"); @@ -204,8 +276,8 @@ private void exportStructureLookupPages(XVerIgExportTrackingRecord igTr) }); } - _logger.LogInformation($"Wrote {exported.Count} structure lookup pages for `{igTr.PackageId}`"); - igTr.SdPageContentFiles.AddRange(exported); + _logger.LogInformation($"Wrote {exported.Count} structure lookup pages for `{igTr.PackageId}` ({sourceArtifactClass})"); + trackerList.AddRange(exported); } private void writeElementTable( @@ -558,7 +630,12 @@ private void writeElementTable( targetStructureCount = targetStructureKeys.Count; } - private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) + private void exportLookupIndexPage( + XVerIgExportTrackingRecord igTr, + FhirArtifactClassEnum sourceArtifactClass, + string indexFileName, + string indexTitle, + List trackerList) { if (igTr.PageContentDir is null) { @@ -571,7 +648,7 @@ private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) Directory.CreateDirectory(dir); } - _logger.LogInformation($"Writing structure index page for `{igTr.PackageId}`..."); + _logger.LogInformation($"Writing structure index page for `{igTr.PackageId}` ({sourceArtifactClass})..."); List exported = []; @@ -579,29 +656,39 @@ private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) string targetBaseUrl = igTr.PackagePair.TargetFhirSequence.ToWebUrlRoot(); // create the lookup file - string filename = Path.Combine(dir, "lookup-sd.md"); + string filename = Path.Combine(dir, indexFileName); using ExportStreamWriter mdWriter = createMarkdownWriter(filename); + bool isResourceLookup = sourceArtifactClass == FhirArtifactClassEnum.Resource; + string sourceArtifactLabel = isResourceLookup ? "resource" : "complex type"; + string sourceArtifactPlural = isResourceLookup ? "resources" : "complex types"; + string sourceColumnLabel = isResourceLookup ? "Resource" : "Type"; + string targetColumnLabel = isResourceLookup ? "Resource" : "Type"; + List conceptMapFiles = isResourceLookup + ? igTr.ResourceMapFiles + : igTr.TypeMapFiles; + mdWriter.WriteLine($"### FHIR {igTr.PackageId} Cross-Version Artifact Lookup"); mdWriter.WriteLine(); - mdWriter.WriteLine("The following table links to documentation for the source version of FHIR, for implementers to understand if there is an extension for the element they are trying to use."); - mdWriter.WriteLine($"These are structures defined in FHIR {igTr.PackagePair.SourceFhirSequence} (the source package), with applicable usage as mapped into FHIR {igTr.PackagePair.TargetFhirSequence} (the target package)."); + mdWriter.WriteLine($"The following table links to documentation for source FHIR {sourceArtifactPlural}, for implementers to understand cross-version representation details."); + mdWriter.WriteLine($"These are {sourceArtifactPlural} defined in FHIR {igTr.PackagePair.SourceFhirSequence} (the source package), with applicable usage as mapped into FHIR {igTr.PackagePair.TargetFhirSequence} (the target package)."); mdWriter.WriteLine(); - string cmId = $"ConceptMap-{igTr.PackagePair.SourcePackageShortName}-resources-for-{igTr.PackagePair.TargetPackageShortName}"; - string cmName = - $"ConceptMap" + - $"{igTr.PackagePair.SourcePackageShortName.ToPascalCase()}" + - $"ResourcesFor" + - $"{igTr.PackagePair.TargetPackageShortName.ToPascalCase()}"; - - mdWriter.WriteLine( - $"A computable version of the following element information is available in:" + - $" [{cmName}](ConceptMap-{cmId}.html)"); + XVerIgFileRecord? conceptMapFile = conceptMapFiles.FirstOrDefault(); + if (conceptMapFile?.Id is not null) + { + mdWriter.WriteLine( + $"A computable version of the following {sourceArtifactLabel} mapping information is available in:" + + $" [{conceptMapFile.Name}](ConceptMap-{conceptMapFile.Id}.html)"); + } + else + { + mdWriter.WriteLine($"No computable {sourceArtifactLabel} ConceptMap was generated."); + } mdWriter.WriteLine(); mdWriter.WriteLine( - $"| {igTr.PackagePair.SourceFhirSequence} Structure" + - $" | {igTr.PackagePair.TargetFhirSequence} Structure" + + $"| {igTr.PackagePair.SourceFhirSequence} {sourceColumnLabel}" + + $" | {igTr.PackagePair.TargetFhirSequence} {targetColumnLabel}" + $" | Lookup File" + $" | Profile" + $" |"); @@ -617,31 +704,30 @@ private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) // iterate over the outcomes foreach (DbStructureOutcome sdOutcome in sdOutcomes) { - if ((sdOutcome.SourceArtifactClass != FhirArtifactClassEnum.Resource) || - _exportExclusions.Contains(sdOutcome.SourceId) || - _exportExclusions.Contains(sdOutcome.SourceName)) + if ((sdOutcome.SourceArtifactClass != sourceArtifactClass) || + StructureExportExclusions.Contains(sdOutcome.SourceId) || + StructureExportExclusions.Contains(sdOutcome.SourceName)) { continue; } - string targetId = sdOutcome.TargetId ?? "Basic"; + bool hasDirectTargetStructure = hasDirectTarget(sdOutcome, sourceArtifactClass); + string targetMarkdown = hasDirectTargetStructure + ? $"[{igTr.PackagePair.TargetFhirSequence} {sdOutcome.TargetId}]({targetBaseUrl}{sdOutcome.TargetId}.html)" + : isResourceLookup + ? $"[{igTr.PackagePair.TargetFhirSequence} Basic]({targetBaseUrl}Basic.html)" + : "No direct target type"; + string profileMarkdown = sdOutcome.GenFileName is null + ? "No profile generated" + : $"[XVer Profile: {getLookupId(sdOutcome)}]({sdOutcome.GenFileName}.html)"; - string id = sdOutcome.GenShortId!; - id = id.StartsWith("profile-", StringComparison.OrdinalIgnoreCase) - ? id["profile-".Length..] - : id.StartsWith("profile", StringComparison.OrdinalIgnoreCase) - ? id["profile".Length..] - : id.StartsWith("prfl-", StringComparison.OrdinalIgnoreCase) - ? id["prfl-".Length..] - : id.StartsWith("prfl", StringComparison.OrdinalIgnoreCase) - ? id["prfl".Length..] - : id; + string id = getLookupId(sdOutcome); mdWriter.WriteLine( $"| [{igTr.PackagePair.SourceFhirSequence} {sdOutcome.SourceName}]({sourceBaseUrl}{sdOutcome.SourceId}.html)" + - $" | [{igTr.PackagePair.TargetFhirSequence} {targetId}]({targetBaseUrl}{targetId}.html)" + + $" | {targetMarkdown}" + $" | [XVer Lookup: {id}](lookup-sd-{id}.html)" + - $" | [XVer Profile: {id}](StructureDefinition-{sdOutcome.GenShortId}.html)" + + $" | {profileMarkdown}" + $" |"); } @@ -656,15 +742,15 @@ private void exportStructureIndexPage(XVerIgExportTrackingRecord igTr) FileName = fn, FileNameWithoutExtension = fn[..^3], IsPageContentFile = true, - Name = "Structure Lookup", + Name = indexTitle, Id = null, Url = null, ResourceType = null, Version = null, - Description = "Structure Lookup", + Description = indexTitle, }); - _logger.LogInformation($"Wrote {exported.Count} structure index pages for `{igTr.PackageId}`"); - igTr.SdPageContentFiles.AddRange(exported); + _logger.LogInformation($"Wrote {exported.Count} structure index pages for `{igTr.PackageId}` ({sourceArtifactClass})"); + trackerList.AddRange(exported); } } diff --git a/src/Fhir.CodeGen.Comparison/Outcomes/StructureOutcomeGenerator.cs b/src/Fhir.CodeGen.Comparison/Outcomes/StructureOutcomeGenerator.cs index befaeeb29..19b63152b 100644 --- a/src/Fhir.CodeGen.Comparison/Outcomes/StructureOutcomeGenerator.cs +++ b/src/Fhir.CodeGen.Comparison/Outcomes/StructureOutcomeGenerator.cs @@ -61,7 +61,6 @@ public StructureOutcomeGenerator( _edOutcomeTargetCache = new(); } - public void CreateOutcomesForStructures( int? maxStepSize = null, HashSet<(FhirReleases.FhirSequenceCodes s, FhirReleases.FhirSequenceCodes t)>? specificPairs = null) diff --git a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessor.cs b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessor.cs index c90686256..134f83f2c 100644 --- a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessor.cs +++ b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessor.cs @@ -261,13 +261,13 @@ public void ProcessCommand(string? command) HashSet<(FhirReleases.FhirSequenceCodes s, FhirReleases.FhirSequenceCodes t)>? specificPairs = null; specificPairs = [ - (FhirReleases.FhirSequenceCodes.DSTU2, FhirReleases.FhirSequenceCodes.R4), + //(FhirReleases.FhirSequenceCodes.DSTU2, FhirReleases.FhirSequenceCodes.R4), //(FhirReleases.FhirSequenceCodes.DSTU2, FhirReleases.FhirSequenceCodes.STU3), //(FhirReleases.FhirSequenceCodes.R4, FhirReleases.FhirSequenceCodes.R4B), //(FhirReleases.FhirSequenceCodes.R4B, FhirReleases.FhirSequenceCodes.R4), //(FhirReleases.FhirSequenceCodes.R5, FhirReleases.FhirSequenceCodes.R4B), //(FhirReleases.FhirSequenceCodes.R4, FhirReleases.FhirSequenceCodes.R5), - //(FhirReleases.FhirSequenceCodes.R5, FhirReleases.FhirSequenceCodes.R4), + (FhirReleases.FhirSequenceCodes.R5, FhirReleases.FhirSequenceCodes.R4), ]; //UpdateValueSetMaps(); @@ -300,10 +300,10 @@ public void ProcessCommand(string? command) //ExportOutcomes(artifactFilter: FhirArtifactClassEnum.ValueSet, includeIgScripts: false); //ExportOutcomes(artifactFilter: FhirArtifactClassEnum.Resource, maxStepSize: 1, includeIgScripts: false, specificPairs: specificPairs); //ExportOutcomes(artifactFilter: FhirArtifactClassEnum.Resource, includeIgScripts: false, specificPairs: specificPairs); - //ExportOutcomes(includeIgScripts: false, specificPairs: specificPairs); + ExportOutcomes(includeIgScripts: false, specificPairs: specificPairs); //ExportOutcomes(includeIgScripts: true, specificPairs: specificPairs); //ExportOutcomes(includeIgScripts: false); - ExportOutcomes(); + //ExportOutcomes(); break; diff --git a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbFhir.cs b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbFhir.cs index f5689a585..149340e9f 100644 --- a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbFhir.cs +++ b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbFhir.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Data; @@ -151,7 +151,7 @@ private class XverPackageIndexInfo public List CodeSystemFiles { get; set; } = []; public List ValueSetFiles { get; set; } = []; - public List StructureLookupFiles { get; set; } = []; + public List ResourceLookupFiles { get; set; } = []; public List ValueSetLookupFiles { get; set; } = []; public XVerIgFileRecord? IgIndexFile { get; set; } = null; diff --git a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbPackage.cs b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbPackage.cs index 26b0ecd76..292d7d5d4 100644 --- a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbPackage.cs +++ b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorDbPackage.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.CommandLine; @@ -2302,7 +2302,7 @@ private string getIgJsonR5( Page = [], }; - foreach (XVerIgFileRecord fileRec in indexInfo.StructureLookupFiles) + foreach (XVerIgFileRecord fileRec in indexInfo.ResourceLookupFiles) { lookupPage.Page.Add(new() { @@ -2684,7 +2684,7 @@ private string getIgJsonR4( pageBuilder.AppendLine(""" { "nameUrl" : "lookup.html", "title" : "Artifact Lookup", "generation" : "markdown" , "page" : [ """); List lookupPages = []; - foreach (XVerIgFileRecord fileRec in indexInfo.StructureLookupFiles) + foreach (XVerIgFileRecord fileRec in indexInfo.ResourceLookupFiles) { lookupPages.Add($$$""" { "nameUrl" : "{{{fileRec.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{fileRec.Name}}}", "generation" : "markdown" }"""); } @@ -2807,7 +2807,7 @@ private string getCombinedIgJsonR4( pageBuilder.AppendLine(""" { "nameUrl" : "index.html", "title" : "Home", "generation" : "markdown" , "page" : [ """); //pageBuilder.AppendLine(""" { "sourceUrl" : "lookup.md", "name" : "lookup.html", "title" : "Artifact Lookup", "generation" : "markdown" , "page" : [ """); - //foreach (XVerIgFileRecord fileRec in indexInfo.StructureLookupFiles) + //foreach (XVerIgFileRecord fileRec in indexInfo.ResourceLookupFiles) //{ // pageBuilder.AppendLine($$$""" { "sourceUrl" : "{{{fileRec.FileName}}}", "name" : "{{{fileRec.FileNameWithoutExtension}}}.html", "title" : "Lookup for {{{fileRec.Name}}}", "generation" : "markdown" }"""); //} diff --git a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorOutcomes.cs b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorOutcomes.cs index cdaf2f822..ea927fdc0 100644 --- a/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorOutcomes.cs +++ b/src/Fhir.CodeGen.Comparison/XVer/XVerProcessorOutcomes.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.CommandLine; @@ -1697,7 +1697,7 @@ private void writeXverOutcomes( // create a filename for this structure's md file string mdFilename = $"Lookup-{sourcePackage.ShortName}-{sourceStructureName}-{targetPackage.ShortName}.md"; - indexInfo.StructureLookupFiles.Add(new() + indexInfo.ResourceLookupFiles.Add(new() { FileName = mdFilename, FileNameWithoutExtension = mdFilename[..^3], @@ -1793,7 +1793,7 @@ private void writeXverOutcomes( // iterate across each of our targets foreach (XverPackageIndexInfo indexInfo in indexInfos) { - if (indexInfo.StructureLookupFiles.Count == 0) + if (indexInfo.ResourceLookupFiles.Count == 0) { continue; // no files for this package, skip it } @@ -1812,7 +1812,7 @@ private void writeXverOutcomes( indexWriter.WriteLine($"| {sourceVersion} Structure | Lookup File |"); indexWriter.WriteLine("| --------- | ----------- |"); - foreach (XVerIgFileRecord fileRec in indexInfo.StructureLookupFiles) + foreach (XVerIgFileRecord fileRec in indexInfo.ResourceLookupFiles) { indexWriter.WriteLine($"| {fileRec.Name} | [{fileRec.FileNameWithoutExtension}]({fileRec.FileNameWithoutExtension}.html) |"); } diff --git a/src/Fhir.CodeGen.Lib.Tests/ConfigTests.cs b/src/Fhir.CodeGen.Lib.Tests/ConfigTests.cs index ecc82e279..c25b16784 100644 --- a/src/Fhir.CodeGen.Lib.Tests/ConfigTests.cs +++ b/src/Fhir.CodeGen.Lib.Tests/ConfigTests.cs @@ -188,4 +188,167 @@ public void TestParseCliStringArray() config.AdditionalFhirRegistryUrls.Any(v => v == "http://a.co/").ShouldBe(true); config.AdditionalFhirRegistryUrls.Any(v => v == "http://b.co").ShouldBe(true); } + + /// + /// Builds a parser with all options registered + /// as global options, mirroring the pattern used by the other tests in + /// this file. + /// + private static Parser BuildRootParser() + { + ConfigurationOption[] configurationOptions = (new ConfigRoot()).GetOptions(); + + RootCommand rootCommand = new("Root command for unit testing."); + foreach (ConfigurationOption co in configurationOptions) + { + rootCommand.AddGlobalOption(co.CliOption); + } + + return new CommandLineBuilder(rootCommand).UseDefaults().Build(); + } + + /// + /// Captures everything written to during + /// and returns it. + /// + private static string CaptureConsoleOut(Action action) + { + TextWriter original = Console.Out; + StringWriter capture = new(); + try + { + Console.SetOut(capture); + action(); + } + finally + { + Console.SetOut(original); + } + + return capture.ToString(); + } + + [Fact] + public void Parse_WithMissingDefaultFhirCache_DoesNotThrow_AndUsesUserProfileDefault() + { + string tempProfile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempProfile); + + try + { + Parser parser = BuildRootParser(); + ParseResult pr = parser.Parse([]); + + TestConfigRoot config = new() { ProfileDir = tempProfile }; + + string output = CaptureConsoleOut(() => Should.NotThrow(() => config.Parse(pr))); + + config.FhirCacheDirectory.ShouldBe(Path.Combine(tempProfile, ".fhir", "packages")); + output.ShouldNotContain("Warning: --fhir-cache"); + } + finally + { + Directory.Delete(tempProfile, recursive: true); + } + } + + [Fact] + public void Parse_WithExplicitMissingRelativeFhirCache_DoesNotThrow_AndFallsBackAndWarns() + { + string tempProfile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempProfile); + + const string missing = "definitely-not-a-real-dir-xyz"; + + try + { + Parser parser = BuildRootParser(); + ParseResult pr = parser.Parse(["--fhir-cache", missing]); + + TestConfigRoot config = new() { ProfileDir = tempProfile }; + + string output = CaptureConsoleOut(() => Should.NotThrow(() => config.Parse(pr))); + + config.FhirCacheDirectory.ShouldNotBeNull(); + config.FhirCacheDirectory!.ShouldEndWith(missing); + output.ShouldContain($"Warning: --fhir-cache value '{missing}' did not resolve; using as-is."); + } + finally + { + Directory.Delete(tempProfile, recursive: true); + } + } + + [Fact] + public void Parse_WithExplicitRootedFhirCache_PassesValueThrough() + { + string tempProfile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempProfile); + + // Rooted path that does NOT exist on disk; we never create it. + string rooted = Path.Combine(Path.GetTempPath(), "fhir-codegen-tests-no-such-dir-" + Path.GetRandomFileName()); + + try + { + Parser parser = BuildRootParser(); + ParseResult pr = parser.Parse(["--fhir-cache", rooted]); + + TestConfigRoot config = new() { ProfileDir = tempProfile }; + + string output = CaptureConsoleOut(() => Should.NotThrow(() => config.Parse(pr))); + + config.FhirCacheDirectory.ShouldBe(rooted); + output.ShouldNotContain("Warning: --fhir-cache"); + } + finally + { + Directory.Delete(tempProfile, recursive: true); + } + } + + [Fact] + public void Parse_WithExplicitRelativeFhirCacheThatResolves_DoesNotWarn() + { + string tempProfile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempProfile); + + // Create a relative directory under AppContext.BaseDirectory so that + // FindRelativeDir(string.Empty, leafName) finds it on the first try + // (testDir = AppContext.BaseDirectory + leafName, exists → returned). + string baseDir = Path.GetDirectoryName(AppContext.BaseDirectory) ?? AppContext.BaseDirectory; + string leafName = "fhir-codegen-tests-rel-" + Path.GetRandomFileName(); + string createdDir = Path.Combine(baseDir, leafName); + Directory.CreateDirectory(createdDir); + + try + { + Parser parser = BuildRootParser(); + ParseResult pr = parser.Parse(["--fhir-cache", leafName]); + + TestConfigRoot config = new() { ProfileDir = tempProfile }; + + string output = CaptureConsoleOut(() => Should.NotThrow(() => config.Parse(pr))); + + config.FhirCacheDirectory.ShouldNotBeNull(); + config.FhirCacheDirectory!.ShouldBe(Path.GetFullPath(createdDir)); + output.ShouldNotContain("Warning: --fhir-cache"); + } + finally + { + Directory.Delete(createdDir, recursive: true); + Directory.Delete(tempProfile, recursive: true); + } + } + + /// + /// Test-only subclass that overrides the + /// user-profile lookup so the cache-resolution tests can use a temp + /// directory without mutating process-global env vars. + /// + private sealed class TestConfigRoot : ConfigRoot + { + public string ProfileDir { get; init; } = string.Empty; + + protected override string GetUserProfileDirectory() => ProfileDir; + } } diff --git a/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs b/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs new file mode 100644 index 000000000..d8f7f45d0 --- /dev/null +++ b/src/Fhir.CodeGen.Lib.Tests/CrossVersionArtifactSemanticTests.cs @@ -0,0 +1,532 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// + +using System.Data; +using System.Text.Json; +using Fhir.CodeGen.Common.Models; +using Fhir.CodeGen.Common.Packaging; +using Fhir.CodeGen.Comparison.Exporter; +using Fhir.CodeGen.Comparison.Models; +using Fhir.CodeGen.Lib.Configuration; +using Hl7.Fhir.Model; +using Microsoft.Data.Sqlite; +using Shouldly; + +namespace Fhir.CodeGen.Lib.Tests; + +public class CrossVersionArtifactSemanticTests +{ + [Fact] + public void XVerResourceAndTypeConceptMapsSeparateArtifactClasses() + { + using SemanticFixture fixture = SemanticFixture.Create(); + + List resourceSources = GetConceptMapSourceCodes(fixture.ResourceConceptMapPath); + List typeSources = GetConceptMapSourceCodes(fixture.TypeConceptMapPath); + + resourceSources.ShouldContain("Patient"); + resourceSources.ShouldNotContain("Address"); + typeSources.ShouldContain("Address"); + } + + [Fact] + public void XVerTypeMapRepresentsUnmappedComplexTypesWithoutBasic() + { + using SemanticFixture fixture = SemanticFixture.Create(); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(fixture.TypeConceptMapPath)); + + JsonElement sourceElement = FindConceptMapSourceElement(document, "UnmappedType"); + + if (sourceElement.TryGetProperty("target", out JsonElement targets)) + { + targets.EnumerateArray() + .Select(target => target.GetProperty("code").GetString()) + .ShouldNotContain("Basic"); + } + } + + [Fact] + public void XVerResourceNoMapStillUsesBasic() + { + using SemanticFixture fixture = SemanticFixture.Create(); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(fixture.ResourceConceptMapPath)); + + JsonElement sourceElement = FindConceptMapSourceElement(document, "UnmappedResource"); + + sourceElement.GetProperty("target") + .EnumerateArray() + .Select(target => target.GetProperty("code").GetString()) + .ShouldContain("Basic"); + } + + [Fact] + public void XVerComplexTypeProfilesUseComplexTypeKind() + { + using SemanticFixture fixture = SemanticFixture.Create(); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(fixture.AddressProfilePath)); + + document.RootElement.GetProperty("kind").GetString().ShouldBe("complex-type"); + document.RootElement.GetProperty("type").GetString().ShouldBe("Address"); + document.RootElement.GetProperty("baseDefinition").GetString().ShouldEndWith("/Address"); + } + + [Fact] + public void XVerUnmappedComplexTypeProfileDoesNotUseBasic() + { + using SemanticFixture fixture = SemanticFixture.Create(); + string profileJson = File.ReadAllText(fixture.UnmappedTypeProfilePath); + using JsonDocument document = JsonDocument.Parse(profileJson); + + document.RootElement.GetProperty("kind").GetString().ShouldBe("complex-type"); + document.RootElement.GetProperty("type").GetString().ShouldBe("Element"); + document.RootElement.GetProperty("baseDefinition").GetString().ShouldEndWith("/Element"); + profileJson.ShouldNotContain("StructureDefinition/Basic"); + profileJson.ShouldNotContain("\"type\": \"Basic\""); + } + + [Fact] + public void XVerUnmappedComplexTypeElementMapDoesNotTargetBasic() + { + using SemanticFixture fixture = SemanticFixture.Create(); + + if (!File.Exists(fixture.UnmappedTypeElementMapPath)) + { + return; + } + + string elementMapJson = File.ReadAllText(fixture.UnmappedTypeElementMapPath); + elementMapJson.ShouldNotContain("Basic"); + } + + [Fact] + public void XVerTypeLookupIndexLinksTypeMap() + { + using SemanticFixture fixture = SemanticFixture.Create(); + + string lookupIndex = File.ReadAllText(fixture.TypeLookupIndexPath); + + lookupIndex.ShouldContain("ConceptMap-R4-type-map-to-R5.html"); + lookupIndex.ShouldNotContain("resource-map"); + lookupIndex.ShouldNotContain("ConceptMap-ConceptMap-"); + } + + [Fact] + public void XVerTypeLookupPagesUseTypeWording() + { + using SemanticFixture fixture = SemanticFixture.Create(); + + string lookupPage = File.ReadAllText(fixture.AddressTypeLookupPath); + + lookupPage.ShouldContain("complex type"); + lookupPage.ShouldNotContain("resource is represented"); + } + + [Fact] + public void XVerUnmappedTypeLookupDoesNotLinkBasic() + { + using SemanticFixture fixture = SemanticFixture.Create(); + + string lookupPage = File.ReadAllText(fixture.UnmappedTypeLookupPath); + + lookupPage.ShouldNotContain("Basic.html"); + lookupPage.ShouldNotContain("Basic resource"); + lookupPage.ShouldContain("no direct target type"); + } + + private static List GetConceptMapSourceCodes(string path) + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + List sourceCodes = []; + + foreach (JsonElement group in document.RootElement.GetProperty("group").EnumerateArray()) + { + foreach (JsonElement element in group.GetProperty("element").EnumerateArray()) + { + sourceCodes.Add(element.GetProperty("code").GetString()!); + } + } + + return sourceCodes; + } + + private static JsonElement FindConceptMapSourceElement(JsonDocument document, string sourceCode) + { + foreach (JsonElement group in document.RootElement.GetProperty("group").EnumerateArray()) + { + foreach (JsonElement element in group.GetProperty("element").EnumerateArray()) + { + if (element.GetProperty("code").GetString() == sourceCode) + { + return element; + } + } + } + + throw new ShouldAssertException($"ConceptMap source element `{sourceCode}` was not found."); + } + + private sealed class SemanticFixture : IDisposable + { + private const string SourceShortName = "R4"; + private const string TargetShortName = "R5"; + private readonly SqliteConnection _connection; + + private SemanticFixture(SqliteConnection connection, string outputRoot) + { + _connection = connection; + OutputRoot = outputRoot; + } + + public string OutputRoot { get; } + + public string PackageRoot => Path.Combine(OutputRoot, "hl7.fhir.uv.xver-r4.r5"); + + public string ResourceDirectory => Path.Combine(PackageRoot, "input", "resources"); + + public string PageContentDirectory => Path.Combine(PackageRoot, "input", "pagecontent"); + + public string ResourceConceptMapPath => Path.Combine(ResourceDirectory, $"{SourceShortName}-resource-map-to-{TargetShortName}.json"); + + public string TypeConceptMapPath => Path.Combine(ResourceDirectory, $"{SourceShortName}-type-map-to-{TargetShortName}.json"); + + public string AddressProfilePath => Path.Combine(ResourceDirectory, "StructureDefinition-r4-address-to-r5-address.json"); + + public string UnmappedTypeProfilePath => Path.Combine(ResourceDirectory, "StructureDefinition-r4-unmappedtype-to-r5-nomap.json"); + + public string UnmappedTypeElementMapPath => Path.Combine(ResourceDirectory, $"{SourceShortName}-UnmappedType-elements-for-{TargetShortName}-NoMap.json"); + + public string TypeLookupIndexPath => Path.Combine(PageContentDirectory, "lookup-sd-types.md"); + + public string AddressTypeLookupPath => Path.Combine(PageContentDirectory, "lookup-sd-r4-address-to-r5-address.md"); + + public string UnmappedTypeLookupPath => Path.Combine(PageContentDirectory, "lookup-sd-r4-unmappedtype-to-r5-nomap.md"); + + public static SemanticFixture Create() + { + SqliteConnection connection = new("Data Source=:memory:"); + connection.Open(); + + DbContentClasses.CreateTables(connection); + DbComparisonClasses.CreateTables(connection); + DbOutcomeClasses.CreateTables(connection); + + string outputRoot = Path.Combine(Path.GetTempPath(), $"fhir-codegen-xver-semantic-{Guid.NewGuid():N}"); + Directory.CreateDirectory(outputRoot); + + SeedDatabase(connection); + + ConfigXVer config = new() + { + OutputDirectory = outputRoot, + XverArtifactVersion = "0.1.0-test", + }; + + XVerExporter exporter = new(connection, config); + exporter.Export( + includeIgScripts: false, + processVocabulary: true, + processStructures: true, + specificPairs: [(FhirReleases.FhirSequenceCodes.R4, FhirReleases.FhirSequenceCodes.R5)]); + + return new(connection, outputRoot); + } + + public void Dispose() + { + _connection.Dispose(); + + if (Directory.Exists(OutputRoot)) + { + Directory.Delete(OutputRoot, recursive: true); + } + } + + private static void SeedDatabase(IDbConnection connection) + { + DbFhirPackage sourcePackage = InsertPackage( + connection, + FhirReleases.FhirSequenceCodes.R4, + SourceShortName, + "hl7.fhir.r4.core", + "4.0.1"); + DbFhirPackage targetPackage = InsertPackage( + connection, + FhirReleases.FhirSequenceCodes.R5, + TargetShortName, + "hl7.fhir.r5.core", + "5.0.0"); + + DbStructureDefinition sourcePatient = InsertStructure(connection, sourcePackage, "Patient", FhirArtifactClassEnum.Resource); + DbStructureDefinition sourceAddress = InsertStructure(connection, sourcePackage, "Address", FhirArtifactClassEnum.ComplexType); + DbStructureDefinition sourceUnmappedType = InsertStructure(connection, sourcePackage, "UnmappedType", FhirArtifactClassEnum.ComplexType); + DbStructureDefinition sourceUnmappedResource = InsertStructure(connection, sourcePackage, "UnmappedResource", FhirArtifactClassEnum.Resource); + + InsertRootElement(connection, sourcePackage, sourcePatient); + InsertRootElement(connection, sourcePackage, sourceAddress); + InsertRootElement(connection, sourcePackage, sourceUnmappedType); + InsertRootElement(connection, sourcePackage, sourceUnmappedResource); + + DbStructureDefinition targetPatient = InsertStructure(connection, targetPackage, "Patient", FhirArtifactClassEnum.Resource); + DbStructureDefinition targetAddress = InsertStructure(connection, targetPackage, "Address", FhirArtifactClassEnum.ComplexType); + DbStructureDefinition targetBasic = InsertStructure(connection, targetPackage, "Basic", FhirArtifactClassEnum.Resource); + DbStructureDefinition targetElement = InsertStructure(connection, targetPackage, "Element", FhirArtifactClassEnum.ComplexType); + DbStructureDefinition targetExtension = InsertStructure(connection, targetPackage, "Extension", FhirArtifactClassEnum.ComplexType); + + InsertRootElement(connection, targetPackage, targetPatient); + InsertUrlElement(connection, targetPackage, targetPatient); + InsertRootElement(connection, targetPackage, targetAddress); + InsertRootElement(connection, targetPackage, targetBasic); + InsertUrlElement(connection, targetPackage, targetBasic); + InsertRootElement(connection, targetPackage, targetElement); + InsertRootElement(connection, targetPackage, targetExtension); + DbElement extensionValue = InsertElement(connection, targetPackage, targetExtension, "Extension.value[x]", "value[x]", 1, isChoiceType: true); + InsertElementType(connection, targetPackage, targetExtension, extensionValue, "string", null); + + InsertStructureOutcome(connection, sourcePackage, targetPackage, sourcePatient, targetPatient); + InsertStructureOutcome(connection, sourcePackage, targetPackage, sourceAddress, targetAddress); + InsertStructureOutcome(connection, sourcePackage, targetPackage, sourceUnmappedType, null); + InsertStructureOutcome(connection, sourcePackage, targetPackage, sourceUnmappedResource, null); + } + + private static DbFhirPackage InsertPackage( + IDbConnection connection, + FhirReleases.FhirSequenceCodes sequence, + string shortName, + string packageId, + string packageVersion) + { + DbFhirPackage package = new() + { + Name = $"FHIR {shortName}", + PackageId = packageId, + PackageVersion = packageVersion, + FhirVersionShort = sequence.ToShortVersion(), + CanonicalUrl = "http://hl7.org/fhir", + ShortName = shortName, + Dependencies = null, + DefinitionFhirSequence = sequence, + }; + + package.Key = DbFhirPackage.Insert(connection, package); + return package; + } + + private static DbStructureDefinition InsertStructure( + IDbConnection connection, + DbFhirPackage package, + string id, + FhirArtifactClassEnum artifactClass) + { + string unversionedUrl = $"http://hl7.org/fhir/StructureDefinition/{id}"; + DbStructureDefinition structure = new() + { + FhirPackageKey = package.Key, + Id = id, + VersionedUrl = $"{unversionedUrl}|{package.PackageVersion}", + UnversionedUrl = unversionedUrl, + Name = id, + Version = package.PackageVersion, + VersionAlgorithmString = null, + VersionAlgorithmCoding = null, + Status = PublicationStatus.Active, + Title = id, + Description = null, + Purpose = null, + Narrative = null, + StandardStatus = null, + WorkGroup = "fhir", + FhirMaturity = null, + IsExperimental = false, + LastChangedDate = null, + Publisher = "HL7", + Copyright = null, + CopyrightLabel = null, + ApprovalDate = null, + LastReviewDate = null, + EffectivePeriodStart = null, + EffectivePeriodEnd = null, + Topic = [], + RelatedArtifacts = [], + Jurisdictions = [], + UseContexts = [], + Contacts = [], + Authors = [], + Editors = [], + Reviewers = [], + Endorsers = [], + RootExtensions = [], + SourcePackageMoniker = null, + Comment = null, + Message = null, + ArtifactClass = artifactClass, + SnapshotCount = 1, + DifferentialCount = 0, + Implements = null, + }; + + structure.Key = DbStructureDefinition.Insert(connection, structure); + return structure; + } + + private static DbElement InsertRootElement( + IDbConnection connection, + DbFhirPackage package, + DbStructureDefinition structure) => + InsertElement(connection, package, structure, structure.Id, structure.Name, 0); + + private static DbElement InsertUrlElement( + IDbConnection connection, + DbFhirPackage package, + DbStructureDefinition structure) => + InsertElement(connection, package, structure, $"{structure.Id}.url", "url", 1, typeLiteral: "uri"); + + private static DbElement InsertElement( + IDbConnection connection, + DbFhirPackage package, + DbStructureDefinition structure, + string id, + string name, + int order, + string typeLiteral = "", + bool isChoiceType = false) + { + DbElement element = new() + { + FhirPackageKey = package.Key, + StructureKey = structure.Key, + ParentElementKey = null, + ResourceFieldOrder = order, + ComponentFieldOrder = order, + Id = id, + Path = id, + ChildElementCount = 0, + Name = name, + Short = null, + Definition = null, + Comments = null, + Requirements = null, + MinCardinality = 0, + MaxCardinality = 1, + MaxCardinalityString = "1", + SliceName = null, + FullCollatedTypeLiteral = typeLiteral, + DistinctTypeCount = string.IsNullOrEmpty(typeLiteral) ? 0 : 1, + DistinctTypeLiterals = typeLiteral, + ValueSetBindingStrength = null, + BindingValueSet = null, + BindingValueSetKey = null, + AdditionalBindingCount = 0, + BindingDescription = null, + IsInherited = false, + BasePath = null, + BaseElementKey = null, + BaseStructureKey = null, + DefinedAsContentReference = false, + ContentReferenceSourceKey = null, + ContentReferenceSourceId = null, + UsedAsContentReference = false, + IsSimpleType = false, + IsChoiceType = isChoiceType, + IsModifier = false, + IsModifierReason = null, + IsDeprecated = false, + }; + + element.Key = DbElement.Insert(connection, element); + return element; + } + + private static void InsertElementType( + IDbConnection connection, + DbFhirPackage package, + DbStructureDefinition structure, + DbElement element, + string typeName, + DbStructureDefinition? typeStructure) + { + DbElementType elementType = new() + { + FhirPackageKey = package.Key, + StructureKey = structure.Key, + ElementKey = element.Key, + TypeName = typeName, + TypeStructureKey = typeStructure?.Key, + TypeProfile = null, + TypeProfileStructureKey = null, + TargetProfile = null, + TargetProfileStructureKey = null, + }; + + elementType.Key = DbElementType.Insert(connection, elementType); + } + + private static void InsertStructureOutcome( + IDbConnection connection, + DbFhirPackage sourcePackage, + DbFhirPackage targetPackage, + DbStructureDefinition sourceStructure, + DbStructureDefinition? targetStructure) + { + string targetSuffix = targetStructure?.Id ?? "NoMap"; + string profileId = $"{sourcePackage.ShortName.ToLowerInvariant()}-{sourceStructure.Id.ToLowerInvariant()}-to-{targetPackage.ShortName.ToLowerInvariant()}-{targetSuffix.ToLowerInvariant()}"; + string elementMapId = $"{sourcePackage.ShortName}-{sourceStructure.Id}-elements-for-{targetPackage.ShortName}-{targetSuffix}"; + bool hasTarget = targetStructure is not null; + + DbStructureOutcome outcome = new() + { + SourceFhirPackageKey = sourcePackage.Key, + SourceFhirSequence = sourcePackage.DefinitionFhirSequence, + TargetFhirPackageKey = targetPackage.Key, + TargetFhirSequence = targetPackage.DefinitionFhirSequence, + RequiresXVerDefinition = true, + TotalTargetCount = hasTarget ? 1 : 0, + TotalSourceCount = 1, + IsRenamed = false, + IsUnmapped = !hasTarget, + IsIdentical = hasTarget && (sourceStructure.Id == targetStructure!.Id), + IsEquivalent = hasTarget && (sourceStructure.Id != targetStructure!.Id), + IsBroaderThanTarget = false, + IsNarrowerThanTarget = false, + FullyMapsToThisTarget = hasTarget, + FullyMapsAcrossAllTargets = hasTarget, + Comments = hasTarget ? "Equivalent test mapping" : "No direct target mapping", + GenLongId = profileId, + GenShortId = profileId, + GenUrl = $"http://hl7.org/fhir/uv/xver/StructureDefinition/{profileId}", + GenName = ToPascalName(profileId), + GenFileName = $"StructureDefinition-{profileId}", + GenArtifactShort = null, + GenArtifactDescription = null, + GenArtifactComment = null, + GenMappingComment = null, + SourceCanonicalVersioned = sourceStructure.VersionedUrl, + SourceCanonicalUnversioned = sourceStructure.UnversionedUrl, + SourceId = sourceStructure.Id, + SourceName = sourceStructure.Name, + SourceVersion = sourceStructure.Version, + TargetCanonicalVersioned = targetStructure?.VersionedUrl, + TargetCanonicalUnversioned = targetStructure?.UnversionedUrl, + TargetId = targetStructure?.Id, + TargetName = targetStructure?.Name, + TargetVersion = targetStructure?.Version, + StructureComparisonKey = null, + SourceStructureKey = sourceStructure.Key, + SourceArtifactClass = sourceStructure.ArtifactClass, + TargetStructureKey = targetStructure?.Key, + TargetArtifactClass = targetStructure?.ArtifactClass, + ElementConceptMapLongId = elementMapId, + ElementConceptMapShortId = elementMapId, + ElementConceptMapUrl = $"http://hl7.org/fhir/uv/xver/ConceptMap/{elementMapId}", + ElementConceptMapName = ToPascalName(elementMapId), + ElementConceptMapFileName = elementMapId, + }; + + outcome.Key = DbStructureOutcome.Insert(connection, outcome); + } + + private static string ToPascalName(string value) => + string.Concat(value.Split('-', StringSplitOptions.RemoveEmptyEntries).Select(part => part[..1].ToUpperInvariant() + part[1..])); + } +} diff --git a/src/Fhir.CodeGen.Lib.Tests/CrossVersionTests.cs b/src/Fhir.CodeGen.Lib.Tests/CrossVersionTests.cs index 053bf837b..341bcea9d 100644 --- a/src/Fhir.CodeGen.Lib.Tests/CrossVersionTests.cs +++ b/src/Fhir.CodeGen.Lib.Tests/CrossVersionTests.cs @@ -679,6 +679,89 @@ private void Source_Load(object sender, CachedResolver.LoadResourceEventArgs e) // } //} + [Theory(DisplayName = "XVerEmitsComplexTypeArtifacts", Skip = "Tests require external repo - run manually if desired")] + [Trait("Category", "XVer")] + [Trait("RequiresExternalRepo", "true")] + [Trait("ExternalRepo", "HL7/fhir-cross-version")] + [InlineData("R4", "R5")] + public void XVerEmitsComplexTypeArtifacts(string srcShort, string tgtShort) + { + // This test pins the "widen xver export beyond Resources" fix from + // scratch/0527-01/. It is gated behind RequiresExternalRepo=true so + // CI (filter "RequiresExternalRepo!=true") skips it; run locally + // against a pre-populated ~/.fhir cache after invoking the xver + // pipeline (e.g. + // dotnet run --project src/fhir-codegen/fhir-codegen.csproj -- + // xver --output-path .\out\xver + // ). + // + // Sentinel complex / metadata types: at least one Profile and one + // Extension StructureDefinition should be generated per type, plus + // a top-level type-map ConceptMap, and the Type Lookup page tree. + string[] sentinelTypes = + [ + "Dosage", "Address", "HumanName", "Identifier", + "CodeableReference", "ContactDetail", "Expression", "RelatedArtifact", + ]; + + string outRoot = Path.Combine(FindRelativeDir("out"), "xver"); + Directory.Exists(outRoot).ShouldBeTrue($"Expected xver output at {outRoot}; run the xver CLI first."); + + // Each emitted IG sits under out/xver//, with input/resources + // and input/pagecontent subdirectories. + string[] pkgDirs = Directory.GetDirectories(outRoot, + $"hl7.fhir.uv.xver-{srcShort.ToLowerInvariant()}.{tgtShort.ToLowerInvariant()}*", + SearchOption.TopDirectoryOnly); + pkgDirs.Length.ShouldBeGreaterThan(0, $"No xver package directory found for {srcShort}->{tgtShort}"); + + foreach (string pkgDir in pkgDirs) + { + string resourcesDir = Path.Combine(pkgDir, "input", "resources"); + string pageContentDir = Path.Combine(pkgDir, "input", "pagecontent"); + + Directory.Exists(resourcesDir).ShouldBeTrue($"Missing resources dir under {pkgDir}"); + Directory.Exists(pageContentDir).ShouldBeTrue($"Missing pagecontent dir under {pkgDir}"); + + string[] allResourceFiles = Directory.GetFiles(resourcesDir, "*.json", SearchOption.TopDirectoryOnly); + + // Sentinel complex types must each produce at least one profile + one extension. + foreach (string sentinel in sentinelTypes) + { + allResourceFiles.Any(f => Path.GetFileName(f).Contains(sentinel, StringComparison.OrdinalIgnoreCase) && + Path.GetFileName(f).StartsWith("StructureDefinition-", StringComparison.OrdinalIgnoreCase)) + .ShouldBeTrue($"Expected at least one StructureDefinition profile for complex type {sentinel} under {resourcesDir}"); + + allResourceFiles.Any(f => Path.GetFileName(f).Contains(sentinel, StringComparison.OrdinalIgnoreCase) && + Path.GetFileName(f).Contains("extension", StringComparison.OrdinalIgnoreCase)) + .ShouldBeTrue($"Expected at least one extension StructureDefinition for complex type {sentinel} under {resourcesDir}"); + } + + // Type-map ConceptMap must be present. + allResourceFiles.Any(f => Path.GetFileName(f).Contains("type-map-to", StringComparison.OrdinalIgnoreCase)) + .ShouldBeTrue($"Expected a *-type-map-to-* ConceptMap under {resourcesDir}"); + + // Type lookup page index. + File.Exists(Path.Combine(pageContentDir, "lookup-sd-types.md")) + .ShouldBeTrue($"Expected lookup-sd-types.md under {pageContentDir}"); + + // Resource lookup page index, with the renamed title. + string lookupSdPath = Path.Combine(pageContentDir, "lookup-sd.md"); + File.Exists(lookupSdPath).ShouldBeTrue($"Expected lookup-sd.md under {pageContentDir}"); + + // Exclusion guard: no artifacts for the abstract bases. + string[] excludedRoots = ["Base", "Element", "BackboneElement", "BackboneType"]; + foreach (string excluded in excludedRoots) + { + allResourceFiles.Any(f => + { + string name = Path.GetFileName(f); + return name.StartsWith($"StructureDefinition-{excluded}", StringComparison.OrdinalIgnoreCase) || + name.StartsWith($"StructureDefinition-extension-{excluded}", StringComparison.OrdinalIgnoreCase); + }).ShouldBeFalse($"Expected no artifacts for abstract base type {excluded} under {resourcesDir}"); + } + } + } + } public class TestWriter : TextWriter diff --git a/src/Fhir.CodeGen.Lib.Tests/Fhir.CodeGen.Lib.Tests.csproj b/src/Fhir.CodeGen.Lib.Tests/Fhir.CodeGen.Lib.Tests.csproj index 7715ef8ae..7050b1358 100644 --- a/src/Fhir.CodeGen.Lib.Tests/Fhir.CodeGen.Lib.Tests.csproj +++ b/src/Fhir.CodeGen.Lib.Tests/Fhir.CodeGen.Lib.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Fhir.CodeGen.Lib.Tests/FileSystemUtilsTests.cs b/src/Fhir.CodeGen.Lib.Tests/FileSystemUtilsTests.cs new file mode 100644 index 000000000..936bb11f9 --- /dev/null +++ b/src/Fhir.CodeGen.Lib.Tests/FileSystemUtilsTests.cs @@ -0,0 +1,58 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// + +using Shouldly; +using Fhir.CodeGen.Common.Utils; + +namespace Fhir.CodeGen.Lib.Tests; + +public class FileSystemUtilsTests +{ + private static string UserProfile => + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + [Fact] + public void FindRelativeDir_TildePath_ResolvesUnderUserProfile() + { + string leaf = "fhir-codegen-tests-tilde-" + Path.GetRandomFileName(); + string created = Path.Combine(UserProfile, leaf); + Directory.CreateDirectory(created); + + try + { + string result = FileSystemUtils.FindRelativeDir(string.Empty, "~/" + leaf); + + result.ShouldBe(Path.GetFullPath(Path.Combine(UserProfile, leaf))); + } + finally + { + Directory.Delete(created, recursive: true); + } + } + + [Fact] + public void FindRelativeDir_MissingTildePath_ReturnsEmpty_WhenThrowIfNotFoundFalse() + { + string missing = "~/fhir-codegen-tests-missing-" + Path.GetRandomFileName(); + + string result = FileSystemUtils.FindRelativeDir(string.Empty, missing, throwIfNotFound: false); + + result.ShouldBeEmpty(); + } + + [Fact] + public void FindRelativeDir_MissingTildePath_Throws_WhenThrowIfNotFoundTrue() + { + string missing = "~/fhir-codegen-tests-missing-" + Path.GetRandomFileName(); + + DirectoryNotFoundException ex = Should.Throw( + () => FileSystemUtils.FindRelativeDir(string.Empty, missing)); + + // The error message should retain the ~/ prefix instead of dropping it + // (the previous implementation produced "Could not find directory !"). + ex.Message.ShouldContain("~/"); + ex.Message.ShouldContain(missing); + } +} diff --git a/src/Fhir.CodeGen.Lib/Configuration/ConfigRoot.cs b/src/Fhir.CodeGen.Lib/Configuration/ConfigRoot.cs index f658b53db..581ee16d5 100644 --- a/src/Fhir.CodeGen.Lib/Configuration/ConfigRoot.cs +++ b/src/Fhir.CodeGen.Lib/Configuration/ConfigRoot.cs @@ -634,6 +634,13 @@ internal HashSet GetOptHash( return values; } + /// Returns the current user's profile directory. + /// Virtual so tests can inject a temporary directory. + protected virtual string GetUserProfileDirectory() + { + return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + /// Parses the given parse result. /// The parse result. public virtual void Parse(System.CommandLine.Parsing.ParseResult parseResult) @@ -653,11 +660,24 @@ public virtual void Parse(System.CommandLine.Parsing.ParseResult parseResult) if (string.IsNullOrEmpty(dir)) { - dir = FileSystemUtils.FindRelativeDir(string.Empty, "~/.fhir/packages"); + // Default to the user's FHIR cache. Do NOT require it to exist; + // DiskCacheClient creates it on first use, and commands like + // `docs cli` never read it at all. + dir = Path.Combine(GetUserProfileDirectory(), ".fhir", "packages"); } else if (!Path.IsPathRooted(dir)) { - dir = FileSystemUtils.FindRelativeDir(string.Empty, dir!); + string suppliedDir = dir; + string foundDir = FileSystemUtils.FindRelativeDir(string.Empty, suppliedDir, throwIfNotFound: false); + if (string.IsNullOrEmpty(foundDir)) + { + Console.WriteLine($"Warning: --fhir-cache value '{suppliedDir}' did not resolve; using as-is."); + dir = suppliedDir; + } + else + { + dir = foundDir; + } } FhirCacheDirectory = string.IsNullOrEmpty(dir) ? null : dir;