Make LocalProcessManager portable (#3)

LocalProcessManager now uses the same algorithm on Windows and Posix systems to find the executable for a command.

Previously, manager.run(['pub']) would work on Linux, but not on Windows because 'pub.bat' could not be located.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 720d352..f16fa65 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+#### 1.1.0
+
+* Added support to transparently find the right executable under Windows.
+
 #### 1.0.1
 
 * The `executable` and `arguments` parameters have been merged into one
diff --git a/lib/src/interface/common.dart b/lib/src/interface/common.dart
new file mode 100644
index 0000000..dc7e9bb
--- /dev/null
+++ b/lib/src/interface/common.dart
@@ -0,0 +1,61 @@
+// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:io' show File, Directory;
+
+import 'package:meta/meta.dart';
+import 'package:path/path.dart' as p;
+import 'package:platform/platform.dart';
+
+/// Searches the `PATH` for the actual executable that [commandName] is supposed
+/// to launch.
+///
+/// Return `null` if the executable cannot be found.
+@visibleForTesting
+String getExecutablePath(String commandName, String workingDirectory,
+    {Platform platform}) {
+  platform ??= new LocalPlatform();
+  workingDirectory ??= Directory.current.path;
+  // TODO(goderbauer): refactor when github.com/google/platform.dart/issues/2
+  //     is available.
+  String pathSeparator = platform.isWindows ? ';' : ':';
+
+  List<String> extensions = <String>[];
+  if (platform.isWindows && p.extension(commandName).isEmpty) {
+    extensions = platform.environment['PATHEXT'].split(pathSeparator);
+  }
+
+  List<String> candidates = <String>[];
+  if (commandName.contains(p.separator)) {
+    candidates =
+        _getCandidatePaths(commandName, <String>[workingDirectory], extensions);
+  } else {
+    List<String> searchPath = platform.environment['PATH'].split(pathSeparator);
+    candidates = _getCandidatePaths(commandName, searchPath, extensions);
+  }
+  return candidates.firstWhere((String path) => new File(path).existsSync(),
+      orElse: () => null);
+}
+
+/// Returns all possible combinations of `$searchPath\$commandName.$ext` for
+/// `searchPath` in [searchPaths] and `ext` in [extensions].
+///
+/// If [extensions] is empty, it will just enumerate all
+/// `$searchPath\$commandName`.
+/// If [commandName] is an absolute path, it will just enumerate
+/// `$commandName.$ext`.
+Iterable<String> _getCandidatePaths(
+    String commandName, List<String> searchPaths, List<String> extensions) {
+  List<String> withExtensions = extensions.isNotEmpty
+      ? extensions.map((String ext) => '$commandName$ext').toList()
+      : <String>[commandName];
+  if (p.isAbsolute(commandName)) {
+    return withExtensions;
+  }
+  return searchPaths
+      .map((String path) =>
+          withExtensions.map((String command) => p.join(path, command)))
+      .expand((Iterable<String> e) => e)
+      .toList();
+}
diff --git a/lib/src/interface/local_process_manager.dart b/lib/src/interface/local_process_manager.dart
index a5509a5..9719c01 100644
--- a/lib/src/interface/local_process_manager.dart
+++ b/lib/src/interface/local_process_manager.dart
@@ -14,13 +14,9 @@
 
 import 'package:meta/meta.dart';
 
+import 'common.dart';
 import 'process_manager.dart';
 
-String _getExecutable(List<dynamic> command) => command.first.toString();
-
-List<String> _getArguments(List<dynamic> command) =>
-    command.skip(1).map((dynamic element) => element.toString()).toList();
-
 /// Local implementation of the `ProcessManager` interface.
 ///
 /// This implementation delegates directly to the corresponding static methods
@@ -44,7 +40,7 @@
     ProcessStartMode mode: ProcessStartMode.NORMAL,
   }) {
     return Process.start(
-      _getExecutable(command),
+      _getExecutable(command, workingDirectory, runInShell),
       _getArguments(command),
       workingDirectory: workingDirectory,
       environment: environment,
@@ -65,7 +61,7 @@
     Encoding stderrEncoding: SYSTEM_ENCODING,
   }) {
     return Process.run(
-      _getExecutable(command),
+      _getExecutable(command, workingDirectory, runInShell),
       _getArguments(command),
       workingDirectory: workingDirectory,
       environment: environment,
@@ -87,7 +83,7 @@
     Encoding stderrEncoding: SYSTEM_ENCODING,
   }) {
     return Process.runSync(
-      _getExecutable(command),
+      _getExecutable(command, workingDirectory, runInShell),
       _getArguments(command),
       workingDirectory: workingDirectory,
       environment: environment,
@@ -99,7 +95,27 @@
   }
 
   @override
+  bool canRun(@checked Object executable, {String workingDirectory}) =>
+      getExecutablePath(executable, workingDirectory) != null;
+
+  @override
   bool killPid(int pid, [ProcessSignal signal = ProcessSignal.SIGTERM]) {
     return Process.killPid(pid, signal);
   }
 }
+
+String _getExecutable(
+    List<dynamic> command, String workingDirectory, bool runInShell) {
+  String commandName = command.first.toString();
+  if (runInShell) {
+    return commandName;
+  }
+  String exe = getExecutablePath(commandName, workingDirectory);
+  if (exe == null) {
+    throw new ArgumentError('Cannot find executable for $commandName.');
+  }
+  return exe;
+}
+
+List<String> _getArguments(List<dynamic> command) =>
+    command.skip(1).map((dynamic element) => element.toString()).toList();
diff --git a/lib/src/interface/process_manager.dart b/lib/src/interface/process_manager.dart
index 9630dce..8375970 100644
--- a/lib/src/interface/process_manager.dart
+++ b/lib/src/interface/process_manager.dart
@@ -163,6 +163,9 @@
     Encoding stderrEncoding: SYSTEM_ENCODING,
   });
 
