pw_watch: Don't search for build dirs; run gn gen if needed

- No longer search for a build.ninja file because sometimes this finds a
  random build.ninja in a presubmit directory. Instead, default to out/
  for the build directory if none is specified.
- Automatically run `gn gen` if a build directory is missing a
  build.ninja file.

Change-Id: Ib53c5f20464b9782f48ebf6ab0d01f035678d34f
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/60560
Pigweed-Auto-Submit: Wyatt Hepler <hepler@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Reviewed-by: Keir Mierle <keir@google.com>
diff --git a/pw_watch/docs.rst b/pw_watch/docs.rst
index d8fd75b..bd28ca3 100644
--- a/pw_watch/docs.rst
+++ b/pw_watch/docs.rst
@@ -19,16 +19,15 @@
 ============
 The simplest way to get started with ``pw_watch`` is to launch it from a shell
 using the Pigweed environment as ``pw watch``. By default, ``pw_watch`` watches
-for repository changes and triggers the default Ninja build target for an
-automatically located build directory (typically ``$PW_ROOT/out``). To override
-this behavior, provide the ``-C`` argument to ``pw watch``.
+for repository changes and triggers the default Ninja build target at out/. To
+override this behavior, provide the ``-C`` argument to ``pw watch``.
 
 .. code:: sh
 
-  # Find a build directory and build the default target
+  # Use ./out/ as the build directory and build the default target
   pw watch
 
-  # Find a build directory and build the stm32f429i target
+  # Use ./out/ as the build directory and build the stm32f429i target
   pw watch python.lint stm32f429i
 
   # Build pw_run_tests.modules in the out/cmake directory
@@ -37,7 +36,7 @@
   # Build the default target in out/ and pw_apps in out/cmake
   pw watch -C out -C out/cmake pw_apps
 
-  # Find a directory and build python.tests, and build pw_apps in out/cmake
+  # Build python.tests in out/ and build pw_apps in out/cmake
   pw watch python.tests -C out/cmake pw_apps
 
   # Build the default target, but only run up to 8 jobs in parallel.
diff --git a/pw_watch/py/pw_watch/watch.py b/pw_watch/py/pw_watch/watch.py
index 2fd7e58..e23362c 100755
--- a/pw_watch/py/pw_watch/watch.py
+++ b/pw_watch/py/pw_watch/watch.py
@@ -256,27 +256,47 @@
         env['PW_USE_COLOR'] = '1'
 
         for i, cmd in enumerate(self.build_commands, 1):
-            command = ['ninja', *self._extra_ninja_args, '-C', *cmd.args()]
+            index = f'[{i}/{num_builds}]'
+            self.builds_succeeded.append(self._run_build(index, cmd, env))
 
-            _LOG.info('[%d/%d] Starting build: %s', i, num_builds,
-                      ' '.join(shlex.quote(arg) for arg in command))
-
-            # Run the build. Put a blank before/after for visual separation.
-            print()
-            self._current_build = subprocess.Popen(command, env=env)
-            returncode = self._current_build.wait()
-            print()
-
-            build_ok = (returncode == 0)
-            if build_ok:
+            if self.builds_succeeded[-1]:
                 level = logging.INFO
                 tag = '(OK)'
             else:
                 level = logging.ERROR
                 tag = '(FAIL)'
-            _LOG.log(level, '[%d/%d] Finished build: %s %s', i, num_builds,
-                     cmd, tag)
-            self.builds_succeeded.append(build_ok)
+
+            _LOG.log(level, '%s Finished build: %s %s', index, cmd, tag)
+
+    def _run_build(self, index: str, cmd: BuildCommand, env: dict) -> bool:
+        # Make sure there is a build.ninja file for Ninja to use.
+        build_ninja = cmd.build_dir / 'build.ninja'
+        if not build_ninja.exists():
+            # If this is a CMake directory, prompt the user to re-run CMake.
+            if cmd.build_dir.joinpath('CMakeCache.txt').exists():
+                _LOG.error('%s %s does not exist; re-run CMake to generate it',
+                           index, build_ninja)
+                return False
+
+            _LOG.warning('%s %s does not exist; running gn gen %s', index,
+                         build_ninja, cmd.build_dir)
+            if not self._execute_command(['gn', 'gen', cmd.build_dir], env):
+                return False
+
+        command = ['ninja', *self._extra_ninja_args, '-C', *cmd.args()]
+        _LOG.info('%s Starting build: %s', index,
+                  ' '.join(shlex.quote(arg) for arg in command))
+
+        return self._execute_command(command, env)
+
+    def _execute_command(self, command: list, env: dict) -> bool:
+        """Runs a command with a blank before/after for visual separation."""
+        print()
+        self._current_build = subprocess.Popen(command, env=env)
+        returncode = self._current_build.wait()
+        print()
+
+        return returncode == 0
 
     # Implementation of DebouncedFunction.cancel()
     def cancel(self) -> bool:
@@ -386,9 +406,9 @@
         default=[],
         help=('Automatically locate a build directory and build these '
               'targets. For example, `host docs` searches for a Ninja '
-              'build directory (starting with out/) and builds the '
-              '`host` and `docs` targets. To specify one or more '
-              'directories, ust the -C / --build_directory option.'))
+              'build directory at out/ and builds the `host` and `docs` '
+              'targets. To specify one or more directories, ust the '
+              '-C / --build_directory option.'))
     parser.add_argument(
         '-C',
         '--build_directory',
@@ -564,21 +584,6 @@
     return exclude_list
 
 
-def _find_build_dir(default_build_dir: Path = Path('out')) -> Optional[Path]:
-    """Searches for a build directory, returning the first it finds."""
-    # Give priority to out/, then something under out/.
-    if default_build_dir.joinpath('build.ninja').exists():
-        return default_build_dir
-
-    for path in default_build_dir.glob('**/build.ninja'):
-        return path.parent
-
-    for path in Path.cwd().glob('**/build.ninja'):
-        return path.parent
-
-    return None
-
-
 # pylint: disable=R0914 # too many local variables
 def watch(default_build_targets: List[str], build_directories: List[str],
           patterns: str, ignore_patterns_string: str, exclude_list: List[Path],
@@ -602,16 +607,14 @@
         for build_dir in build_directories
     ]
 
-    # If no build directory was specified, search the tree for a build.ninja.
+    # If no build directory was specified, check for out/build.ninja.
     if default_build_targets or not build_directories:
-        build_dir = _find_build_dir()
-
         # Make sure we found something; if not, bail.
-        if build_dir is None:
+        if not Path('out').exists():
             _die("No build dirs found. Did you forget to run 'gn gen out'?")
 
         build_commands.append(
-            BuildCommand(build_dir, tuple(default_build_targets)))
+            BuildCommand(Path('out'), tuple(default_build_targets)))
 
     # Verify that the build output directories exist.
     for i, build_target in enumerate(build_commands, 1):
@@ -630,7 +633,8 @@
             logging.getLogger('httpwatcher').setLevel(logging.CRITICAL)
             logging.getLogger('tornado').setLevel(logging.CRITICAL)
 
-            docs_path = build_dir.joinpath(serve_docs_path.joinpath('html'))
+            docs_path = build_commands[0].build_dir.joinpath(
+                serve_docs_path.joinpath('html'))
             httpwatcher.watch(docs_path,
                               host="127.0.0.1",
                               port=serve_docs_port)