west: runners: Guess build folder

When using a build folder format with build.dir-fmt that includes any
parameters that need resolving, the west runners cannot find the folder
since the required information (board, source dir or app) is not
available.
Add a very simple heuristic to support the case where a build folder
starts with a hardcoded prefix (for example 'build/') and a single build
is present under that prefix.
The heuristic is gated behind a new configuration option:
build.guess-dir

Signed-off-by: Carles Cufi <carles.cufi@nordicsemi.no>
diff --git a/scripts/west_commands/build_helpers.py b/scripts/west_commands/build_helpers.py
index 5cc0f6a..5281dfd 100644
--- a/scripts/west_commands/build_helpers.py
+++ b/scripts/west_commands/build_helpers.py
@@ -12,6 +12,7 @@
 
 import zcmake
 import os
+from pathlib import Path
 from west import log
 from west.configuration import config
 from west.util import escapes_directory
@@ -28,7 +29,7 @@
 checked, in that order. If one is a Zephyr build directory, it is used.
 '''.format(DEFAULT_BUILD_DIR)
 
-def _resolve_build_dir(fmt, cwd, **kwargs):
+def _resolve_build_dir(fmt, guess, cwd, **kwargs):
     # Remove any None values, we do not want 'None' as a string
     kwargs = {k: v for k, v in kwargs.items() if v is not None}
     # Check if source_dir is below cwd first
@@ -40,9 +41,38 @@
             # no meaningful relative path possible
             kwargs['source_dir'] = ''
 
-    return fmt.format(**kwargs)
+    try:
+        return fmt.format(**kwargs)
+    except KeyError:
+        if not guess:
+            return None
 
-def find_build_dir(dir, **kwargs):
+    # Guess the build folder by iterating through all sub-folders from the
+    # root of the format string and trying to resolve. If resolving fails,
+    # proceed to iterate over subfolders only if there is a single folder
+    # present on each iteration.
+    parts = Path(fmt).parts
+    b = Path('.')
+    for p in parts:
+        # default to cwd in the first iteration
+        curr = b
+        b = b.joinpath(p)
+        try:
+            # if fmt is an absolute path, the first iteration will always
+            # resolve '/'
+            b = Path(str(b).format(**kwargs))
+        except KeyError:
+            # Missing key, check sub-folders and match if a single one exists
+            while True:
+                dirs = [f for f in curr.iterdir() if f.is_dir()]
+                if len(dirs) != 1:
+                    return None
+                curr = dirs[0]
+                if is_zephyr_build(str(curr)):
+                    return str(curr)
+    return str(b)
+
+def find_build_dir(dir, guess=False, **kwargs):
     '''Heuristic for finding a build directory.
 
     The default build directory is computed by reading the build.dir-fmt
@@ -60,12 +90,8 @@
     else:
         cwd = os.getcwd()
         default = config.get('build', 'dir-fmt', fallback=DEFAULT_BUILD_DIR)
-        try:
-            default = _resolve_build_dir(default, cwd, **kwargs)
-            log.dbg('config dir-fmt: {}'.format(default),
-                    level=log.VERBOSE_EXTREME)
-        except KeyError:
-            default = None
+        default = _resolve_build_dir(default, guess, cwd, **kwargs)
+        log.dbg('config dir-fmt: {}'.format(default), level=log.VERBOSE_EXTREME)
         if default and is_zephyr_build(default):
             build_dir = default
         elif is_zephyr_build(cwd):