Repository: FHIR/sushi (ts)

Issue:
Make app.ts more testable and reusable
The app.ts file is basically a script -- making it more difficult to test and difficult to re-use in other contexts (like in a VS Code extension or a web playground).  We should refactor it so most of the logic is encapsulated in functions (that can be tested) and the `app.ts` script is primarily concerned with setting up the CLI and invoking the functions w/ the input.

Pull request description:
Pull functions out of app.ts so that they can be tested
Fixes #345 and completes task [CIMPL-329](https://standardhealthrecord.atlassian.net/browse/CIMPL-329).

Most of what was in `app.ts` is now in `utils/Processing.ts`. This is a refactoring PR, and I've tested that there is no change to the exported contents of mcode before and after this change set.

I wanted to keep all of the `process.exit(1)` calls in `app.ts`, so that's why several of the new functions will throw empty errors or rethrow caught errors.

The empty `.eslintrc` file is to keep eslint from complaining about the `package.json` that doesn't contain valid JSON content.

Patch:
diff --git a/src/app.ts b/src/app.ts
index 9dbc8d2..b456efe 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -3,23 +3,22 @@
 import path from 'path';
 import fs from 'fs-extra';
 import program from 'commander';
-import cloneDeep from 'lodash/cloneDeep';
-import { importText, FSHTank, RawFSH } from './import';
+import { FSHTank } from './import';
 import { exportFHIR, Package } from './export';
 import { IGExporter } from './ig';
 import { logger, stats, Type } from './utils';
-import { loadDependency, loadCustomResources } from './fhirdefs';
+import { loadCustomResources } from './fhirdefs';
 import { FHIRDefinitions } from './fhirdefs';
 import {
-  filterInlineInstances,
-  filterExampleInstances,
-  filterCapabilitiesInstances,
-  filterVocabularyInstances,
-  filterModelInstances,
-  filterOperationInstances,
-  filterExtensionInstances,
-  filterProfileInstances
-} from './utils';
+  findInputDir,
+  ensureOutputDir,
+  readConfig,
+  loadExternalDependencies,
+  fillTank,
+  writeFHIRResources,
+  getRawFSHes,
+  getIgDataPath
+} from './utils/Processing';
 import { pad, padStart, sample, padEnd } from 'lodash';
 import chalk from 'chalk';
 
