Skip to main content

nmbrs_workload/
suggest.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Deep workload suggestions — the shared "did you mean" set.
5//!
6//! When an exact workload reference misses, three callers want the same
7//! answer: the shell-completion provider (`nmbrs::completion`) as its
8//! fallback, and the resolver's not-found paths
9//! (`nmbrs_runtime::runner::resolve_workload`, [`crate::verify::verify_target`],
10//! `nmbrs::copy_cmd`) as the suggestion tail on the error. Each asks the
11//! same question — *every workload whose final name segment begins with
12//! what the operator typed* — over the local file hierarchy and the
13//! bundled catalog.
14//!
15//! Leaf-segment matching is the point: it is what makes a bare
16//! `phase_poll` surface a buried `examples/controls/phase_poll_smoke`. The
17//! catalog carries the namespace; the operator types the stem. A
18//! slash-qualified partial (`examples/controls/phase_poll`) still matches
19//! by full-reference prefix, so both habits work.
20//!
21//! The local walk descends [`MAX_DEPTH`] directory levels below the start
22//! directory (the operator's cwd); the bundled catalog is a flat namespace
23//! with no depth bound. SRD-85 makes the catalog the discovery surface, so
24//! a suggestion drawn from it is always runnable by the name shown.
25
26use std::path::Path;
27
28/// Directory levels the local walk descends below the start directory. A
29/// file at `a/b/c/<name>.yaml` is the deepest reached at the default of 3.
30/// The bundled catalog is flat and not subject to this bound.
31pub const MAX_DEPTH: usize = 3;
32
33/// Cap on directory entries the local walk reads in one call, so a
34/// suggestion never degrades into an unbounded tree crawl in a large repo.
35const MAX_ENTRIES_SCANNED: usize = 4000;
36
37/// Most names to spell out in a "did you mean" tail before collapsing the
38/// remainder into a `(+N more …)` note.
39const MAX_LISTED: usize = 10;
40
41/// Directories never worth walking for workloads.
42const SKIP_DIRS: &[&str] = &["target", "node_modules", "logs", ".git"];
43
44/// Every workload reference matching `partial` by leaf segment (or by
45/// full-reference prefix), drawn from the local file hierarchy below the
46/// current directory (down to [`MAX_DEPTH`] levels) **and** the bundled
47/// catalog. Sorted and deduped.
48///
49/// The completion provider drops to this when no prefix candidate is
50/// obvious near the cursor; the `run`/`check` resolvers feed it to
51/// [`did_you_mean`]. Empty `partial` yields nothing — there is no leaf to
52/// match on, and offering the whole tree as a suggestion helps no one.
53pub fn suggest_workloads(partial: &str) -> Vec<String> {
54    if partial.is_empty() {
55        return Vec::new();
56    }
57    let needle = leaf_of(partial);
58    let mut out = Vec::new();
59    let mut budget = MAX_ENTRIES_SCANNED;
60    walk(
61        Path::new("."),
62        String::new(),
63        partial,
64        needle,
65        0,
66        &mut budget,
67        &mut out,
68    );
69    out.extend(bundled_matches(partial, needle));
70    out.sort();
71    out.dedup();
72    out
73}
74
75/// Catalog-only matches, for callers that run bundled names exclusively
76/// (`nmbrs copy`): suggesting a local file there would name something the
77/// command cannot act on.
78pub fn suggest_bundled(partial: &str) -> Vec<String> {
79    if partial.is_empty() {
80        return Vec::new();
81    }
82    let mut out = bundled_matches(partial, leaf_of(partial));
83    out.sort();
84    out.dedup();
85    out
86}
87
88/// Render a hit list (from [`suggest_workloads`] / [`suggest_bundled`]) as
89/// a human "did you mean" tail, ready to append to an error message:
90/// ` Did you mean: a, b?` for a short list, or
91/// ` Did you mean one of: a, b (+N more — \`nmbrs describe workloads --all\`)`
92/// when capped at [`MAX_LISTED`]. Empty string when `hits` is empty, so
93/// callers can append it unconditionally.
94pub fn did_you_mean(hits: &[String]) -> String {
95    if hits.is_empty() {
96        return String::new();
97    }
98    let shown: Vec<&str> = hits.iter().take(MAX_LISTED).map(String::as_str).collect();
99    let overflow = hits.len() - shown.len();
100    let list = shown.join(", ");
101    if overflow > 0 {
102        format!(
103            " Did you mean one of: {list} \
104             (+{overflow} more — `nmbrs describe workloads --all`)"
105        )
106    } else {
107        format!(" Did you mean: {list}?")
108    }
109}
110
111/// Bundled-catalog entries whose full name prefixes `partial` or whose
112/// final segment prefixes `needle`.
113fn bundled_matches(partial: &str, needle: &str) -> Vec<String> {
114    crate::catalog::iter()
115        .map(|w| w.name)
116        .filter(|n| n.starts_with(partial) || leaf_of(n).starts_with(needle))
117        .map(str::to_string)
118        .collect()
119}
120
121/// The final `/`-separated segment of `s` (the whole string if no `/`).
122fn leaf_of(s: &str) -> &str {
123    s.rsplit('/').next().unwrap_or(s)
124}
125
126/// True when a local file path (relative to cwd) is already represented in
127/// the bundled catalog: it lives under a bundle-source root and its mapped
128/// catalog name resolves. Mirrors `nmbrs/build.rs`'s SRD-85 naming, so the
129/// repo's own example files don't double up with their embedded copies in a
130/// suggestion list. The double-up is what makes `examples/dy<TAB>` collapse
131/// to the shared `examples/` prefix (catalog `examples/controls/…` vs file
132/// `nmbrs/examples/workloads/controls/….yaml`); dropping the redundant file
133/// leaves one clean candidate bash can complete to.
134fn is_catalog_duplicate(rel: &str) -> bool {
135    catalog_name_for_local(rel).is_some_and(|n| crate::catalog::lookup(&n).is_some())
136}
137
138/// The catalog name a local file under a bundle-source root would carry, or
139/// `None` if it isn't under one. Inverse of `nmbrs/build.rs`:
140/// `workloads/<x>` → `<x>` (so `workloads/cql/<x>` → `cql/<x>`) and
141/// `examples/workloads/<x>` → `examples/<x>` (extension stripped) — the
142/// SRD-85 logical layout relative to the cwd.
143fn catalog_name_for_local(rel: &str) -> Option<String> {
144    let stem = rel
145        .strip_suffix(".yaml")
146        .or_else(|| rel.strip_suffix(".yml"))?;
147    if let Some(rest) = stem.strip_prefix("examples/workloads/") {
148        return Some(format!("examples/{rest}"));
149    }
150    stem.strip_prefix("workloads/").map(str::to_string)
151}
152
153/// Recursive yaml walk. Descends every directory (skipping hidden and
154/// [`SKIP_DIRS`]) until `depth` reaches [`MAX_DEPTH`], emitting each yaml
155/// file whose relative path prefixes `partial` or whose stem prefixes
156/// `needle`. Bounded by `budget` entries read.
157fn walk(
158    dir: &Path,
159    rel: String,
160    partial: &str,
161    needle: &str,
162    depth: usize,
163    budget: &mut usize,
164    out: &mut Vec<String>,
165) {
166    if *budget == 0 {
167        return;
168    }
169    let Ok(entries) = std::fs::read_dir(dir) else {
170        return;
171    };
172    for entry in entries.flatten() {
173        if *budget == 0 {
174            return;
175        }
176        *budget -= 1;
177        let name = entry.file_name().to_string_lossy().into_owned();
178        if name.starts_with('.') {
179            continue;
180        }
181        let path = entry.path();
182        let child_rel = if rel.is_empty() {
183            name.clone()
184        } else {
185            format!("{rel}/{name}")
186        };
187        if path.is_dir() {
188            if SKIP_DIRS.contains(&name.as_str()) {
189                continue;
190            }
191            if depth < MAX_DEPTH {
192                walk(&path, child_rel, partial, needle, depth + 1, budget, out);
193            }
194            continue;
195        }
196        let Some(stem) = name
197            .strip_suffix(".yaml")
198            .or_else(|| name.strip_suffix(".yml"))
199        else {
200            continue;
201        };
202        if (child_rel.starts_with(partial) || stem.starts_with(needle))
203            && !is_catalog_duplicate(&child_rel)
204        {
205            out.push(child_rel);
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::fs;
214
215    fn touch(p: &Path) {
216        if let Some(parent) = p.parent() {
217            fs::create_dir_all(parent).unwrap();
218        }
219        fs::write(p, "ops: { a: { raw: x } }\n").unwrap();
220    }
221
222    /// Walk a freshly-built tree directly (the public entry point keys off
223    /// the process cwd, which a test must not mutate globally).
224    fn local_hits(root: &Path, partial: &str) -> Vec<String> {
225        let needle = leaf_of(partial);
226        let mut out = Vec::new();
227        let mut budget = MAX_ENTRIES_SCANNED;
228        walk(
229            root,
230            String::new(),
231            partial,
232            needle,
233            0,
234            &mut budget,
235            &mut out,
236        );
237        out.sort();
238        out
239    }
240
241    #[test]
242    fn leaf_match_surfaces_buried_stem() {
243        let dir = std::env::temp_dir().join(format!("nmbrs-suggest-{}", std::process::id()));
244        let _ = fs::remove_dir_all(&dir);
245        touch(&dir.join("examples/workloads/controls/phase_poll_smoke.yaml"));
246        let hits = local_hits(&dir, "phase_poll");
247        assert_eq!(
248            hits,
249            vec!["examples/workloads/controls/phase_poll_smoke.yaml"]
250        );
251    }
252
253    #[test]
254    fn depth_bound_excludes_level_four() {
255        let dir = std::env::temp_dir().join(format!("nmbrs-suggest-depth-{}", std::process::id()));
256        let _ = fs::remove_dir_all(&dir);
257        // a/b/c/<file> is the deepest reached (3 dir levels); a/b/c/d/<file> is not.
258        touch(&dir.join("a/b/c/match_me.yaml"));
259        touch(&dir.join("a/b/c/d/match_me.yaml"));
260        let hits = local_hits(&dir, "match_me");
261        assert_eq!(hits, vec!["a/b/c/match_me.yaml"]);
262    }
263
264    #[test]
265    fn slash_qualified_partial_matches_by_full_prefix() {
266        let dir = std::env::temp_dir().join(format!("nmbrs-suggest-slash-{}", std::process::id()));
267        let _ = fs::remove_dir_all(&dir);
268        touch(&dir.join("cursors/all_cursor/enumerate.yaml"));
269        let hits = local_hits(&dir, "cursors/all_cursor/enum");
270        assert_eq!(hits, vec!["cursors/all_cursor/enumerate.yaml"]);
271    }
272
273    #[test]
274    fn skip_dirs_are_not_walked() {
275        let dir = std::env::temp_dir().join(format!("nmbrs-suggest-skip-{}", std::process::id()));
276        let _ = fs::remove_dir_all(&dir);
277        touch(&dir.join("target/buried/match_me.yaml"));
278        touch(&dir.join("kept/match_me.yaml"));
279        let hits = local_hits(&dir, "match_me");
280        assert_eq!(hits, vec!["kept/match_me.yaml"]);
281    }
282
283    #[test]
284    fn local_path_maps_to_catalog_name() {
285        // Inverse of build.rs's SRD-85 naming — the equivalence used to
286        // dedup a repo example file against its embedded catalog twin.
287        assert_eq!(
288            catalog_name_for_local("examples/workloads/controls/phase_poll_smoke.yaml").as_deref(),
289            Some("examples/controls/phase_poll_smoke")
290        );
291        assert_eq!(
292            catalog_name_for_local("workloads/keyvalue.yaml").as_deref(),
293            Some("keyvalue")
294        );
295        assert_eq!(
296            catalog_name_for_local("workloads/cql/baselinesv3/keyvalue.yml").as_deref(),
297            Some("cql/baselinesv3/keyvalue")
298        );
299        // A file outside any bundle-source root has no catalog twin.
300        assert_eq!(catalog_name_for_local("my/own/workload.yaml"), None);
301        assert_eq!(catalog_name_for_local("examples/notes/readme.txt"), None);
302    }
303
304    #[test]
305    fn empty_partial_yields_nothing() {
306        assert!(suggest_workloads("").is_empty());
307        assert!(suggest_bundled("").is_empty());
308    }
309
310    #[test]
311    fn did_you_mean_formats_short_and_capped_lists() {
312        assert_eq!(did_you_mean(&[]), "");
313        assert_eq!(
314            did_you_mean(&["a".to_string(), "b".to_string()]),
315            " Did you mean: a, b?"
316        );
317        let many: Vec<String> = (0..MAX_LISTED + 3).map(|i| format!("w{i}")).collect();
318        let tail = did_you_mean(&many);
319        assert!(tail.contains("+3 more"), "overflow note: {tail}");
320        assert!(
321            tail.contains("Did you mean one of:"),
322            "capped phrasing: {tail}"
323        );
324    }
325}