// Copyright 2021 The Bazel Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//    http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.devtools.build.lib.skyframe;

import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.common.truth.Truth8.assertThat;
import static com.google.devtools.build.lib.actions.FileStateValue.DIRECTORY_FILE_STATE_NODE;
import static com.google.devtools.build.lib.actions.FileStateValue.NONEXISTENT_FILE_STATE_NODE;
import static com.google.devtools.build.lib.testing.common.DirectoryListingHelper.file;
import static com.google.devtools.build.lib.testing.common.DirectoryListingHelper.symlink;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.fail;

import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import com.google.common.collect.Streams;
import com.google.devtools.build.lib.actions.FileStateValue;
import com.google.devtools.build.lib.server.FailureDetails.DiffAwareness.Code;
import com.google.devtools.build.lib.testutil.Scratch;
import com.google.devtools.build.lib.util.AbruptExitException;
import com.google.devtools.build.lib.vfs.DelegateFileSystem;
import com.google.devtools.build.lib.vfs.Dirent;
import com.google.devtools.build.lib.vfs.FileStateKey;
import com.google.devtools.build.lib.vfs.FileStatus;
import com.google.devtools.build.lib.vfs.FileSystem;
import com.google.devtools.build.lib.vfs.Path;
import com.google.devtools.build.lib.vfs.PathFragment;
import com.google.devtools.build.lib.vfs.Root;
import com.google.devtools.build.lib.vfs.RootedPath;
import com.google.devtools.build.lib.vfs.SyscallCache;
import com.google.devtools.build.skyframe.ImmutableDiff;
import com.google.devtools.build.skyframe.SkyKey;
import com.google.devtools.build.skyframe.SkyValue;
import com.google.testing.junit.testparameterinjector.TestParameter;
import com.google.testing.junit.testparameterinjector.TestParameterInjector;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Nullable;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

/** Unit tests for {@link FileSystemValueCheckerInferringAncestors}. */
@RunWith(TestParameterInjector.class)
public final class FileSystemValueCheckerInferringAncestorsTest {
  private final Scratch scratch = new Scratch();
  private final List<String> statedPaths = new ArrayList<>();
  private final PerBuildSyscallCache syscallCache = PerBuildSyscallCache.newBuilder().build();
  private Root root;
  private Root untrackedRoot;
  private Exception throwOnStat;

  @TestParameter({"1", "16"})
  private int fsvcThreads;

  @Before
  public void createRoot() throws IOException {
    Path srcRootPath = scratch.dir("/src");
    PathFragment srcRoot = srcRootPath.asFragment();
    FileSystem trackingFileSystem =
        new DelegateFileSystem(scratch.getFileSystem()) {
          @Nullable
          @Override
          public synchronized FileStatus statIfFound(PathFragment path, boolean followSymlinks)
              throws IOException {
            if (throwOnStat != null) {
              Exception toThrow = throwOnStat;
              throwOnStat = null;
              Throwables.propagateIfPossible(toThrow, IOException.class);
              fail("Unexpected exception type");
            }
            statedPaths.add(path.relativeTo(srcRoot).toString());
            return super.statIfFound(path, followSymlinks);
          }
        };
    root = Root.fromPath(trackingFileSystem.getPath(srcRoot));
    scratch.setWorkingDir("/src");
    untrackedRoot = Root.fromPath(srcRootPath);
  }

  @After
  public void checkExceptionThrown() {
    assertThat(throwOnStat).isNull();
    syscallCache.clear();
  }

  @Test
  public void getDiffWithInferredAncestors_unknownFileChanged_returnsFileAndDirs()
      throws Exception {
    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(fileStateValueKey("foo/file")),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(
            fileStateValueKey(""),
            fileStateValueKey("foo"),
            fileStateValueKey("foo/file"),
            directoryListingStateValueKey(""),
            directoryListingStateValueKey("foo"));
    assertThat(diff.changedKeysWithNewValues()).isEmpty();
    assertThat(statedPaths).isEmpty();
  }