@@ -58,93 +57,34 @@ async function app() {
 
   logger.info(`Running SUSHI ${getVersion()}`);
 
-  // If no input folder is specified, set default to current directory
-  if (!input) {
-    input = '.';
-    logger.info('path-to-fsh-defs defaulted to current working directory');
-  }
-
-  // Use fsh/ subdirectory if not already specified and present
-  const fshSubdirectoryPath = path.join(input, 'fsh');
-  if (fs.existsSync(fshSubdirectoryPath)) {
-    input = path.join(input, 'fsh');
-    logger.info('fsh/ subdirectory detected and add to input path');
-  }
+  input = findInputDir(input);
 
   // If a fsh subdirectory is used, we are in an IG Publisher context
   const isIgPubContext = path.parse(input).base === 'fsh';
-  if (isIgPubContext) {
-    logger.info(
-      'Current FSH tank conforms to an IG Publisher context. Output will be adjusted accordingly.'
-    );
-  }
-
-  let files: string[];
-  try {
-    files = getFilesRecursive(input);
-  } catch {
-    logger.error('Invalid path to FSH definition folder.');
-    program.outputHelp();
-    process.exit(1);
-  }
+  const outDir = ensureOutputDir(input, program.out, isIgPubContext);
 
-  // Check that package.json exists
-  const packagePath = path.join(input, 'package.json');
-  if (!fs.existsSync(packagePath)) {
-    logger.error('No package.json in FSH definition folder.');
-    process.exit(1);
-  }
-
-  // Parse package.json
   let config: any;
   try {
-    config = fs.readJSONSync(packagePath);
-  } catch (e) {
-    logger.error(`The package.json file is not valid JSON: ${packagePath}`);
-    process.exit(1);
-  }
-
-  // Ensure FHIR R4 is added as a dependency
-  const fhirR4Dependency = config.dependencies?.['hl7.fhir.r4.core'];
-  if (fhirR4Dependency !== '4.0.1') {
-    logger.error(
-      'The package.json must specify FHIR R4 as a dependency. Be sure to' +
-        ' add "hl7.fhir.r4.core": "4.0.1" to the dependencies list.'
-    );
+    config = readConfig(input);
+  } catch {
     process.exit(1);
   }
 
   // Load external dependencies
   const defs = new FHIRDefinitions();
-  const dependencyDefs: Promise<FHIRDefinitions | void>[] = [];
-  for (const dep of Object.keys(config?.dependencies ?? {})) {
-    dependencyDefs.push(
-      loadDependency(dep, config.dependencies[dep], defs)
-        .then(def => {
-          return def;
-        })
-        .catch(e => {
-          logger.error(`Failed to load ${dep}#${config.dependencies[dep]}`);
-          logger.error(e.message);
-        })
-    );
-  }
+  const dependencyDefs = loadExternalDependencies(defs, config);
 
   // Load custom resources specified in ig-data folder
   loadCustomResources(input, defs);
 
-  const rawFSHes = files
-    .filter(file => file.endsWith('.fsh'))
-    .map(file => {
-      const filePath = path.resolve(file);
-      const fileContent = fs.readFileSync(filePath, 'utf8');
-      return new RawFSH(fileContent, filePath);
-    });
-
-  logger.info('Importing FSH text...');
-  const docs = importText(rawFSHes);
-
-  const tank = new FSHTank(docs, config);
+  let tank: FSHTank;
+  try {
+    const rawFSH = getRawFSHes(input);
+    tank = fillTank(rawFSH, config);
+  } catch {
+    program.outputHelp();
+    process.exit(1);
+  }
   await Promise.all(dependencyDefs);
 
   // Check for StructureDefinition
@@ -160,65 +100,12 @@ async function app() {
 
   logger.info('Converting FSH to FHIR resources...');
   const outPackage = exportFHIR(tank, defs);
+  writeFHIRResources(outDir, outPackage, program.snapshot);
 
-  let outDir = program.out;
-  if (isIgPubContext && !program.out) {
-    // When running in an IG Publisher context, default output is the parent folder of the tank
-    outDir = path.join(input, '..');
-    logger.info(`No output path specified. Output to ${outDir}`);
-  } else if (!program.out) {
-    // Any other time, default output is just to 'build'
-    outDir = path.join('.', 'build');
-    logger.info(`No output path specified. Output to ${outDir}`);
-  }
-  fs.ensureDirSync(outDir);
-
-  fs.writeFileSync(
-    path.join(outDir, 'package.json'),
-    JSON.stringify(outPackage.config, null, 2),
-    'utf8'
-  );
-
-  logger.info('Exporting FHIR resources as JSON...');
-  let count = 0;
-  const writeResources = (
-    folder: string,
-    resources: { getFileName: () => string; toJSON: (snapshot: boolean) => any }[]
-  ) => {
-    const exportDir = path.join(outDir, 'input', folder);
-    resources.forEach(resource => {
-      fs.outputJSONSync(
-        path.join(exportDir, resource.getFileName()),
-        resource.toJSON(program.snapshot),
-        {
-          spaces: 2
-        }
-      );
-      count++;
-    });
-  };
-  writeResources('profiles', outPackage.profiles);
-  writeResources('extensions', outPackage.extensions);
-  writeResources('vocabulary', [...outPackage.valueSets, ...outPackage.codeSystems]);
-
-  // Sort instances into appropriate directories
-  const instances = cloneDeep(outPackage.instances); // Filter functions below mutate the argument, so clone what is in the package
-  filterInlineInstances(instances);
-  writeResources('examples', filterExampleInstances(instances));
-  writeResources('capabilities', filterCapabilitiesInstances(instances));
-  writeResources('vocabulary', filterVocabularyInstances(instances));
-  writeResources('models', filterModelInstances(instances));
-  writeResources('operations', filterOperationInstances(instances));
-  writeResources('extensions', filterExtensionInstances(instances));
-  writeResources('profiles', filterProfileInstances(instances));
-  writeResources('resources', instances); // Any instance left cannot be categorized any further so should just be in generic resources
-
-  logger.info(`Exported ${count} FHIR resources as JSON.`);
-
-  // If ig-data exists, generate an IG, otherwise, generate resources only
+  // If igDataPath exists, generate an IG, otherwise, generate resources only
   let isIG = false;
-  const igDataPath = path.resolve(input, 'ig-data');
-  if (fs.existsSync(igDataPath)) {
+  const igDataPath = getIgDataPath(input);
+  if (igDataPath) {
     isIG = true;
     logger.info('Assembling Implementation Guide sources...');
     const igExporter = new IGExporter(outPackage, defs, igDataPath, isIgPubContext);
@@ -242,15 +129,6 @@ function getVersion(): string {
   return 'unknown';
 }
 
-function getFilesRecursive(dir: string): string[] {
-  if (fs.statSync(dir).isDirectory()) {
-    const ancestors = fs.readdirSync(dir, 'utf8').map(f => getFilesRecursive(path.join(dir, f)));
-    return [].concat(...ancestors);
-  } else {
-    return [dir];
-  }
-}
-
 function printResults(pkg: Package, isIG: boolean) {
   // NOTE: These variables are creatively names to align well in the strings below while keeping prettier happy
   const prNum = pad(pkg.profiles.length.toString(), 8);
diff --git a/src/utils/Processing.ts b/src/utils/Processing.ts
new file mode 100644
index 0000000..df8a210
--- /dev/null
+++ b/src/utils/Processing.ts
@@ -0,0 +1,184 @@
+import path from 'path';
+import fs from 'fs-extra';
+import { logger } from './FSHLogger';
+import { loadDependency } from '../fhirdefs/load';
+import { FHIRDefinitions } from '../fhirdefs';
+import { FSHTank, RawFSH, importText } from '../import';
+import { cloneDeep } from 'lodash';
+import { Package } from '../export';
+import {
+  filterInlineInstances,
+  filterExampleInstances,
+  filterCapabilitiesInstances,
+  filterVocabularyInstances,
+  filterModelInstances,
+  filterOperationInstances,
+  filterExtensionInstances,
+  filterProfileInstances
+} from './InstanceDefinitionUtils';
+
+export function findInputDir(input: string): string {
+  // If no input folder is specified, set default to current directory
+  if (!input) {
+    input = '.';
+    logger.info('path-to-fsh-defs defaulted to current working directory');
+  }
+
+  // Use fsh/ subdirectory if not already specified and present
+  const fshSubdirectoryPath = path.join(input, 'fsh');
+  if (fs.existsSync(fshSubdirectoryPath)) {
+    input = path.join(input, 'fsh');
+    logger.info('fsh/ subdirectory detected and add to input path');
+  }
+  return input;
+}
+
+export function ensureOutputDir(input: string, output: string, isIgPubContext: boolean): string {
+  if (isIgPubContext) {
+    logger.info(
+      'Current FSH tank conforms to an IG Publisher context. Output will be adjusted accordingly.'
+    );
+  }
+  let outDir = output;
+  if (isIgPubContext && !output) {
+    // When running in an IG Publisher context, default output is the parent folder of the tank
+    outDir = path.join(input, '..');
+    logger.info(`No output path specified. Output to ${outDir}`);
+  } else if (!output) {
+    // Any other time, default output is just to 'build'
+    outDir = path.join('.', 'build');
+    logger.info(`No output path specified. Output to ${outDir}`);
+  }
+  fs.ensureDirSync(outDir);
+  return outDir;
+}
+
+export function readConfig(input: string): any {
+  // Check that package.json exists
+  const packagePath = path.join(input, 'package.json');
+  if (!fs.existsSync(packagePath)) {
+    logger.error('No package.json in FSH definition folder.');
+    throw Error;
+  }
+
+  // Parse package.json
+  let config: any;
+  try {
+    config = fs.readJSONSync(packagePath);
+  } catch (e) {
+    logger.error(`The package.json file is not valid JSON: ${packagePath}`);
+    throw e;
+  }
+
+  // Ensure FHIR R4 is added as a dependency
+  const fhirR4Dependency = config.dependencies?.['hl7.fhir.r4.core'];
+  if (fhirR4Dependency !== '4.0.1') {
+    logger.error(
+      'The package.json must specify FHIR R4 as a dependency. Be sure to' +
+        ' add "hl7.fhir.r4.core": "4.0.1" to the dependencies list.'
+    );
+    throw Error;
+  }
+  return config;
+}
+
+export function loadExternalDependencies(
+  defs: FHIRDefinitions,
+  config: any
+): Promise<FHIRDefinitions | void>[] {
+  const dependencyDefs: Promise<FHIRDefinitions | void>[] = [];
+  for (const dep of Object.keys(config?.dependencies ?? {})) {
+    dependencyDefs.push(
+      loadDependency(dep, config.dependencies[dep], defs)
+        .then(def => {
+          return def;
+        })
+        .catch(e => {
+          logger.error(`Failed to load ${dep}#${config.dependencies[dep]}: ${e.message}`);
+        })
+    );
+  }
+  return dependencyDefs;
+}
+
+export function getRawFSHes(input: string): RawFSH[] {
+  let files: string[];
+  try {
+    files = getFilesRecursive(input);
+  } catch {
+    logger.error('Invalid path to FSH definition folder.');
+    throw Error;
+  }
+  const rawFSHes = files
+    .filter(file => file.endsWith('.fsh'))
+    .map(file => {
+      const filePath = path.resolve(file);
+      const fileContent = fs.readFileSync(filePath, 'utf8');
+      return new RawFSH(fileContent, filePath);
+    });
+  return rawFSHes;
+}
+
+export function fillTank(rawFSHes: RawFSH[], config: any): FSHTank {
+  logger.info('Importing FSH text...');
+  const docs = importText(rawFSHes);
+  return new FSHTank(docs, config);
+}
+
+export function writeFHIRResources(outDir: string, outPackage: Package, snapshot: boolean) {
+  fs.writeFileSync(
+    path.join(outDir, 'package.json'),
+    JSON.stringify(outPackage.config, null, 2),
+    'utf8'
+  );
+
+  logger.info('Exporting FHIR resources as JSON...');
+  let count = 0;
+  const writeResources = (
+    folder: string,
+    resources: { getFileName: () => string; toJSON: (snapshot: boolean) => any }[]
+  ) => {
+    const exportDir = path.join(outDir, 'input', folder);
+    resources.forEach(resource => {
+      fs.outputJSONSync(path.join(exportDir, resource.getFileName()), resource.toJSON(snapshot), {
+        spaces: 2
+      });
+      count++;
+    });
+  };
+  writeResources('profiles', outPackage.profiles);
+  writeResources('extensions', outPackage.extensions);
+  writeResources('vocabulary', [...outPackage.valueSets, ...outPackage.codeSystems]);
+
+  // Sort instances into appropriate directories
+  const instances = cloneDeep(outPackage.instances); // Filter functions below mutate the argument, so clone what is in the package
+  filterInlineInstances(instances);
+  writeResources('examples', filterExampleInstances(instances));
+  writeResources('capabilities', filterCapabilitiesInstances(instances));
+  writeResources('vocabulary', filterVocabularyInstances(instances));
+  writeResources('models', filterModelInstances(instances));
+  writeResources('operations', filterOperationInstances(instances));
+  writeResources('extensions', filterExtensionInstances(instances));
+  writeResources('profiles', filterProfileInstances(instances));
+  writeResources('resources', instances); // Any instance left cannot be categorized any further so should just be in generic resources
+
+  logger.info(`Exported ${count} FHIR resources as JSON.`);
+}
+
+export function getIgDataPath(input: string): string {
+  const igDataPath = path.resolve(input, 'ig-data');
+  if (fs.existsSync(igDataPath)) {
+    return igDataPath;
+  } else {
+    return null;
+  }
+}
+
+function getFilesRecursive(dir: string): string[] {
+  if (fs.statSync(dir).isDirectory()) {
+    const ancestors = fs.readdirSync(dir, 'utf8').map(f => getFilesRecursive(path.join(dir, f)));
+    return [].concat(...ancestors);
+  } else {
+    return [dir];
+  }
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 5552ad9..6672944 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -3,3 +3,4 @@ export * from './FSHLogger';
 export * from './MasterFisher';
 export * from './Mixin';
 export * from './InstanceDefinitionUtils';
+export * from './Processing';


Test patch:
diff --git a/test/utils/Processing.test.ts b/test/utils/Processing.test.ts
new file mode 100644
index 0000000..6621880
--- /dev/null
+++ b/test/utils/Processing.test.ts
@@ -0,0 +1,381 @@
+import fs from 'fs-extra';
+import path from 'path';
+import temp from 'temp';
+import { loggerSpy } from '../testhelpers/loggerSpy';
+import {
+  findInputDir,
+  ensureOutputDir,
+  readConfig,
+  loadExternalDependencies,
+  getRawFSHes,
+  writeFHIRResources,
+  getIgDataPath
+} from '../../src/utils/Processing';
+import * as loadModule from '../../src/fhirdefs/load';
+import { FHIRDefinitions } from '../../src/fhirdefs';
+import { Package } from '../../src/export';
+import { StructureDefinition, ValueSet, CodeSystem, InstanceDefinition } from '../../src/fhirtypes';
+import { PackageLoadError } from '../../src/errors';
+
+describe('Processing', () => {
+  temp.track();
+
+  describe('#findInputDir()', () => {
+    let tempRoot: string;
+
+    beforeAll(() => {
+      tempRoot = temp.mkdirSync('sushi-test');
+      fs.mkdirpSync(path.join(tempRoot, 'has-fsh', 'fsh'));
+      fs.mkdirSync(path.join(tempRoot, 'no-fsh'));
+    });
+
+    afterAll(() => {
+      temp.cleanupSync();
+    });
+
+    it('should find a path to the current working directory when no input folder is specified', () => {
+      const foundInput = findInputDir(undefined);
+      expect(foundInput).toBe('.');
+    });
+
+    it('should find a path to the fsh subdirectory if present', () => {
+      const input = path.join(tempRoot, 'has-fsh');
+      const foundInput = findInputDir(input);
+      expect(foundInput).toBe(path.join(tempRoot, 'has-fsh', 'fsh'));
+    });
+
+    it('should find a path to the provided directory if the fsh subdirectory is not present', () => {
+      const input = path.join(tempRoot, 'no-fsh');
+      const foundInput = findInputDir(input);
+      expect(foundInput).toBe(input);
+    });
+  });
+
+  describe('#ensureOutputDir()', () => {
+    let tempRoot: string;
+
+    beforeAll(() => {
+      tempRoot = temp.mkdirSync('sushi-test');
+      fs.mkdirSync(path.join(tempRoot, 'my-input'));
+    });
+
+    afterAll(() => {
+      temp.cleanupSync();
+    });
+
+    it('should use and create the output directory when it is provided', () => {
+      const input = path.join(tempRoot, 'my-input');
+      const output = path.join(tempRoot, 'my-output');
+      const igContextOutputDir = ensureOutputDir(input, output, true);
+      const nonIgContextOutputDir = ensureOutputDir(input, output, false);
+      expect(igContextOutputDir).toBe(output);
+      expect(nonIgContextOutputDir).toBe(output);
+      expect(fs.existsSync(output)).toBeTruthy();
+    });
+
+    it('should default the output directory to "build" when not running in IG Publisher context', () => {
+      const input = path.join(tempRoot, 'my-input');
+      const outputDir = ensureOutputDir(input, undefined, false);
+      expect(outputDir).toBe('build');
+      expect(fs.existsSync(outputDir)).toBeTruthy();
+    });
+
+    it('should default the output directory to the parent of the input when running in IG Publisher context', () => {
+      const input = path.join(tempRoot, 'my-input');
+      const outputDir = ensureOutputDir(input, undefined, true);
+      expect(outputDir).toBe(tempRoot);
+      expect(fs.existsSync(outputDir)).toBeTruthy();
+    });
+  });
+
+  describe('#readConfig()', () => {
+    beforeEach(() => {
+      loggerSpy.reset();
+    });
+
+    it('should return the contents of package.json from the input directory', () => {
+      const input = path.join(__dirname, 'fixtures', 'valid-json');
+      const expectedConfig = fs.readJSONSync(path.join(input, 'package.json'));
+      const config = readConfig(input);
+      expect(config).toEqual(expectedConfig);
+    });
+
+    it('should log and throw an error when package.json is not found in the input directory', () => {
+      const input = path.join(__dirname, 'fixtures', 'no-package');
+      expect(() => {
+        readConfig(input);
+      }).toThrow();
+      expect(loggerSpy.getLastMessage('error')).toMatch(/No package\.json/s);
+    });
+
+    it('should log and throw an error when the contents of package.json are not valid json', () => {
+      const input = path.join(__dirname, 'fixtures', 'invalid-json');
+      expect(() => {
+        readConfig(input);
+      }).toThrow();
+      expect(loggerSpy.getLastMessage('error')).toMatch(/not valid JSON/s);
+    });
+
+    it('should log and throw an error when the configuration does not include a FHIR R4 dependency', () => {
+      const input = path.join(__dirname, 'fixtures', 'no-fhir-r4');
+      expect(() => {
+        readConfig(input);
+      }).toThrow();
+      expect(loggerSpy.getLastMessage('error')).toMatch(/must specify FHIR R4 as a dependency/s);
+    });
+  });
+
+  describe('#loadExternalDependencies()', () => {
+    beforeAll(() => {
+      jest
+        .spyOn(loadModule, 'loadDependency')
+        .mockImplementation(
+          async (packageName: string, version: string, FHIRDefs: FHIRDefinitions) => {
+            // the mock loader can find hl7.fhir.r4.core and hl7.fhir.us.core
+            if (packageName === 'hl7.fhir.r4.core' || packageName === 'hl7.fhir.us.core') {
+              FHIRDefs.packages.push(`${packageName}#${version}`);
+              return Promise.resolve(FHIRDefs);
+            } else {
+              throw new PackageLoadError(`${packageName}#${version}`);
+            }
+          }
+        );
+    });
+    beforeEach(() => {
+      loggerSpy.reset();
+    });
+
+    it('should load specified dependencies', () => {
+      const config = {
+        dependencies: {
+          'hl7.fhir.r4.core': '4.0.1',
+          'hl7.fhir.us.core': '3.1.0'
+        }
+      };
+      const defs = new FHIRDefinitions();
+      const dependencyDefs = loadExternalDependencies(defs, config);
+      return Promise.all(dependencyDefs).then(() => {
+        expect(defs.packages.length).toBe(2);
+        expect(defs.packages).toContain('hl7.fhir.r4.core#4.0.1');
+        expect(defs.packages).toContain('hl7.fhir.us.core#3.1.0');
+      });
+    });
+
+    it('should log an error when it fails to load a dependency', () => {
+      const config = {
+        dependencies: {
+          'hl7.fhir.r4.core': '4.0.1',
+          'hl7.does.not.exist': 'current'
+        }
+      };
+      const defs = new FHIRDefinitions();
+      const dependencyDefs = loadExternalDependencies(defs, config);
+      return Promise.all(dependencyDefs).then(() => {
+        expect(defs.packages.length).toBe(1);
+        expect(defs.packages).toContain('hl7.fhir.r4.core#4.0.1');
+        expect(loggerSpy.getLastMessage('error')).toMatch(
+          /Failed to load hl7\.does\.not\.exist#current/s
+        );
+      });
+    });
+  });
+
+  describe('#getRawFSHes()', () => {
+    it('should return a RawFSH for each file in the input directory that ends with .fsh', () => {
+      const input = path.join(__dirname, 'fixtures', 'fsh-files');
+      const rawFSHes = getRawFSHes(input);
+      expect(rawFSHes.length).toBe(2);
+      expect(rawFSHes).toContainEqual({
+        content: '// Content of first file',
+        path: path.join(input, 'first.fsh')
+      });
+      expect(rawFSHes).toContainEqual({
+        content: '// Content of second file',
+        path: path.join(input, 'second.fsh')
+      });
+    });
+
+    it('should log and throw an error when the input path is invalid', () => {
+      const input = path.join(__dirname, 'fixtures', 'wrong-path');
+      expect(() => {
+        getRawFSHes(input);
+      }).toThrow();
+      expect(loggerSpy.getLastMessage('error')).toMatch(/Invalid path to FSH definition folder\./s);
+    });
+  });
+
+  describe('#writeFHIRResources()', () => {
+    let tempRoot: string;
+    let outPackage: Package;
+
+    beforeAll(() => {
+      tempRoot = temp.mkdirSync('output-dir');
+      const input = path.join(__dirname, 'fixtures', 'valid-json');
+      const config = readConfig(input);
+      outPackage = new Package(config);
+
+      const myProfile = new StructureDefinition();
+      myProfile.id = 'my-profile';
+      const myExtension = new StructureDefinition();
+      myExtension.id = 'my-extension';
+      const myValueSet = new ValueSet();
+      myValueSet.id = 'my-value-set';
+      const myCodeSystem = new CodeSystem();
+      myCodeSystem.id = 'my-code-system';
+
+      const myInlineInstance = new InstanceDefinition();
+      myInlineInstance.id = 'my-inline-instance';
+      myInlineInstance._instanceMeta.usage = 'Inline';
+      const myExampleInstance = new InstanceDefinition();
+      myExampleInstance.id = 'my-example';
+      myExampleInstance.resourceType = 'Observation';
+      myExampleInstance._instanceMeta.usage = 'Example';
+      const myCapabilityStatement = new InstanceDefinition();
+      myCapabilityStatement.id = 'my-capabilities';
+      myCapabilityStatement.resourceType = 'CapabilityStatement';
+      const myConceptMap = new InstanceDefinition();
+      myConceptMap.id = 'my-concept-map';
+      myConceptMap.resourceType = 'ConceptMap';
+      const myModelInstance = new InstanceDefinition();
+      myModelInstance.id = 'my-model';
+      myModelInstance.resourceType = 'StructureDefinition';
+      myModelInstance.kind = 'logical';
+      const myOperationDefinition = new InstanceDefinition();
+      myOperationDefinition.id = 'my-operation';
+      myOperationDefinition.resourceType = 'OperationDefinition';
+      const myExtensionInstance = new InstanceDefinition();
+      myExtensionInstance.id = 'my-extension-instance';
+      myExtensionInstance.resourceType = 'StructureDefinition';
+      myExtensionInstance.kind = 'resource';
+      myExtensionInstance.type = 'Extension';
+      const myProfileInstance = new InstanceDefinition();
+      myProfileInstance.id = 'my-profile-instance';
+      myProfileInstance.resourceType = 'StructureDefinition';
+      myProfileInstance.kind = 'resource';
+      myProfileInstance.type = 'Observation';
+      const myOtherInstance = new InstanceDefinition();
+      myOtherInstance.id = 'my-other-instance';
+      myOtherInstance.resourceType = 'Observation';
+
+      outPackage.profiles.push(myProfile);
+      outPackage.extensions.push(myExtension);
+      outPackage.valueSets.push(myValueSet);
+      outPackage.codeSystems.push(myCodeSystem);
+      outPackage.instances.push(
+        myInlineInstance,
+        myExampleInstance,
+        myCapabilityStatement,
+        myConceptMap,
+        myModelInstance,
+        myOperationDefinition,
+        myExtensionInstance,
+        myProfileInstance,
+        myOtherInstance
+      );
+      writeFHIRResources(tempRoot, outPackage, false);
+    });
+
+    afterAll(() => {
+      temp.cleanupSync();
+    });
+
+    it('should write profiles and profile instances to the "profiles" directory', () => {
+      const profilesPath = path.join(tempRoot, 'input', 'profiles');
+      expect(fs.existsSync(profilesPath)).toBeTruthy();
+      const profilesFiles = fs.readdirSync(profilesPath);
+      expect(profilesFiles.length).toBe(2);
+      expect(profilesFiles).toContain('StructureDefinition-my-profile.json');
+      expect(profilesFiles).toContain('StructureDefinition-my-profile-instance.json');
+    });
+
+    it('should write extensions and extension instances to the "extensions" directory', () => {
+      const extensionsPath = path.join(tempRoot, 'input', 'extensions');
+      expect(fs.existsSync(extensionsPath)).toBeTruthy();
+      const extensionsFiles = fs.readdirSync(extensionsPath);
+      expect(extensionsFiles.length).toBe(2);
+      expect(extensionsFiles).toContain('StructureDefinition-my-extension.json');
+      expect(extensionsFiles).toContain('StructureDefinition-my-extension-instance.json');
+    });
+
+    it('should write value sets, code systems, and vocabulary instances to the "vocabulary" directory', () => {
+      const vocabularyPath = path.join(tempRoot, 'input', 'vocabulary');
+      expect(fs.existsSync(vocabularyPath)).toBeTruthy();
+      const vocabularyFiles = fs.readdirSync(vocabularyPath);
+      expect(vocabularyFiles.length).toBe(3);
+      expect(vocabularyFiles).toContain('ValueSet-my-value-set.json');
+      expect(vocabularyFiles).toContain('CodeSystem-my-code-system.json');
+      expect(vocabularyFiles).toContain('ConceptMap-my-concept-map.json');
+    });
+
+    it('should write example instances to the "examples" directory', () => {
+      const examplesPath = path.join(tempRoot, 'input', 'examples');
+      expect(fs.existsSync(examplesPath)).toBeTruthy();
+      const examplesFiles = fs.readdirSync(examplesPath);
+      expect(examplesFiles.length).toBe(1);
+      expect(examplesFiles).toContain('Observation-my-example.json');
+    });
+
+    it('should write capability instances to the "capabilities" directory', () => {
+      const capabilitiesPath = path.join(tempRoot, 'input', 'capabilities');
+      expect(fs.existsSync(capabilitiesPath)).toBeTruthy();
+      const capabilitiesFiles = fs.readdirSync(capabilitiesPath);
+      expect(capabilitiesFiles.length).toBe(1);
+      expect(capabilitiesFiles).toContain('CapabilityStatement-my-capabilities.json');
+    });
+
+    it('should write model instances to the "models" directory', () => {
+      const modelsPath = path.join(tempRoot, 'input', 'models');
+      expect(fs.existsSync(modelsPath)).toBeTruthy();
+      const modelsFiles = fs.readdirSync(modelsPath);
+      expect(modelsFiles.length).toBe(1);
+      expect(modelsFiles).toContain('StructureDefinition-my-model.json');
+    });
+
+    it('should write operation instances to the "operations" directory', () => {
+      const operationsPath = path.join(tempRoot, 'input', 'operations');
+      expect(fs.existsSync(operationsPath)).toBeTruthy();
+      const operationsFiles = fs.readdirSync(operationsPath);
+      expect(operationsFiles.length).toBe(1);
+      expect(operationsFiles).toContain('OperationDefinition-my-operation.json');
+    });
+
+    it('should write all other non-inline instances to the "resources" directory', () => {
+      const resourcesPath = path.join(tempRoot, 'input', 'resources');
+      expect(fs.existsSync(resourcesPath)).toBeTruthy();
+      const resourcesFiles = fs.readdirSync(resourcesPath);
+      expect(resourcesFiles.length).toBe(1);
+      expect(resourcesFiles).toContain('Observation-my-other-instance.json');
+    });
+
+    it('should write an info message with the number of instances exported', () => {
+      expect(loggerSpy.getLastMessage('info')).toMatch(/Exported 12 FHIR resources/s);
+    });
+  });
+
+  describe('#getIgDataPath()', () => {
+    let tempRoot: string;
+
+    beforeAll(() => {
+      tempRoot = temp.mkdirSync('sushi-test');
+      fs.mkdirSync(path.join(tempRoot, 'yes-please'));
+      fs.mkdirSync(path.join(tempRoot, 'yes-please', 'ig-data'));
+      fs.mkdirSync(path.join(tempRoot, 'not-this-time'));
+    });
+
+    afterAll(() => {
+      temp.cleanupSync();
+    });
+
+    it('should return the path to the ig-data directory when it exists', () => {
+      const input = path.join(tempRoot, 'yes-please');
+      const igDataPath = getIgDataPath(input);
+      expect(igDataPath).toBe(path.join(input, 'ig-data'));
+    });
+
+    it('should return null when the path to the ig-data directory does not exist', () => {
+      const input = path.join(tempRoot, 'not-this-time');
+      const igDataPath = getIgDataPath(input);
+      expect(igDataPath).toBeNull();
+    });
+  });
+});
diff --git a/test/utils/fixtures/fsh-files/first.fsh b/test/utils/fixtures/fsh-files/first.fsh
new file mode 100644
index 0000000..95ce00c
--- /dev/null
+++ b/test/utils/fixtures/fsh-files/first.fsh
@@ -0,0 +1,1 @@
+// Content of first file
\ No newline at end of file
diff --git a/test/utils/fixtures/fsh-files/second.fsh b/test/utils/fixtures/fsh-files/second.fsh
new file mode 100644
index 0000000..c01a09b
--- /dev/null
+++ b/test/utils/fixtures/fsh-files/second.fsh
@@ -0,0 +1,1 @@
+// Content of second file
\ No newline at end of file
diff --git a/test/utils/fixtures/fsh-files/third.txt b/test/utils/fixtures/fsh-files/third.txt
new file mode 100644
index 0000000..9815f8c
--- /dev/null
+++ b/test/utils/fixtures/fsh-files/third.txt
@@ -0,0 +1,1 @@
+// Content of third file
\ No newline at end of file
diff --git a/test/utils/fixtures/invalid-json/.eslintrc b/test/utils/fixtures/invalid-json/.eslintrc
new file mode 100644
index 0000000..e69de29
diff --git a/test/utils/fixtures/invalid-json/package.json b/test/utils/fixtures/invalid-json/package.json
new file mode 100644
index 0000000..b2716a2
--- /dev/null
+++ b/test/utils/fixtures/invalid-json/package.json
@@ -0,0 +1,2 @@
+This isn't JSON at all!
+I am super unsatisfied with this JSON!
\ No newline at end of file
diff --git a/test/utils/fixtures/no-fhir-r4/package.json b/test/utils/fixtures/no-fhir-r4/package.json
new file mode 100644
index 0000000..1cfc658
--- /dev/null
+++ b/test/utils/fixtures/no-fhir-r4/package.json
@@ -0,0 +1,22 @@
+{
+  "name": "sushi-test",
+  "version": "0.1.0",
+  "canonical": "http://hl7.org/fhir/sushi-test",
+  "url": "http://hl7.org/fhir/sushi-test",
+  "title": "FSH Test IG",
+  "description": "Provides a simple example of how FSH can be used to create an IG",
+  "dependencies": {
+    "hl7.fhir.us.core": "3.1.0",
+    "hl7.fhir.uv.vhdir": "current"
+  },
+  "language": "en",
+  "author": "James Tuna",
+  "maintainers": [
+    {
+      "name": "Bill Cod",
+      "email": "cod@reef.gov",
+      "url": "https://capecodfishermen.org/"
+    }
+  ],
+  "license": "CC0-1.0"
+}
diff --git a/test/utils/fixtures/no-package/something.json b/test/utils/fixtures/no-package/something.json
new file mode 100644
index 0000000..d56d5d6
--- /dev/null
+++ b/test/utils/fixtures/no-package/something.json
@@ -0,0 +1,23 @@
+{
+  "name": "sushi-test",
+  "version": "0.1.0",
+  "canonical": "http://hl7.org/fhir/sushi-test",
+  "url": "http://hl7.org/fhir/sushi-test",
+  "title": "FSH Test IG",
+  "description": "Provides a simple example of how FSH can be used to create an IG",
+  "dependencies": {
+    "hl7.fhir.r4.core": "4.0.1",
+    "hl7.fhir.us.core": "3.1.0",
+    "hl7.fhir.uv.vhdir": "current"
+  },
+  "language": "en",
+  "author": "James Tuna",
+  "maintainers": [
+    {
+      "name": "Bill Cod",
+      "email": "cod@reef.gov",
+      "url": "https://capecodfishermen.org/"
+    }
+  ],
+  "license": "CC0-1.0"
+}
diff --git a/test/utils/fixtures/valid-json/package.json b/test/utils/fixtures/valid-json/package.json
new file mode 100644
index 0000000..d56d5d6
--- /dev/null
+++ b/test/utils/fixtures/valid-json/package.json
@@ -0,0 +1,23 @@
+{
+  "name": "sushi-test",
+  "version": "0.1.0",
+  "canonical": "http://hl7.org/fhir/sushi-test",
+  "url": "http://hl7.org/fhir/sushi-test",
+  "title": "FSH Test IG",
+  "description": "Provides a simple example of how FSH can be used to create an IG",
+  "dependencies": {
+    "hl7.fhir.r4.core": "4.0.1",
+    "hl7.fhir.us.core": "3.1.0",
+    "hl7.fhir.uv.vhdir": "current"
+  },
+  "language": "en",
+  "author": "James Tuna",
+  "maintainers": [
+    {
+      "name": "Bill Cod",
+      "email": "cod@reef.gov",
+      "url": "https://capecodfishermen.org/"
+    }
+  ],
+  "license": "CC0-1.0"
+}