+  /// Returns `true` if the [executable] exists and if it can be executed.
+  bool canRun(dynamic executable, {String workingDirectory});
+
   /// Kills the process with id [pid].
   ///
   /// Where possible, sends the [signal] to the process with id
diff --git a/lib/src/record_replay/can_run_manifest_entry.dart b/lib/src/record_replay/can_run_manifest_entry.dart
new file mode 100644
index 0000000..095a667
--- /dev/null
+++ b/lib/src/record_replay/can_run_manifest_entry.dart
@@ -0,0 +1,36 @@
+import 'manifest_entry.dart';
+
+/// An entry in the process invocation manifest for `canRun`.
+class CanRunManifestEntry extends ManifestEntry {
+  @override
+  final String type = 'can_run';
+
+  /// The name of the executable for which the run-ability is checked.
+  final String executable;
+
+  /// The result of the check.
+  final bool result;
+
+  /// Creates a new manifest entry with the given properties.
+  CanRunManifestEntry({this.executable, this.result});
+
+  /// Creates a new manifest entry populated with the specified JSON [data].
+  ///
+  /// If any required fields are missing from the JSON data, this will throw
+  /// a [FormatException].
+  factory CanRunManifestEntry.fromJson(Map<String, dynamic> data) {
+    checkRequiredField(data, 'executable');
+    checkRequiredField(data, 'result');
+    CanRunManifestEntry entry = new CanRunManifestEntry(
+      executable: data['executable'],
+      result: data['result'],
+    );
+    return entry;
+  }
+
+  @override
+  Map<String, dynamic> toJson() => new JsonBuilder()
+      .add('executable', executable)
+      .add('result', result)
+      .entry;
+}
diff --git a/lib/src/record_replay/manifest.dart b/lib/src/record_replay/manifest.dart
index ffac2fb..45c1177 100644
--- a/lib/src/record_replay/manifest.dart
+++ b/lib/src/record_replay/manifest.dart
@@ -5,7 +5,9 @@
 import 'dart:convert';
 import 'dart:io' show ProcessStartMode;
 
+import 'can_run_manifest_entry.dart';
 import 'manifest_entry.dart';
+import 'run_manifest_entry.dart';
 
 /// Tests if two lists contain pairwise equal elements.
 bool _areListsEqual/*<T>*/(
@@ -48,7 +50,18 @@
     List<Map<String, dynamic>> decoded = new JsonDecoder().convert(json);
     Manifest manifest = new Manifest();
     decoded.forEach((Map<String, dynamic> entry) {
-      manifest._entries.add(new ManifestEntry.fromJson(entry));
+      switch (entry['type']) {
+        case 'run':
+          manifest._entries.add(new RunManifestEntry.fromJson(entry['body']));
+          break;
+        case 'can_run':
+          manifest._entries
+              .add(new CanRunManifestEntry.fromJson(entry['body']));
+          break;
+        default:
+          throw new UnsupportedError(
+              'Manifest type ${entry['type']} is unkown.');
+      }
     });
     return manifest;
   }
@@ -59,15 +72,16 @@
   /// The number of entries currently in the manifest.
   int get length => _entries.length;
 