  @Test
  public void getDiffWithInferredAncestors_fileModified_returnsFileWithValues() throws Exception {
    scratch.file("file", "hello");
    FileStateKey key = fileStateValueKey("file");
    FileStateValue value = fileStateValue("file");
    scratch.overwriteFile("file", "there");

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(fileStateValueKey("file"), value),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(key),
            fsvcThreads,
            syscallCache);

    FileStateValue newValue = fileStateValue("file");
    assertThat(diff.changedKeysWithNewValues()).containsExactly(key, newValue);
    assertThat(diff.changedKeysWithoutNewValues()).isEmpty();
    assertThat(statedPaths).containsExactly("file");
  }

  @Test
  public void getDiffWithInferredAncestors_fileAdded_returnsFileAndDirListing() throws Exception {
    scratch.file("file");
    FileStateKey key = fileStateValueKey("file");

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                key, NONEXISTENT_FILE_STATE_NODE, fileStateValueKey(""), fileStateValue("")),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(key),
            fsvcThreads,
            syscallCache);

    FileStateValue value = fileStateValue("file");
    assertThat(diff.changedKeysWithNewValues()).containsExactly(key, value);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(directoryListingStateValueKey(""));
    assertThat(statedPaths).containsExactly("file");
  }

  @Test
  public void getDiffWithInferredAncestors_fileWithDirsAdded_returnsFileAndInjectsDirs()
      throws Exception {
    scratch.file("a/b/file");
    FileStateKey fileKey = fileStateValueKey("a/b/file");

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                fileStateValueKey(""),
                fileStateValue(""),
                fileStateValueKey("a"),
                NONEXISTENT_FILE_STATE_NODE,
                fileStateValueKey("a/b"),
                NONEXISTENT_FILE_STATE_NODE,
                fileKey,
                NONEXISTENT_FILE_STATE_NODE),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(fileKey),
            fsvcThreads,
            syscallCache);

    FileStateValue value = fileStateValue("a/b/file");
    assertThat(diff.changedKeysWithNewValues())
        .containsExactly(
            fileKey,
            value,
            fileStateValueKey("a"),
            DIRECTORY_FILE_STATE_NODE,
            fileStateValueKey("a/b"),
            DIRECTORY_FILE_STATE_NODE);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(
            directoryListingStateValueKey(""),
            directoryListingStateValueKey("a"),
            directoryListingStateValueKey("a/b"));
    assertThat(statedPaths).containsExactly("a/b/file");
  }

  @Test
  public void getDiffWithInferredAncestors_addedFileWithReportedDirs_returnsFileAndInjectsDirs()
      throws Exception {
    scratch.file("a/b/file");
    FileStateKey fileKey = fileStateValueKey("a/b/file");

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                fileStateValueKey(""),
                fileStateValue(""),
                fileStateValueKey("a"),
                NONEXISTENT_FILE_STATE_NODE,
                fileStateValueKey("a/b"),
                NONEXISTENT_FILE_STATE_NODE,
                fileKey,
                NONEXISTENT_FILE_STATE_NODE),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(fileKey, fileStateValueKey("a")),
            fsvcThreads,
            syscallCache);

    FileStateValue newState = fileStateValue("a/b/file");
    assertThat(diff.changedKeysWithNewValues())
        .containsExactly(
            fileKey,
            newState,
            fileStateValueKey("a"),
            DIRECTORY_FILE_STATE_NODE,
            fileStateValueKey("a/b"),
            DIRECTORY_FILE_STATE_NODE);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(
            directoryListingStateValueKey(""),
            directoryListingStateValueKey("a"),
            directoryListingStateValueKey("a/b"));
    assertThat(statedPaths).containsExactly("a/b/file");
  }

  /**
   * This is a degenerate case since we normally only know about a file if we checked all parents,
   * but that is theoretically possible with this API.
   */
  @Test
  public void getDiffWithInferredAncestors_fileWithUnknownDirsAdded_returnsFileAndDirs()
      throws Exception {
    scratch.file("a/b/c/d");

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                fileStateValueKey(""),
                fileStateValue(""),
                fileStateValueKey("a/b/c/d"),
                NONEXISTENT_FILE_STATE_NODE),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(fileStateValueKey("a/b/c/d")),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(
            fileStateValueKey("a"),
            fileStateValueKey("a/b"),
            fileStateValueKey("a/b/c"),
            directoryListingStateValueKey(""),
            directoryListingStateValueKey("a"),
            directoryListingStateValueKey("a/b"),
            directoryListingStateValueKey("a/b/c"));
    assertThat(diff.changedKeysWithNewValues())
        .containsExactly(fileStateValueKey("a/b/c/d"), fileStateValue("a/b/c/d"));
    assertThat(statedPaths).containsExactly("a/b/c/d");
  }

  @Test
  public void getDiffWithInferredAncestors_addEmptyDir_returnsDirAndParentListing()
      throws Exception {
    scratch.dir("dir");
    FileStateKey key = fileStateValueKey("dir");
    FileStateValue value = fileStateValue("dir");

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                key, NONEXISTENT_FILE_STATE_NODE, fileStateValueKey(""), fileStateValue("")),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(key),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithNewValues()).containsExactly(key, value);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(directoryListingStateValueKey(""));
    assertThat(statedPaths).containsExactly("dir");
  }

  @Test
  public void getDiffWithInferredAncestors_deleteFile_returnsFileParentListing() throws Exception {
    Path file = scratch.file("dir/file1");
    scratch.file("dir/file2");
    FileStateKey key = fileStateValueKey("dir/file1");
    FileStateValue oldValue = fileStateValue("dir/file1");
    file.delete();

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                key, oldValue, fileStateValueKey("dir"), fileStateValue("dir")),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(key),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithNewValues()).containsExactly(key, NONEXISTENT_FILE_STATE_NODE);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(directoryListingStateValueKey("dir"));
    assertThat(statedPaths).containsExactly("dir/file1", "dir");
  }

  @Test
  public void getDiffWithInferredAncestors_deleteFileFromDirWithListing_skipsDirStat()
      throws Exception {
    Path file1 = scratch.file("dir/file1");
    FileStateKey key = fileStateValueKey("dir/file1");
    FileStateValue oldValue = fileStateValue("dir/file1");
    file1.delete();

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                key, oldValue, fileStateValueKey("dir"), fileStateValue("dir")),
            /*graphDoneValues=*/ ImmutableMap.of(
                directoryListingStateValueKey("dir"),
                directoryListingStateValue(file("file1"), file("file2"))),
            /*modifiedKeys=*/ ImmutableSet.of(key),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithNewValues()).containsExactly(key, NONEXISTENT_FILE_STATE_NODE);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(directoryListingStateValueKey("dir"));
    assertThat(statedPaths).containsExactly("dir/file1");
  }

  @Test
  public void getDiffWithInferredAncestors_deleteLastFileFromDir_ignoresInvalidatedListing()
      throws Exception {
    Path file1 = scratch.file("dir/file1");
    FileStateKey key = fileStateValueKey("dir/file1");
    FileStateValue oldValue = fileStateValue("dir/file1");
    file1.delete();

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                key,
                oldValue,
                fileStateValueKey("dir"),
                fileStateValue("dir"),
                directoryListingStateValueKey("dir"),
                directoryListingStateValue(file("file1"), file("file2"))),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(key),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithNewValues()).containsExactly(key, NONEXISTENT_FILE_STATE_NODE);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(directoryListingStateValueKey("dir"));
    assertThat(statedPaths).containsExactly("dir/file1", "dir");
  }

  @Test
  public void getDiffWithInferredAncestors_modifyAllUnknownEntriesInDirWithListing_skipsDir()
      throws Exception {
    Path file = scratch.file("dir/file");
    file.getParentDirectory()
        .getRelative("symlink")
        .createSymbolicLink(PathFragment.create("file"));
    FileStateKey fileKey = fileStateValueKey("dir/file");
    FileStateKey symlinkKey = fileStateValueKey("dir/symlink");

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(fileStateValueKey("dir"), fileStateValue("dir")),
            /*graphDoneValues=*/ ImmutableMap.of(
                directoryListingStateValueKey("dir"),
                directoryListingStateValue(file("file"), symlink("symlink"))),
            /*modifiedKeys=*/ ImmutableSet.of(fileKey, symlinkKey),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithNewValues())
        .containsExactly(
            fileKey, fileStateValue("dir/file"), symlinkKey, fileStateValue("dir/symlink"));
    assertThat(diff.changedKeysWithoutNewValues()).isEmpty();
    assertThat(statedPaths).containsExactly("dir/file", "dir/symlink");
  }

  @Test
  public void getDiffWithInferredAncestors_replaceUnknownEntriesInDirWithListing_skipsSiblingStat()
      throws Exception {
    scratch.dir("dir/file1");
    scratch.dir("dir/file2");
    FileStateKey file1Key = fileStateValueKey("dir/file1");
    FileStateKey file2Key = fileStateValueKey("dir/file2");
    DirectoryListingStateValue.Key dirKey = directoryListingStateValueKey("dir");

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(fileStateValueKey("dir"), fileStateValue("dir")),
            /*graphDoneValues=*/ ImmutableMap.of(
                dirKey, directoryListingStateValue(file("file1"), file("file2"))),
            /*modifiedKeys=*/ ImmutableSet.of(file1Key, file2Key),
            fsvcThreads,
            syscallCache);

    assertIsSubsetOf(
        diff.changedKeysWithNewValues().entrySet(),
        Maps.immutableEntry(file1Key, fileStateValue("dir/file1")),
        Maps.immutableEntry(file2Key, fileStateValue("dir/file2")));
    assertThat(diff.changedKeysWithoutNewValues()).contains(dirKey);
    assertThat(
            Streams.concat(
                diff.changedKeysWithoutNewValues().stream(),
                diff.changedKeysWithNewValues().keySet().stream()))
        .containsExactly(file1Key, file2Key, dirKey);
    assertThat(statedPaths).isNotEmpty();
    assertIsSubsetOf(statedPaths, "dir/file1", "dir/file2");
    if (fsvcThreads == 1) {
      // In case of single-threaded execution, we know that once we check dir/file1 or dir/file2, we
      // will be able to skip stat on the other one.
      assertThat(diff.changedKeysWithNewValues()).hasSize(1);
      assertThat(diff.changedKeysWithoutNewValues()).hasSize(2);
      assertThat(statedPaths).hasSize(1);
    }
  }

  @Test
  public void getDiffWithInferredAncestors_deleteAllFilesFromDir_returnsFilesAndDirListing()
      throws Exception {
    Path file1 = scratch.file("dir/file1");
    Path file2 = scratch.file("dir/file2");
    Path file3 = scratch.file("dir/file3");
    FileStateKey key1 = fileStateValueKey("dir/file1");
    FileStateValue oldValue1 = fileStateValue("dir/file1");
    FileStateKey key2 = fileStateValueKey("dir/file2");
    FileStateValue oldValue2 = fileStateValue("dir/file2");
    FileStateKey key3 = fileStateValueKey("dir/file3");
    FileStateValue oldValue3 = fileStateValue("dir/file3");
    file1.delete();
    file2.delete();
    file3.delete();

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                key1,
                oldValue1,
                key2,
                oldValue2,
                key3,
                oldValue3,
                fileStateValueKey("dir"),
                fileStateValue("dir")),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(key1, key2, key3),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithNewValues())
        .containsExactly(
            key1, NONEXISTENT_FILE_STATE_NODE,
            key2, NONEXISTENT_FILE_STATE_NODE,
            key3, NONEXISTENT_FILE_STATE_NODE);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(directoryListingStateValueKey("dir"));
    assertThat(statedPaths).containsExactly("dir", "dir/file1", "dir/file2", "dir/file3");
  }

  @Test
  public void getDiffWithInferredAncestors_deleteFileWithDirs_returnsFileAndDirs()
      throws Exception {
    scratch.file("a/b/c/file");
    FileStateKey abKey = fileStateValueKey("a/b");
    FileStateValue abValue = fileStateValue("a/b");
    FileStateKey abcKey = fileStateValueKey("a/b/c");
    FileStateValue abcValue = fileStateValue("a/b/c");
    FileStateKey abcFileKey = fileStateValueKey("a/b/c/file");
    FileStateValue abcFileValue = fileStateValue("a/b/c/file");
    scratch.dir("a/b").deleteTree();

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                fileStateValueKey("a"),
                fileStateValue("a"),
                abKey,
                abValue,
                abcKey,
                abcValue,
                abcFileKey,
                abcFileValue),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(abcFileKey),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithNewValues())
        .containsExactly(
            abKey, NONEXISTENT_FILE_STATE_NODE,
            abcKey, NONEXISTENT_FILE_STATE_NODE,
            abcFileKey, NONEXISTENT_FILE_STATE_NODE);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(
            directoryListingStateValueKey("a"),
            directoryListingStateValueKey("a/b"),
            directoryListingStateValueKey("a/b/c"));
    assertThat(statedPaths).containsExactly("a", "a/b", "a/b/c", "a/b/c/file");
  }

  @Test
  public void getDiffWithInferredAncestors_deleteFileWithReportedDirs_returnsFileAndDirListings()
      throws Exception {
    scratch.file("a/b/c/file");
    FileStateKey abKey = fileStateValueKey("a/b");
    FileStateValue abValue = fileStateValue("a/b");
    FileStateKey abcKey = fileStateValueKey("a/b/c");
    FileStateValue abcValue = fileStateValue("a/b/c");
    FileStateKey abcFileKey = fileStateValueKey("a/b/c/file");
    FileStateValue abcFileValue = fileStateValue("a/b/c/file");
    scratch.dir("a/b").deleteTree();

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                fileStateValueKey("a"),
                fileStateValue("a"),
                abKey,
                abValue,
                abcKey,
                abcValue,
                abcFileKey,
                abcFileValue),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(abcFileKey, abKey),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithNewValues())
        .containsExactly(
            abKey, NONEXISTENT_FILE_STATE_NODE,
            abcKey, NONEXISTENT_FILE_STATE_NODE,
            abcFileKey, NONEXISTENT_FILE_STATE_NODE);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(
            directoryListingStateValueKey("a"),
            directoryListingStateValueKey("a/b"),
            directoryListingStateValueKey("a/b/c"));
    assertThat(statedPaths).containsExactly("a", "a/b", "a/b/c", "a/b/c/file");
  }

  @Test
  public void getDiffWithInferredAncestors_deleteFile_infersDirFromModifiedSibling()
      throws Exception {
    Path file1 = scratch.file("dir/file1");
    scratch.file("dir/file2", "1");
    FileStateKey file1Key = fileStateValueKey("dir/file1");
    FileStateValue file1Value = fileStateValue("dir/file1");
    FileStateKey file2Key = fileStateValueKey("dir/file2");
    FileStateValue file2Value = fileStateValue("dir/file2");
    file1.delete();
    scratch.overwriteFile("dir/file2", "12");

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                fileStateValueKey("dir"),
                fileStateValue("dir"),
                file1Key,
                file1Value,
                file2Key,
                file2Value),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(file1Key, file2Key, fileStateValueKey("dir")),
            fsvcThreads,
            syscallCache);

    FileStateValue file2NewValue = fileStateValue("dir/file2");
    assertThat(diff.changedKeysWithNewValues())
        .containsExactly(file1Key, NONEXISTENT_FILE_STATE_NODE, file2Key, file2NewValue);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(directoryListingStateValueKey("dir"));
    assertThat(statedPaths).containsExactly("dir/file1", "dir/file2");
  }

  @Test
  public void getDiffWithInferredAncestors_deleteDirReportDirOnly_returnsDir() throws Exception {
    Path file1 = scratch.file("dir/file1");
    scratch.file("dir/file2");
    FileStateKey file1Key = fileStateValueKey("dir/file1");
    FileStateValue file1Value = fileStateValue("dir/file1");
    FileStateKey file2Key = fileStateValueKey("dir/file2");
    FileStateValue file2Value = fileStateValue("dir/file2");
    FileStateKey dirKey = fileStateValueKey("dir");
    FileStateValue dirValue = fileStateValue("dir");
    file1.getParentDirectory().deleteTree();

    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                file1Key,
                file1Value,
                file2Key,
                file2Value,
                dirKey,
                dirValue,
                fileStateValueKey(""),
                fileStateValue("")),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(dirKey),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithNewValues())
        .containsExactly(dirKey, NONEXISTENT_FILE_STATE_NODE);
    assertThat(diff.changedKeysWithoutNewValues())
        .containsExactly(directoryListingStateValueKey(""));
    assertThat(statedPaths).containsExactly("dir", "");
  }

  @Test
  public void getDiffWithInferredAncestors_phantomChangeForNonexistentEntry_returnsEmptyDiff()
      throws Exception {
    ImmutableDiff diff =
        FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
            /*tsgm=*/ null,
            /*graphValues=*/ ImmutableMap.of(
                fileStateValueKey("file"), NONEXISTENT_FILE_STATE_NODE),
            /*graphDoneValues=*/ ImmutableMap.of(),
            /*modifiedKeys=*/ ImmutableSet.of(fileStateValueKey("file")),
            fsvcThreads,
            syscallCache);

    assertThat(diff.changedKeysWithoutNewValues()).isEmpty();
    assertThat(diff.changedKeysWithNewValues()).isEmpty();
    assertThat(statedPaths).containsExactly("file");
  }

  @Test
  public void getDiffWithInferredAncestors_statFails_fails() {
    throwOnStat = new IOException("oh no");
    ImmutableMap<SkyKey, SkyValue> graphValues =
        ImmutableMap.of(fileStateValueKey("file"), NONEXISTENT_FILE_STATE_NODE);
    ImmutableSet<FileStateKey> modifiedKeys = ImmutableSet.of(fileStateValueKey("file"));

    AbruptExitException e =
        assertThrows(
            AbruptExitException.class,
            () ->
                FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
                    /*tsgm=*/ null,
                    graphValues,
                    /*graphDoneValues=*/ ImmutableMap.of(),
                    modifiedKeys,
                    fsvcThreads,
                    syscallCache));

    assertThat(e.getDetailedExitCode().getFailureDetail().hasDiffAwareness()).isTrue();
    assertThat(e.getDetailedExitCode().getFailureDetail().getDiffAwareness().getCode())
        .isEqualTo(Code.DIFF_STAT_FAILED);
    assertThat(e).hasMessageThat().isEqualTo("Failed to stat: '/src/file' while computing diff");
  }

  @Test
  public void getDiffWithInferredAncestors_statCrashes_fails() {
    throwOnStat = new RuntimeException("oh no");
    ImmutableMap<SkyKey, SkyValue> graphValues =
        ImmutableMap.of(fileStateValueKey("file"), NONEXISTENT_FILE_STATE_NODE);
    ImmutableSet<FileStateKey> modifiedKeys = ImmutableSet.of(fileStateValueKey("file"));

    assertThrows(
        IllegalStateException.class,
        () ->
            FileSystemValueCheckerInferringAncestors.getDiffWithInferredAncestors(
                /*tsgm=*/ null,
                graphValues,
                /*graphDoneValues=*/ ImmutableMap.of(),
                modifiedKeys,
                fsvcThreads,
                syscallCache));
  }

  private static <T> void assertIsSubsetOf(Iterable<T> list, T... elements) {
    ImmutableSet<T> set = ImmutableSet.copyOf(elements);
    assertWithMessage("%s has elements from outside of %s", list, set)
        .that(set)
        .containsAtLeastElementsIn(list);
  }

  private FileStateKey fileStateValueKey(String relativePath) {
    return FileStateValue.key(
        RootedPath.toRootedPath(root, root.asPath().getRelative(relativePath)));
  }

  private DirectoryListingStateValue.Key directoryListingStateValueKey(String relativePath) {
    return DirectoryListingStateValue.key(
        RootedPath.toRootedPath(root, root.asPath().getRelative(relativePath)));
  }

  private static DirectoryListingStateValue directoryListingStateValue(Dirent... dirents) {
    return DirectoryListingStateValue.create(ImmutableList.copyOf(dirents));
  }

  private FileStateValue fileStateValue(String relativePath) throws IOException {
    return FileStateValue.create(
        RootedPath.toRootedPath(
            untrackedRoot, untrackedRoot.asPath().asFragment().getRelative(relativePath)),
        SyscallCache.NO_CACHE,
        /*tsgm=*/ null);
  }
}
