Repository: FHIR/sushi (ts)

Issue:
Set the count on FSH-authored CodeSystems
If SUSHI can determine the count for a FSH-authored CodeSystem, it should set `count`.  The `content` property should probably be considered as SUSHI may not be able to set a valid count for CodeSystems whose content is  not `complete` (e.g., `fragment`).

Pull request description:
Update CodeSystem count when appropriate
If the count is not explicitly set AND the content is "complete" AND there is at least one concept, SUSHI will set the count to the length of the concept array.

Fixes #419

Patch:
diff --git a/src/export/CodeSystemExporter.ts b/src/export/CodeSystemExporter.ts
index 0c29352..dd63247 100644
--- a/src/export/CodeSystemExporter.ts
+++ b/src/export/CodeSystemExporter.ts
@@ -35,7 +35,7 @@ export class CodeSystemExporter {
     }
   }
 
-  private setCaretRules(codeSystem: CodeSystem, rules: CaretValueRule[]) {
+  private setCaretRules(codeSystem: CodeSystem, rules: CaretValueRule[]): void {
     const csStructureDefinition = StructureDefinition.fromJSON(
       this.fisher.fishForFHIR('CodeSystem', Type.Resource)
     );
@@ -55,6 +55,29 @@ export class CodeSystemExporter {
     }
   }
 
+  private updateCount(codeSystem: CodeSystem, fshDefinition: FshCodeSystem): void {
+    // We can only derive a true count if the content is #complete
+    if (codeSystem.content === 'complete') {
+      const actualCount = codeSystem.concept?.length;
+      if (codeSystem.count == null && actualCount != null) {
+        codeSystem.count = actualCount;
+      } else if (codeSystem.count !== actualCount) {
+        const countRule = fshDefinition.rules.find(
+          r => r instanceof CaretValueRule && r.caretPath === 'count'
+        );
+        const sourceInfo = countRule?.sourceInfo ?? fshDefinition.sourceInfo;
+        logger.warn(
+          `The user-specified ^count (${codeSystem.count}) does not match the specified number of concepts ` +
+            `(${
+              actualCount ?? 0
+            }). If this is not a "complete" CodeSystem, set the ^content property to the appropriate ` +
+            'value; otherwise fix or remove the ^count.',
+          sourceInfo
+        );
+      }
+    }
+  }
+
   exportCodeSystem(fshDefinition: FshCodeSystem): CodeSystem {
     if (this.pkg.codeSystems.some(cs => cs.name === fshDefinition.name)) {
       return;
@@ -63,6 +86,7 @@ export class CodeSystemExporter {
     this.setMetadata(codeSystem, fshDefinition);
     this.setCaretRules(codeSystem, fshDefinition.rules);
     this.setConcepts(codeSystem, fshDefinition);
+    this.updateCount(codeSystem, fshDefinition);
     this.pkg.codeSystems.push(codeSystem);
     return codeSystem;
   }


Test patch:
diff --git a/test/export/CodeSystemExporter.test.ts b/test/export/CodeSystemExporter.test.ts
index 92f60fe..5d0d7f7 100644
--- a/test/export/CodeSystemExporter.test.ts
+++ b/test/export/CodeSystemExporter.test.ts
@@ -1,6 +1,6 @@
 import { CodeSystemExporter, Package } from '../../src/export';
 import { FSHDocument, FSHTank } from '../../src/import';
-import { FshCodeSystem } from '../../src/fshtypes';
+import { FshCodeSystem, FshCode } from '../../src/fshtypes';
 import { FshConcept } from '../../src/fshtypes/FshConcept';
 import { CaretValueRule } from '../../src/fshtypes/rules';
 import { TestFisher } from '../testhelpers';
@@ -32,6 +32,7 @@ describe('CodeSystemExporter', () => {
     const pkg = new Package(input.config);
     const fisher = new TestFisher(input, defs, pkg);
     exporter = new CodeSystemExporter(input, pkg, fisher);
+    loggerSpy.reset();
   });
 
   it('should output empty results with empty input', () => {
@@ -101,6 +102,7 @@ describe('CodeSystemExporter', () => {
       content: 'complete',
       url: 'http://example.com/CodeSystem/MyCodeSystem',
       version: '0.0.1',
+      count: 2,
       concept: [{ code: 'myCode' }, { code: 'anotherCode' }]
     });
   });
@@ -125,6 +127,7 @@ describe('CodeSystemExporter', () => {
       content: 'complete',
       url: 'http://example.com/CodeSystem/MyCodeSystem',
       version: '0.0.1',
+      count: 2,
       concept: [
         {
           code: 'myCode',
@@ -218,6 +221,84 @@ describe('CodeSystemExporter', () => {
     });
   });
 
+  it('should not override count when ^count is provided by user', () => {
+    const codeSystem = new FshCodeSystem('MyCodeSystem');
+    const rule = new CaretValueRule('');
+    rule.caretPath = 'content';
+    rule.value = new FshCode('fragment', 'http://hl7.org/fhir/codesystem-content-mode');
+    codeSystem.rules.push(rule);
+    const rule2 = new CaretValueRule('');
+    rule2.caretPath = 'count';
+    rule2.value = 5;
+    codeSystem.rules.push(rule2);
+    codeSystem.concepts = [new FshConcept('myCode'), new FshConcept('anotherCode')];
+    doc.codeSystems.set(codeSystem.name, codeSystem);
+    const exported = exporter.export().codeSystems;
+    expect(exported.length).toBe(1);
+    expect(exported[0].count).toBe(5);
+    expect(loggerSpy.getAllLogs('warn').length).toBe(0);
+  });
+
+  it('should warn when ^count does not match number of concepts in #complete CodeSystem', () => {
+    const codeSystem = new FshCodeSystem('MyCodeSystem');
+    // NOTE: CS defaults to #complete so we don't need to explicitly set it
+    const rule = new CaretValueRule('');
+    rule.caretPath = 'count';
+    rule.value = 5;
+    rule.sourceInfo.file = 'test.fsh';
+    rule.sourceInfo.location = {
+      startLine: 2,
+      startColumn: 1,
+      endLine: 2,
+      endColumn: 12
+    };
+    codeSystem.rules.push(rule);
+    codeSystem.concepts = [new FshConcept('myCode'), new FshConcept('anotherCode')];
+    doc.codeSystems.set(codeSystem.name, codeSystem);
+    const exported = exporter.export().codeSystems;
+    expect(exported.length).toBe(1);
+    expect(exported[0].count).toBe(5);
+    expect(loggerSpy.getLastMessage('warn')).toMatch(
+      /The user-specified \^count \(5\) does not match the specified number of concepts \(2\)\..*File: test\.fsh\s*Line: 2\D*/s
+    );
+  });
+
+  it('should warn when ^count is set and concepts is null in #complete CodeSystem', () => {
+    const codeSystem = new FshCodeSystem('MyCodeSystem');
+    // NOTE: CS defaults to #complete so we don't need to explicitly set it
+    const rule = new CaretValueRule('');
+    rule.caretPath = 'count';
+    rule.value = 5;
+    rule.sourceInfo.file = 'test.fsh';
+    rule.sourceInfo.location = {
+      startLine: 2,
+      startColumn: 1,
+      endLine: 2,
+      endColumn: 12
+    };
+    codeSystem.rules.push(rule);
+    doc.codeSystems.set(codeSystem.name, codeSystem);
+    const exported = exporter.export().codeSystems;
+    expect(exported.length).toBe(1);
+    expect(exported[0].count).toBe(5);
+    expect(loggerSpy.getLastMessage('warn')).toMatch(
+      /The user-specified \^count \(5\) does not match the specified number of concepts \(0\)\..*File: test\.fsh\s*Line: 2\D*/s
+    );
+  });
+
+  it('should not set count when ^content is not #complete', () => {
+    const codeSystem = new FshCodeSystem('MyCodeSystem');
+    const rule = new CaretValueRule('');
+    rule.caretPath = 'content';
+    rule.value = new FshCode('fragment', 'http://hl7.org/fhir/codesystem-content-mode');
+    codeSystem.rules.push(rule);
+    codeSystem.concepts = [new FshConcept('myCode'), new FshConcept('anotherCode')];
+    doc.codeSystems.set(codeSystem.name, codeSystem);
+    const exported = exporter.export().codeSystems;
+    expect(exported.length).toBe(1);
+    expect(exported[0].count).toBeUndefined();
+  });
+
   it('should log a message when applying invalid CaretValueRule', () => {
     const codeSystem = new FshCodeSystem('CaretCodeSystem');
     const rule = new CaretValueRule('').withFile('InvalidValue.fsh').withLocation([6, 3, 6, 12]);