-  /// Gets the entry whose [ManifestEntry.pid] matches the specified [pid].
-  ManifestEntry getEntry(int pid) {
-    return _entries.firstWhere((ManifestEntry entry) => entry.pid == pid);
+  /// Gets the entry whose [RunManifestEntry.pid] matches the specified [pid].
+  ManifestEntry getRunEntry(int pid) {
+    return _entries.firstWhere(
+        (ManifestEntry entry) => entry is RunManifestEntry && entry.pid == pid);
   }
 
   /// Finds the first manifest entry that has not been invoked and whose
   /// metadata matches the specified criteria. If no arguments are specified,
   /// this will simply return the first entry that has not yet been invoked.
-  ManifestEntry findPendingEntry({
+  ManifestEntry findPendingRunEntry({
     List<String> command,
     ProcessStartMode mode,
     Encoding stdoutEncoding,
@@ -75,14 +89,28 @@
   }) {
     return _entries.firstWhere(
       (ManifestEntry entry) {
-        bool hit = !entry.invoked;
-        // Ignore workingDirectory & environment, as they could
-        // yield false negatives.
-        hit = hit && _isHit(entry.command, command, _areListsEqual);
-        hit = hit && _isHit(entry.mode, mode);
-        hit = hit && _isHit(entry.stdoutEncoding, stdoutEncoding);
-        hit = hit && _isHit(entry.stderrEncoding, stderrEncoding);
-        return hit;
+        return entry is RunManifestEntry &&
+            !entry.invoked &&
+            _isHit(entry.command, command, _areListsEqual) &&
+            _isHit(entry.mode, mode) &&
+            _isHit(entry.stdoutEncoding, stdoutEncoding) &&
+            _isHit(entry.stderrEncoding, stderrEncoding);
+      },
+      orElse: () => null,
+    );
+  }
+
+  /// Finds the first manifest entry that has not been invoked and whose
+  /// metadata matches the specified criteria. If no arguments are specified,
+  /// this will simply return the first entry that has not yet been invoked.
+  ManifestEntry findPendingCanRunEntry({
+    String executable,
+  }) {
+    return _entries.firstWhere(
+      (ManifestEntry entry) {
+        return entry is CanRunManifestEntry &&
+            !entry.invoked &&
+            _isHit(entry.executable, executable);
       },
       orElse: () => null,
     );
@@ -91,7 +119,10 @@
   /// Returns a JSON-encoded representation of this manifest.
   String toJson() {
     List<Map<String, dynamic>> list = <Map<String, dynamic>>[];
-    _entries.forEach((ManifestEntry entry) => list.add(entry.toJson()));
+    _entries.forEach((ManifestEntry entry) => list.add(new JsonBuilder()
+        .add('type', entry.type)
+        .add('body', entry.toJson())
+        .entry));
     return const JsonEncoder.withIndent('  ').convert(list);
   }
 }
diff --git a/lib/src/record_replay/manifest_entry.dart b/lib/src/record_replay/manifest_entry.dart
index db568ad..11d66f6 100644
--- a/lib/src/record_replay/manifest_entry.dart
+++ b/lib/src/record_replay/manifest_entry.dart
@@ -1,140 +1,11 @@
-// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
-// for details. All rights reserved. Use of this source code is governed by a
-// BSD-style license that can be found in the LICENSE file.
-
-import 'dart:convert';
-import 'dart:io' show ProcessStartMode, SYSTEM_ENCODING;
-
 import 'manifest.dart';
 import 'replay_process_manager.dart';
 
-/// Throws a [FormatException] if [data] does not contain [key].
-void _checkRequiredField(Map<String, dynamic> data, String key) {
-  if (!data.containsKey(key))
-    throw new FormatException('Required field missing: $key');
-}
-
-/// Gets a `ProcessStartMode` value by its string name.
-ProcessStartMode _getProcessStartMode(String value) {
-  if (value != null) {
-    for (ProcessStartMode mode in ProcessStartMode.values) {
-      if (mode.toString() == value) {
-        return mode;
-      }
-    }
-    throw new FormatException('Invalid value for mode: $value');
-  }
-  return null;
-}
-
-/// Gets an `Encoding` instance by the encoding name.
-Encoding _getEncoding(String encoding) {
-  if (encoding == 'system') {
-    return SYSTEM_ENCODING;
-  } else if (encoding != null) {
-    return Encoding.getByName(encoding);
-  }
-  return null;
-}
-
 /// An entry in the process invocation manifest.
 ///
 /// Each entry in the [Manifest] represents a single recorded process
 /// invocation.
-class ManifestEntry {
-  /// The process id.
-  final int pid;
-
-  /// The base file name for this entry. `stdout` and `stderr` files for this
-  /// process will be serialized in the recording directory as
-  /// `$basename.stdout` and `$basename.stderr`, respectively.
-  final String basename;
-
-  /// The command that was run. The first element is the executable, and the
-  /// remaining elements are the arguments to the executable.
-  final List<String> command;
-
-  /// The process' working directory when it was spawned.
-  final String workingDirectory;
-
-  /// The environment variables that were passed to the process.
-  final Map<String, String> environment;
-
-  /// Whether the invoker's environment was made available to the process.
-  final bool includeParentEnvironment;
-
-  /// Whether the process was spawned through a system shell.
-  final bool runInShell;
-
-  /// The mode with which the process was spawned.
-  final ProcessStartMode mode;
-
-  /// The encoding used for the `stdout` of the process.
-  final Encoding stdoutEncoding;
-
-  /// The encoding used for the `stderr` of the process.
-  final Encoding stderrEncoding;
-
-  /// The exit code of the process.
-  int exitCode;
-
-  /// Creates a new manifest entry with the given properties.
-  ManifestEntry({
-    this.pid,
-    this.basename,
-    this.command,
-    this.workingDirectory,
-    this.environment,
-    this.includeParentEnvironment,
-    this.runInShell,
-    this.mode,
-    this.stdoutEncoding,
-    this.stderrEncoding,
-    this.exitCode,
-  });
-
-  /// Creates a new manifest entry populated with the specified JSON [data].
-  ///
-  /// If any required fields are missing from the JSON data, this will throw
-  /// a [FormatException].
-  factory ManifestEntry.fromJson(Map<String, dynamic> data) {
-    _checkRequiredField(data, 'pid');
-    _checkRequiredField(data, 'basename');
-    _checkRequiredField(data, 'command');
-    ManifestEntry entry = new ManifestEntry(
-      pid: data['pid'],
-      basename: data['basename'],
-      command: data['command'],
-      workingDirectory: data['workingDirectory'],
-      environment: data['environment'],
-      includeParentEnvironment: data['includeParentEnvironment'],
-      runInShell: data['runInShell'],
-      mode: _getProcessStartMode(data['mode']),
-      stdoutEncoding: _getEncoding(data['stdoutEncoding']),
-      stderrEncoding: _getEncoding(data['stderrEncoding']),
-      exitCode: data['exitCode'],
-    );
-    entry.daemon = data['daemon'];
-    entry.notResponding = data['notResponding'];
-    return entry;
-  }
-
-  /// The executable that was invoked.
-  String get executable => command.first;
-
-  /// The arguments that were passed to [executable].
-  List<String> get arguments => command.skip(1).toList();
-
-  /// Indicates that the process is a daemon.
-  bool get daemon => _daemon;
-  bool _daemon = false;
-  set daemon(bool value) => _daemon = value ?? false;
-
-  /// Indicates that the process did not respond to `SIGTERM`.
-  bool get notResponding => _notResponding;
-  bool _notResponding = false;
-  set notResponding(bool value) => _notResponding = value ?? false;
-
+abstract class ManifestEntry {
   /// Whether this entry has been "invoked" by [ReplayProcessManager].
   bool get invoked => _invoked;
   bool _invoked = false;
@@ -144,36 +15,32 @@
     _invoked = true;
   }
 
+  /// The type of this [ManifestEntry].
+  String get type;
+
   /// Returns a JSON-encodable representation of this manifest entry.
-  Map<String, dynamic> toJson() => new _JsonBuilder()
-      .add('pid', pid)
-      .add('basename', basename)
-      .add('command', command)
-      .add('workingDirectory', workingDirectory)
-      .add('environment', environment)
-      .add('includeParentEnvironment', includeParentEnvironment)
-      .add('runInShell', runInShell)
-      .add('mode', mode, () => mode.toString())
-      .add('stdoutEncoding', stdoutEncoding, () => stdoutEncoding.name)
-      .add('stderrEncoding', stderrEncoding, () => stderrEncoding.name)
-      .add('daemon', daemon)
-      .add('notResponding', notResponding)
-      .add('exitCode', exitCode)
-      .entry;
+  Map<String, dynamic> toJson();
 }
 
 /// A lightweight class that provides a means of building a manifest entry
 /// JSON object.
-class _JsonBuilder {
+class JsonBuilder {
+  /// The JSON-encodable object.
   final Map<String, dynamic> entry = <String, dynamic>{};
 
   /// Adds the specified key/value pair to the manifest entry iff the value
   /// is non-null. If [jsonValue] is specified, its value will be used instead
   /// of the raw value.
-  _JsonBuilder add(String name, dynamic value, [dynamic jsonValue()]) {
+  JsonBuilder add(String name, dynamic value, [dynamic jsonValue()]) {
     if (value != null) {
       entry[name] = jsonValue == null ? value : jsonValue();
     }
     return this;
   }
 }
+
+/// Throws a [FormatException] if [data] does not contain [key].
+void checkRequiredField(Map<String, dynamic> data, String key) {
+  if (!data.containsKey(key))
+    throw new FormatException('Required field missing: $key');
+}
diff --git a/lib/src/record_replay/recording_process_manager.dart b/lib/src/record_replay/recording_process_manager.dart
index 51ed57f..c1f337c 100644
--- a/lib/src/record_replay/recording_process_manager.dart
+++ b/lib/src/record_replay/recording_process_manager.dart
@@ -18,11 +18,12 @@
 import 'package:path/path.dart' as path;
 
 import '../interface/process_manager.dart';
+import 'can_run_manifest_entry.dart';
 import 'common.dart';
 import 'constants.dart';
 import 'manifest.dart';
-import 'manifest_entry.dart';
 import 'replay_process_manager.dart';
+import 'run_manifest_entry.dart';
 
 /// Records process invocation activity and serializes it to disk.
 ///
@@ -97,7 +98,7 @@
 
     List<String> sanitizedCommand = sanitize(command);
     String basename = _getBasename(process.pid, sanitizedCommand);
-    ManifestEntry entry = new ManifestEntry(
+    RunManifestEntry entry = new RunManifestEntry(
       pid: process.pid,
       basename: basename,
       command: sanitizedCommand,
@@ -145,7 +146,7 @@
 
     List<String> sanitizedCommand = sanitize(command);
     String basename = _getBasename(result.pid, sanitizedCommand);
-    _manifest.add(new ManifestEntry(
+    _manifest.add(new RunManifestEntry(
       pid: result.pid,
       basename: basename,
       command: sanitizedCommand,
@@ -201,7 +202,7 @@
 
     List<String> sanitizedCommand = sanitize(command);
     String basename = _getBasename(result.pid, sanitizedCommand);
-    _manifest.add(new ManifestEntry(
+    _manifest.add(new RunManifestEntry(
       pid: result.pid,
       basename: basename,
       command: sanitizedCommand,
@@ -229,6 +230,15 @@
   }
 
   @override
+  bool canRun(dynamic executable, {String workingDirectory}) {
+    bool result =
+        delegate.canRun(executable, workingDirectory: workingDirectory);
+    _manifest.add(new CanRunManifestEntry(
+        executable: executable.toString(), result: result));
+    return result;
+  }
+
+  @override
   bool killPid(int pid, [io.ProcessSignal signal = io.ProcessSignal.SIGTERM]) {
     return delegate.killPid(pid, signal);
   }
@@ -283,13 +293,13 @@
 
   /// Waits for all running processes to exit, and records their exit codes in
   /// the process manifest. Any process that doesn't exit within [timeout]
-  /// will be marked as a [ManifestEntry.daemon] and be signalled with
+  /// will be marked as a [RunManifestEntry.daemon] and be signalled with
   /// `SIGTERM`. If such processes *still* don't exit within [timeout] after
-  /// being signalled, they'll be marked as [ManifestEntry.notResponding].
+  /// being signalled, they'll be marked as [RunManifestEntry.notResponding].
   Future<Null> _waitForRunningProcessesToExit(Duration timeout) async {
     await _waitForRunningProcessesToExitWithTimeout(
         timeout: timeout,
-        onTimeout: (ManifestEntry entry) {
+        onTimeout: (RunManifestEntry entry) {
           entry.daemon = true;
           delegate.killPid(entry.pid);
         });
@@ -297,16 +307,16 @@
     // them to shutdown, wait one more time for those processes to exit.
     await _waitForRunningProcessesToExitWithTimeout(
         timeout: timeout,
-        onTimeout: (ManifestEntry entry) {
+        onTimeout: (RunManifestEntry entry) {
           entry.notResponding = true;
         });
   }
 
   Future<Null> _waitForRunningProcessesToExitWithTimeout({
     Duration timeout,
-    void onTimeout(ManifestEntry entry),
+    void onTimeout(RunManifestEntry entry),
   }) async {
-    void callOnTimeout(int pid) => onTimeout(_manifest.getEntry(pid));
+    void callOnTimeout(int pid) => onTimeout(_manifest.getRunEntry(pid));
     await Future
         .wait(new List<Future<int>>.from(_runningProcesses.values))
         .timeout(timeout,
diff --git a/lib/src/record_replay/replay_process_manager.dart b/lib/src/record_replay/replay_process_manager.dart
index 3c2362a..bef2343 100644
--- a/lib/src/record_replay/replay_process_manager.dart
+++ b/lib/src/record_replay/replay_process_manager.dart
@@ -18,10 +18,11 @@
 import 'package:path/path.dart' as path;
 
 import '../interface/process_manager.dart';
+import 'can_run_manifest_entry.dart';
 import 'common.dart';
 import 'constants.dart';
 import 'manifest.dart';
-import 'manifest_entry.dart';
+import 'run_manifest_entry.dart';
 import 'recording_process_manager.dart';
 
 /// Fakes all process invocations by replaying a previously-recorded series
@@ -95,7 +96,7 @@
     bool runInShell: false,
     io.ProcessStartMode mode: io.ProcessStartMode.NORMAL,
   }) async {
-    ManifestEntry entry = _popEntry(command, mode: mode);
+    RunManifestEntry entry = _popRunEntry(command, mode: mode);
     _ReplayResult result = await _ReplayResult.create(this, entry);
     return result.asProcess(entry.daemon);
   }
@@ -110,7 +111,7 @@
     Encoding stdoutEncoding: io.SYSTEM_ENCODING,
     Encoding stderrEncoding: io.SYSTEM_ENCODING,
   }) async {
-    ManifestEntry entry = _popEntry(command,
+    RunManifestEntry entry = _popRunEntry(command,
         stdoutEncoding: stdoutEncoding, stderrEncoding: stderrEncoding);
     return await _ReplayResult.create(this, entry);
   }
@@ -125,7 +126,7 @@
     Encoding stdoutEncoding: io.SYSTEM_ENCODING,
     Encoding stderrEncoding: io.SYSTEM_ENCODING,
   }) {
-    ManifestEntry entry = _popEntry(command,
+    RunManifestEntry entry = _popRunEntry(command,
         stdoutEncoding: stdoutEncoding, stderrEncoding: stderrEncoding);
     return _ReplayResult.createSync(this, entry);
   }
@@ -133,14 +134,14 @@
   /// Finds and returns the next entry in the process manifest that matches
   /// the specified process arguments. Once found, it marks the manifest entry
   /// as having been invoked and thus not eligible for invocation again.
-  ManifestEntry _popEntry(
+  RunManifestEntry _popRunEntry(
     List<dynamic> command, {
     io.ProcessStartMode mode,
     Encoding stdoutEncoding,
     Encoding stderrEncoding,
   }) {
     List<String> sanitizedCommand = sanitize(command);
-    ManifestEntry entry = _manifest.findPendingEntry(
+    RunManifestEntry entry = _manifest.findPendingRunEntry(
       command: sanitizedCommand,
       mode: mode,
       stdoutEncoding: stdoutEncoding,
@@ -157,6 +158,18 @@
   }
 
   @override
+  bool canRun(dynamic executable, {String workingDirectory}) {
+    CanRunManifestEntry entry = _manifest.findPendingCanRunEntry(
+      executable: executable.toString(),
+    );
+    if (entry == null) {
+      throw new ArgumentError('No matching invocation found for $executable');
+    }
+    entry.setInvoked();
+    return entry.result;
+  }
+
+  @override
   bool killPid(int pid, [io.ProcessSignal signal = io.ProcessSignal.SIGTERM]) {
     throw new UnsupportedError(
         "$runtimeType.killPid() has not been implemented because at the time "
@@ -192,7 +205,7 @@
 
   static Future<_ReplayResult> create(
     ReplayProcessManager manager,
-    ManifestEntry entry,
+    RunManifestEntry entry,
   ) async {
     FileSystem fs = manager.location.fileSystem;
     String basePath = path.join(manager.location.path, entry.basename);
@@ -220,7 +233,7 @@
 
   static _ReplayResult createSync(
     ReplayProcessManager manager,
-    ManifestEntry entry,
+    RunManifestEntry entry,
   ) {
     FileSystem fs = manager.location.fileSystem;
     String basePath = path.join(manager.location.path, entry.basename);
diff --git a/lib/src/record_replay/run_manifest_entry.dart b/lib/src/record_replay/run_manifest_entry.dart
new file mode 100644
index 0000000..c9e3103
--- /dev/null
+++ b/lib/src/record_replay/run_manifest_entry.dart
@@ -0,0 +1,148 @@
+// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+import 'dart:io' show ProcessStartMode, SYSTEM_ENCODING;
+
+import 'manifest_entry.dart';
+
+/// Gets a `ProcessStartMode` value by its string name.
+ProcessStartMode _getProcessStartMode(String value) {
+  if (value != null) {
+    for (ProcessStartMode mode in ProcessStartMode.values) {
+      if (mode.toString() == value) {
+        return mode;
+      }
+    }
+    throw new FormatException('Invalid value for mode: $value');
+  }
+  return null;
+}
+
+/// Gets an `Encoding` instance by the encoding name.
+Encoding _getEncoding(String encoding) {
+  if (encoding == 'system') {
+    return SYSTEM_ENCODING;
+  } else if (encoding != null) {
+    return Encoding.getByName(encoding);
+  }
+  return null;
+}
+
+/// An entry in the process invocation manifest for running an executable.
+class RunManifestEntry extends ManifestEntry {
+  @override
+  final String type = 'run';
+
+  /// The process id.
+  final int pid;
+
+  /// The base file name for this entry. `stdout` and `stderr` files for this
+  /// process will be serialized in the recording directory as
+  /// `$basename.stdout` and `$basename.stderr`, respectively.
+  final String basename;
+
+  /// The command that was run. The first element is the executable, and the
+  /// remaining elements are the arguments to the executable.
+  final List<String> command;
+
+  /// The process' working directory when it was spawned.
+  final String workingDirectory;
+
+  /// The environment variables that were passed to the process.
+  final Map<String, String> environment;
+
+  /// Whether the invoker's environment was made available to the process.
+  final bool includeParentEnvironment;
+
+  /// Whether the process was spawned through a system shell.
+  final bool runInShell;
+
+  /// The mode with which the process was spawned.
+  final ProcessStartMode mode;
+
+  /// The encoding used for the `stdout` of the process.
+  final Encoding stdoutEncoding;
+
+  /// The encoding used for the `stderr` of the process.
+  final Encoding stderrEncoding;
+
+  /// The exit code of the process.
+  int exitCode;
+
+  /// Creates a new manifest entry with the given properties.
+  RunManifestEntry({
+    this.pid,
+    this.basename,
+    this.command,
+    this.workingDirectory,
+    this.environment,
+    this.includeParentEnvironment,
+    this.runInShell,
+    this.mode,
+    this.stdoutEncoding,
+    this.stderrEncoding,
+    this.exitCode,
+  });
+
+  /// Creates a new manifest entry populated with the specified JSON [data].
+  ///
+  /// If any required fields are missing from the JSON data, this will throw
+  /// a [FormatException].
+  factory RunManifestEntry.fromJson(Map<String, dynamic> data) {
+    checkRequiredField(data, 'pid');
+    checkRequiredField(data, 'basename');
+    checkRequiredField(data, 'command');
+    RunManifestEntry entry = new RunManifestEntry(
+      pid: data['pid'],
+      basename: data['basename'],
+      command: data['command'],
+      workingDirectory: data['workingDirectory'],
+      environment: data['environment'],
+      includeParentEnvironment: data['includeParentEnvironment'],
+      runInShell: data['runInShell'],
+      mode: _getProcessStartMode(data['mode']),
+      stdoutEncoding: _getEncoding(data['stdoutEncoding']),
+      stderrEncoding: _getEncoding(data['stderrEncoding']),
+      exitCode: data['exitCode'],
+    );
+    entry.daemon = data['daemon'];
+    entry.notResponding = data['notResponding'];
+    return entry;
+  }
+
+  /// The executable that was invoked.
+  String get executable => command.first;
+
+  /// The arguments that were passed to [executable].
+  List<String> get arguments => command.skip(1).toList();
+
+  /// Indicates that the process is a daemon.
+  bool get daemon => _daemon;
+  bool _daemon = false;
+  set daemon(bool value) => _daemon = value ?? false;
+
+  /// Indicates that the process did not respond to `SIGTERM`.
+  bool get notResponding => _notResponding;
+  bool _notResponding = false;
+  set notResponding(bool value) => _notResponding = value ?? false;
+
+  /// Returns a JSON-encodable representation of this manifest entry.
+  @override
+  Map<String, dynamic> toJson() => new JsonBuilder()
+      .add('pid', pid)
+      .add('basename', basename)
+      .add('command', command)
+      .add('workingDirectory', workingDirectory)
+      .add('environment', environment)
+      .add('includeParentEnvironment', includeParentEnvironment)
+      .add('runInShell', runInShell)
+      .add('mode', mode, () => mode.toString())
+      .add('stdoutEncoding', stdoutEncoding, () => stdoutEncoding.name)
+      .add('stderrEncoding', stderrEncoding, () => stderrEncoding.name)
+      .add('daemon', daemon)
+      .add('notResponding', notResponding)
+      .add('exitCode', exitCode)
+      .entry;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 4acfa0c..2065165 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,15 +1,16 @@
 name: process
-version: 1.0.1
+version: 1.1.0
 authors:
 - Todd Volkert <tvolkert@google.com>
 description: A pluggable, mockable process invocation abstraction for Dart.
-homepage: https://github.com/tvolkert/process
+homepage: https://github.com/google/process.dart
 
 dependencies:
   file: ^1.0.1
   intl: '>=0.14.0 <0.15.0'
   meta: ^1.0.4
   path: ^1.4.0
+  platform: ^1.0.1
 
 dev_dependencies:
   test: ^0.12.10
diff --git a/test/data/replay/MANIFEST.txt b/test/data/replay/MANIFEST.txt
index eda5bea..978ec3d 100644
--- a/test/data/replay/MANIFEST.txt
+++ b/test/data/replay/MANIFEST.txt
@@ -1,23 +1,36 @@
 [
   {
-    "pid": 100,
-    "basename": "001.sing.100",
-    "command": [
-      "sing",
-      "ppap"
-    ],
-    "mode": "ProcessStartMode.NORMAL",
-    "exitCode": 0
+    "type": "run",
+    "body": {
+      "pid": 100,
+      "basename": "001.sing.100",
+      "command": [
+        "sing",
+        "ppap"
+      ],
+      "mode": "ProcessStartMode.NORMAL",
+      "exitCode": 0
+    }
   },
   {
-    "pid": 101,
-    "basename": "002.dance.101",
-    "command": [
-      "dance",
-      "gangnam-style"
-    ],
-    "stdoutEncoding": "system",
-    "stderrEncoding": "system",
-    "exitCode": 2
+    "type": "run",
+    "body": {
+      "pid": 101,
+      "basename": "002.dance.101",
+      "command": [
+        "dance",
+        "gangnam-style"
+      ],
+      "stdoutEncoding": "system",
+      "stderrEncoding": "system",
+      "exitCode": 2
+    }
+  },
+  {
+    "type": "can_run",
+    "body": {
+      "executable": "marathon",
+      "result": true
+    }
   }
 ]
diff --git a/test/record_test.dart b/test/record_test.dart
index b340bb2..a94984d 100644
--- a/test/record_test.dart
+++ b/test/record_test.dart
@@ -3,10 +3,11 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:convert';
-import 'dart:io' show Process, ProcessResult, SYSTEM_ENCODING;
+import 'dart:io' show Platform, Process, ProcessResult, SYSTEM_ENCODING;
 
 import 'package:file/file.dart';
 import 'package:file/local.dart';
+import 'package:path/path.dart' as p;
 import 'package:process/process.dart';
 import 'package:process/record_replay.dart';
 import 'package:test/test.dart';
@@ -15,6 +16,9 @@
 
 void main() {
   FileSystem fs = new LocalFileSystem();
+  // TODO(goderbauer): refactor when github.com/google/platform.dart/issues/1
+  //     is available.
+  String newline = Platform.isWindows ? '\r\n' : '\n';
 
   group('RecordingProcessManager', () {
     Directory tmp;
@@ -30,7 +34,8 @@
     });
 
     test('start', () async {
-      Process process = await manager.start(<String>['echo', 'foo']);
+      Process process =
+          await manager.start(<String>['echo', 'foo'], runInShell: true);
       int pid = process.pid;
       int exitCode = await process.exitCode;
       List<int> stdout = await consume(process.stdout);
@@ -45,22 +50,25 @@
       _Recording recording = new _Recording(tmp);
       expect(recording.manifest, hasLength(1));
       Map<String, dynamic> entry = recording.manifest.first;
-      expect(entry['pid'], pid);
-      expect(entry['command'], <String>['echo', 'foo']);
-      expect(entry['mode'], 'ProcessStartMode.NORMAL');
-      expect(entry['exitCode'], exitCode);
+      expect(entry['type'], 'run');
+      Map<String, dynamic> body = entry['body'];
+      expect(body['pid'], pid);
+      expect(body['command'], <String>['echo', 'foo']);
+      expect(body['mode'], 'ProcessStartMode.NORMAL');
+      expect(body['exitCode'], exitCode);
       expect(recording.stdoutForEntryAt(0), stdout);
       expect(recording.stderrForEntryAt(0), stderr);
     });
 
     test('run', () async {
-      ProcessResult result = await manager.run(<String>['echo', 'bar']);
+      ProcessResult result =
+          await manager.run(<String>['echo', 'bar'], runInShell: true);
       int pid = result.pid;
       int exitCode = result.exitCode;
       String stdout = result.stdout;
       String stderr = result.stderr;
       expect(exitCode, 0);
-      expect(stdout, 'bar\n');
+      expect(stdout, 'bar$newline');
       expect(stderr, isEmpty);
 
       // Force the recording to be written to disk.
@@ -69,23 +77,26 @@
       _Recording recording = new _Recording(tmp);
       expect(recording.manifest, hasLength(1));
       Map<String, dynamic> entry = recording.manifest.first;
-      expect(entry['pid'], pid);
-      expect(entry['command'], <String>['echo', 'bar']);
-      expect(entry['stdoutEncoding'], 'system');
-      expect(entry['stderrEncoding'], 'system');
-      expect(entry['exitCode'], exitCode);
+      expect(entry['type'], 'run');
+      Map<String, dynamic> body = entry['body'];
+      expect(body['pid'], pid);
+      expect(body['command'], <String>['echo', 'bar']);
+      expect(body['stdoutEncoding'], 'system');
+      expect(body['stderrEncoding'], 'system');
+      expect(body['exitCode'], exitCode);
       expect(recording.stdoutForEntryAt(0), stdout);
       expect(recording.stderrForEntryAt(0), stderr);
     });
 
     test('runSync', () async {
-      ProcessResult result = manager.runSync(<String>['echo', 'baz']);
+      ProcessResult result =
+          manager.runSync(<String>['echo', 'baz'], runInShell: true);
       int pid = result.pid;
       int exitCode = result.exitCode;
       String stdout = result.stdout;
       String stderr = result.stderr;
       expect(exitCode, 0);
-      expect(stdout, 'baz\n');
+      expect(stdout, 'baz$newline');
       expect(stderr, isEmpty);
 
       // Force the recording to be written to disk.
@@ -94,14 +105,34 @@
       _Recording recording = new _Recording(tmp);
       expect(recording.manifest, hasLength(1));
       Map<String, dynamic> entry = recording.manifest.first;
-      expect(entry['pid'], pid);
-      expect(entry['command'], <String>['echo', 'baz']);
-      expect(entry['stdoutEncoding'], 'system');
-      expect(entry['stderrEncoding'], 'system');
-      expect(entry['exitCode'], exitCode);
+      expect(entry['type'], 'run');
+      Map<String, dynamic> body = entry['body'];
+      expect(body['pid'], pid);
+      expect(body['command'], <String>['echo', 'baz']);
+      expect(body['stdoutEncoding'], 'system');
+      expect(body['stderrEncoding'], 'system');
+      expect(body['exitCode'], exitCode);
       expect(recording.stdoutForEntryAt(0), stdout);
       expect(recording.stderrForEntryAt(0), stderr);
     });
+
+    test('canRun', () async {
+      String executable = p.join(tmp.path, 'bla.exe');
+      fs.file(executable).createSync();
+
+      bool result = manager.canRun(executable);
+
+      // Force the recording to be written to disk.
+      await manager.flush(finishRunningProcesses: true);
+
+      _Recording recording = new _Recording(tmp);
+      expect(recording.manifest, hasLength(1));
+      Map<String, dynamic> entry = recording.manifest.first;
+      expect(entry['type'], 'can_run');
+      Map<String, dynamic> body = entry['body'];
+      expect(body['executable'], executable);
+      expect(body['result'], result);
+    });
   });
 }
 
@@ -116,10 +147,10 @@
   }
 
   dynamic stdoutForEntryAt(int index) =>
-      _getStdioContent(manifest[index], 'stdout');
+      _getStdioContent(manifest[index]['body'], 'stdout');
 
   dynamic stderrForEntryAt(int index) =>
-      _getStdioContent(manifest[index], 'stderr');
+      _getStdioContent(manifest[index]['body'], 'stderr');
 
   dynamic _getFileContent(String name, Encoding encoding) {
     File file = dir.fileSystem.file('${dir.path}/$name');
diff --git a/test/replay_test.dart b/test/replay_test.dart
index 8b84b1a..ff9fb49 100644
--- a/test/replay_test.dart
+++ b/test/replay_test.dart
@@ -51,5 +51,10 @@
       expect(result.stdout, '');
       expect(result.stderr, 'No one can dance like Psy\n');
     });
+
+    test('canRun', () {
+      bool result = manager.canRun('marathon');
+      expect(result, true);
+    });
   });
 }
diff --git a/test/src/interface/common_test.dart b/test/src/interface/common_test.dart
new file mode 100644
index 0000000..1c82252
--- /dev/null
+++ b/test/src/interface/common_test.dart
@@ -0,0 +1,191 @@
+// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:file/file.dart';
+import 'package:file/local.dart';
+import 'package:path/path.dart' as p;
+import 'package:platform/platform.dart';
+import 'package:process/src/interface/common.dart';
+import 'package:test/test.dart';
+
+void main() {
+  FileSystem fs = new LocalFileSystem();
+
+  group('getExecutablePath', () {
+    Directory workingDir, dir1, dir2, dir3;
+
+    setUp(() {
+      workingDir = fs.systemTempDirectory.createTempSync('work_dir_');
+      dir1 = fs.systemTempDirectory.createTempSync('dir1_');
+      dir2 = fs.systemTempDirectory.createTempSync('dir2_');
+      dir3 = fs.systemTempDirectory.createTempSync('dir3_');
+    });
+
+    tearDown(() {
+      <Directory>[workingDir, dir1, dir2, dir3]
+          .forEach((Directory d) => d.deleteSync(recursive: true));
+    });
+
+    group('on windows', () {
+      Platform platform;
+
+      setUp(() {
+        platform = new FakePlatform(
+            operatingSystem: 'windows',
+            environment: <String, String>{
+              'PATH': '${dir1.path};${dir2.path}',
+              'PATHEXT': '.exe;.bat'
+            });
+      });
+
+      test('absolute', () {
+        String command = p.join(dir3.path, 'bla.exe');
+        String expectedPath = command;
+        fs.file(command).createSync();
+
+        String executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+
+        command = p.withoutExtension(command);
+        executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('in path', () {
+        String command = 'bla.exe';
+        String expectedPath = p.join(dir2.path, command);
+        fs.file(expectedPath).createSync();
+
+        String executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+
+        command = p.withoutExtension(command);
+        executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('in path multiple times', () {
+        String command = 'bla.exe';
+        String expectedPath = p.join(dir1.path, command);
+        String wrongPath = p.join(dir2.path, command);
+        fs.file(expectedPath).createSync();
+        fs.file(wrongPath).createSync();
+
+        String executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+
+        command = p.withoutExtension(command);
+        executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('in subdir of work dir', () {
+        String command = p.join('.', 'foo', 'bla.exe');
+        String expectedPath = p.join(workingDir.path, command);
+        fs.file(expectedPath).createSync(recursive: true);
+
+        String executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+
+        command = p.withoutExtension(command);
+        executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('in work dir', () {
+        String command = p.join('.', 'bla.exe');
+        String expectedPath = p.join(workingDir.path, command);
+        String wrongPath = p.join(dir2.path, command);
+        fs.file(expectedPath).createSync();
+        fs.file(wrongPath).createSync();
+
+        String executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+
+        command = p.withoutExtension(command);
+        executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('with multiple extensions', () {
+        String command = 'foo';
+        String expectedPath = p.join(dir1.path, '$command.exe');
+        String wrongPath1 = p.join(dir1.path, '$command.bat');
+        String wrongPath2 = p.join(dir2.path, '$command.exe');
+        fs.file(expectedPath).createSync();
+        fs.file(wrongPath1).createSync();
+        fs.file(wrongPath2).createSync();
+
+        String executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('not found', () {
+        String command = 'foo.exe';
+
+        String executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        expect(executablePath, isNull);
+      });
+    });
+
+    group('on Linux', () {
+      Platform platform;
+
+      setUp(() {
+        platform = new FakePlatform(
+            operatingSystem: 'linux',
+            environment: <String, String>{'PATH': '${dir1.path}:${dir2.path}'});
+      });
+
+      test('absolute', () {
+        String command = p.join(dir3.path, 'bla');
+        String expectedPath = command;
+        String wrongPath = p.join(dir3.path, 'bla.bat');
+        fs.file(command).createSync();
+        fs.file(wrongPath).createSync();
+
+        String executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('in path multiple times', () {
+        String command = 'xxx';
+        String expectedPath = p.join(dir1.path, command);
+        String wrongPath = p.join(dir2.path, command);
+        fs.file(expectedPath).createSync();
+        fs.file(wrongPath).createSync();
+
+        String executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        _expectSamePath(executablePath, expectedPath);
+      });
+
+      test('not found', () {
+        String command = 'foo';
+
+        String executablePath =
+            getExecutablePath(command, workingDir.path, platform: platform);
+        expect(executablePath, isNull);
+      });
+    });
+  });
+}
+
+void _expectSamePath(String actual, String expected) {
+  expect(actual, isNotNull);
+  expect(actual.toLowerCase(), expected.toLowerCase());
+}