Skip to main content

gqls/
cli.rs

1//! clap CLI, dispatch, and output formatting (text / json / ndjson).
2
3use anyhow::Result;
4use clap::{CommandFactory, Parser};
5use clap_complete::{generate, Shell};
6use serde::Serialize;
7
8use crate::load;
9use crate::model::{Kind, SchemaRecord};
10use crate::search;
11
12/// The semantic-only flags (--semantic, --model, --refresh, --clear-cache) are
13/// hidden from --help on builds without the feature, where they'd only error.
14const HIDE_SEMANTIC: bool = !cfg!(feature = "_semantic");
15
16#[cfg(feature = "_semantic")]
17const EXAMPLES: &str = "\
18EXAMPLES:
19  gqls user schema.graphql            fuzzy search an SDL file
20  gqls createUser -k mutation         restrict to a kind (schema auto-discovered)
21  gqls User.email                     qualified Type.field query
22  gqls 'User.*'                       wildcard — list a type's fields (quote it)
23  gqls 'User.{first,last}Name'        also ? for one char, {a,b} to alternate
24  gqls repo schema.json               search a local introspection dump
25  gqls repo https://api/graphql       introspect a live endpoint
26  gqls 'cancel a subscription'        rank by meaning (fuzzy + semantic, auto)
27  gqls Query.user -R --code ./app     jump to the graphql-ruby resolver
28  gqls user schema.graphql -j         JSON output (-J for ndjson)
29";
30
31#[cfg(not(feature = "_semantic"))]
32const EXAMPLES: &str = "\
33EXAMPLES:
34  gqls user schema.graphql            fuzzy search an SDL file
35  gqls createUser -k mutation         restrict to a kind (schema auto-discovered)
36  gqls User.email                     qualified Type.field query
37  gqls 'User.*'                       wildcard — list a type's fields (quote it)
38  gqls 'User.{first,last}Name'        also ? for one char, {a,b} to alternate
39  gqls repo schema.json               search a local introspection dump
40  gqls repo https://api/graphql       introspect a live endpoint
41  gqls Query.user -R --code ./app     jump to the graphql-ruby resolver
42  gqls user schema.graphql -j         JSON output (-J for ndjson)
43
44Semantic search (--semantic, rank by meaning) is not compiled into this build. Enable it:
45  cargo install gqls-cli --features semantic
46  brew install dpep/tools/gqls
47";
48
49#[derive(Parser)]
50#[command(
51    name = "gqls",
52    version,
53    about = "Search a GraphQL schema — fuzzy, semantic, or straight to the resolver.",
54    long_about = "Find the types, fields, args, and directives in a GraphQL schema from the \
55                  terminal. The source is an SDL file, a local introspection JSON dump, or a live \
56                  http(s) endpoint; with none given, gqls discovers a schema in the current tree. \
57                  Fuzzy and semantic results are ranked together by default (--semantic or \
58                  --fuzzy forces one); --resolve jumps to the graphql-ruby resolver via rq. All modes \
59                  support -j/--json and -J/--ndjson.",
60    after_help = EXAMPLES
61)]
62struct Cli {
63    /// Search query. Fuzzy by default; abbreviations like `usr` match `User`,
64    /// and `Type.field` queries match against the qualified path. Wildcards
65    /// (`*` any run, `?` one char, `{a,b}` alternatives) enumerate instead of
66    /// searching — quote them (`'User.*'`) so the shell doesn't expand first.
67    #[arg(required_unless_present_any = ["clear_cache", "completions", "warm"])]
68    query: Option<String>,
69
70    /// Schema source: a `.graphql`/`.graphqls` SDL file, a `.json` introspection
71    /// dump, or an http(s) URL (introspected live). If omitted, gqls searches
72    /// the current directory tree for a schema.
73    source: Option<String>,
74
75    /// Restrict to a kind (object, field, query, mutation, enum, scalar, ...).
76    #[arg(short, long)]
77    kind: Option<String>,
78
79    /// Maximum number of results.
80    #[arg(short, long, default_value_t = 20)]
81    limit: usize,
82
83    /// Pretty JSON array.
84    #[arg(short, long, conflicts_with = "ndjson")]
85    json: bool,
86
87    /// Newline-delimited JSON (one record per line).
88    #[arg(short = 'J', long)]
89    ndjson: bool,
90
91    /// Omit schema descriptions from text output (they're shown by default;
92    /// `--json`/`--ndjson` always carry the full text).
93    #[arg(short = 'D', long)]
94    no_description: bool,
95
96    /// Force semantic-only search. By default fuzzy and semantic results are
97    /// combined once the schema's vectors are cached.
98    #[arg(long, hide = HIDE_SEMANTIC)]
99    semantic: bool,
100
101    /// Force fuzzy-only search — skip the semantic combine.
102    #[arg(long, conflicts_with = "semantic")]
103    fuzzy: bool,
104
105    /// Embedding model for --semantic: a local dir / `.onnx` path, or a
106    /// HuggingFace `org/name` id. Defaults to all-MiniLM-L6-v2.
107    #[arg(long, hide = HIDE_SEMANTIC)]
108    model: Option<String>,
109
110    /// Force a re-embed for --semantic, overwriting the cache. Schema edits
111    /// already re-embed on their own; use this for changes the cache can't see
112    /// (e.g. a new model).
113    #[arg(long, hide = HIDE_SEMANTIC)]
114    refresh: bool,
115
116    /// Delete all cached embedding vector files, then exit.
117    #[arg(long, hide = HIDE_SEMANTIC)]
118    clear_cache: bool,
119
120    /// Pre-embed the schema's vectors (warm the cache), then exit.
121    #[arg(long, hide = HIDE_SEMANTIC)]
122    warm: bool,
123
124    /// Print a shell completion script (bash, zsh, fish, ...) to stdout, then exit.
125    #[arg(long, value_name = "SHELL")]
126    completions: Option<Shell>,
127
128    /// Jump the top match to its graphql-ruby resolver/method in code, via
129    /// `rq` (must be installed).
130    #[arg(short = 'R', long)]
131    resolve: bool,
132
133    /// Directory of the server code for --resolve (defaults to rq's index).
134    #[arg(long)]
135    code: Option<String>,
136
137    /// Header for URL introspection, `Name: Value` (repeatable) — e.g. an
138    /// `Authorization` token for an auth-gated endpoint.
139    #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
140    header: Vec<String>,
141
142    /// Verbose stderr diagnostics: cache hits, rq candidates, and why the
143    /// embedding model loaded or fell back.
144    #[arg(short, long, conflicts_with = "quiet")]
145    verbose: bool,
146
147    /// Suppress status chatter on stderr (results and hard errors still print).
148    #[arg(short, long)]
149    quiet: bool,
150}
151
152/// The chosen output format — computed once, honored by every mode. Text
153/// carries whether to print descriptions; the JSON forms always include them.
154#[derive(Clone, Copy)]
155enum Output {
156    Text { descriptions: bool },
157    Json,
158    Ndjson,
159}
160
161/// A ranked result — from either the fuzzy scorer or the semantic ranker, so
162/// both flow through one output path.
163struct Match<'a> {
164    record: &'a SchemaRecord,
165    score: f64,
166}
167
168/// All fuzzy hits above the quality cutoff, best first — the caller truncates
169/// to the display limit, so the length is the true match count. The `bool` is
170/// [`search::named_hit`]: whether some hit names the query's leaf.
171fn fuzzy_matches<'a>(
172    query: &str,
173    records: &'a [SchemaRecord],
174    kind: Option<Kind>,
175    parent: Option<&str>,
176) -> (Vec<Match<'a>>, bool) {
177    let hits = search::search(query, records, kind, parent);
178    let named = search::named_hit(query, &hits);
179    let matches = hits
180        .into_iter()
181        .map(|h| Match {
182            record: h.record,
183            score: h.score as f64,
184        })
185        .collect();
186    (matches, named)
187}
188
189#[cfg(feature = "_semantic")]
190fn semantic_matches<'a>(
191    query: &str,
192    records: &'a [SchemaRecord],
193    kind: Option<Kind>,
194    parent: Option<&str>,
195    cli: &Cli,
196) -> Vec<Match<'a>> {
197    crate::semantic::search(
198        query,
199        records,
200        kind,
201        parent,
202        cli.limit,
203        cli.model.as_deref(),
204        cli.refresh,
205    )
206    .into_iter()
207    .map(|(score, record)| Match { record, score })
208    .collect()
209}
210
211/// Merge the fuzzy and semantic rankings via Reciprocal Rank Fusion — precise
212/// name matches and meaning matches both surface, and a record strong in both
213/// rises to the top. Fuzzy is weighted a touch higher so an exact-name hit
214/// keeps the lead; scale-free, so the two score systems needn't be normalized.
215#[cfg(feature = "_semantic")]
216fn combine<'a>(fuzzy: Vec<Match<'a>>, semantic: Vec<Match<'a>>, limit: usize) -> Vec<Match<'a>> {
217    use std::collections::HashMap;
218    const K: f64 = 60.0;
219    // Key on the record's stable qualified path (unique per entity) rather than
220    // pointer identity, so fusion stays correct even if a ranker ever returned
221    // records not borrowed from the same slice.
222    let mut scored: HashMap<&str, (f64, &SchemaRecord)> = HashMap::new();
223    for (rank, m) in fuzzy.iter().enumerate() {
224        scored
225            .entry(m.record.path.as_str())
226            .or_insert((0.0, m.record))
227            .0 += 1.0 / (K + rank as f64 + 1.0);
228    }
229    for (rank, m) in semantic.iter().enumerate() {
230        scored
231            .entry(m.record.path.as_str())
232            .or_insert((0.0, m.record))
233            .0 += 0.7 / (K + rank as f64 + 1.0);
234    }
235    let mut merged: Vec<Match> = scored
236        .into_values()
237        .map(|(score, record)| Match { record, score })
238        .collect();
239    merged.sort_by(|a, b| b.score.total_cmp(&a.score));
240    merged.truncate(limit);
241    merged
242}
243
244/// Spawn a detached `gqls --warm <source>` so the schema's vectors embed in the
245/// background — the next run gets combined fuzzy+semantic results with no wait.
246/// Opt out with `GQLS_NO_AUTOWARM`. Best-effort; failures are ignored.
247#[cfg(feature = "_semantic")]
248fn spawn_background_warm(source: &str, headers: &[String]) {
249    if std::env::var_os("GQLS_NO_AUTOWARM").is_some() {
250        return;
251    }
252    // Single-flight: a detached warm for this source may already be running.
253    // A short-lived lockfile keeps a burst of cold queries from spawning a herd
254    // that all embed the same schema and race the cache.
255    if !claim_warm_lock(source) {
256        return;
257    }
258    if let Ok(exe) = std::env::current_exe() {
259        let mut cmd = std::process::Command::new(exe);
260        cmd.arg("--warm").arg(source);
261        for h in headers {
262            cmd.arg("--header").arg(h);
263        }
264        let _ = cmd
265            .stdin(std::process::Stdio::null())
266            .stdout(std::process::Stdio::null())
267            .stderr(std::process::Stdio::null())
268            .spawn();
269    }
270}
271
272/// Best-effort single-flight guard for background warming: returns true (and
273/// stakes a claim) when no recent warm for `source` is in flight, false when one
274/// likely is. The lockfile self-expires by mtime, so a crashed warm can't wedge
275/// warming forever, and a failed warm won't be retried in a tight loop.
276#[cfg(feature = "_semantic")]
277fn claim_warm_lock(source: &str) -> bool {
278    use std::collections::hash_map::DefaultHasher;
279    use std::hash::{Hash, Hasher};
280    use std::time::Duration;
281
282    const LOCK_TTL: Duration = Duration::from_secs(10 * 60);
283    // Lockfiles live in the system temp dir so the OS auto-reaps them.
284    let dir = crate::paths::temp_dir();
285    let mut h = DefaultHasher::new();
286    source.hash(&mut h);
287    let lock = dir.join(format!("warming-{:016x}.lock", h.finish()));
288    if let Ok(meta) = std::fs::metadata(&lock) {
289        if let Ok(modified) = meta.modified() {
290            if modified.elapsed().is_ok_and(|age| age < LOCK_TTL) {
291                return false; // a recent warm is presumably still running
292            }
293        }
294    }
295    let _ = std::fs::create_dir_all(&dir);
296    std::fs::write(&lock, []).is_ok()
297}
298
299/// Parse `-H "Name: Value"` strings into `(name, value)` pairs.
300fn parse_headers(raw: &[String]) -> Result<Vec<(String, String)>> {
301    raw.iter()
302        .map(|h| {
303            let (name, value) = h
304                .split_once(':')
305                .ok_or_else(|| anyhow::anyhow!("--header {h:?} must be `Name: Value`"))?;
306            Ok((name.trim().to_string(), value.trim().to_string()))
307        })
308        .collect()
309}
310
311pub fn run() -> Result<()> {
312    let cli = Cli::parse();
313    crate::logging::init(cli.verbose, cli.quiet);
314
315    if let Some(shell) = cli.completions {
316        let mut cmd = Cli::command();
317        let name = cmd.get_name().to_string();
318        generate(shell, &mut cmd, name, &mut std::io::stdout());
319        return Ok(());
320    }
321
322    if cli.clear_cache {
323        let introspect = crate::load::introspect::clear_cache();
324        let records = crate::load::record_cache::clear();
325        #[cfg(feature = "_semantic")]
326        let vectors = crate::semantic::clear_cache();
327        #[cfg(not(feature = "_semantic"))]
328        let vectors = 0;
329        crate::status!("cleared {} cached file(s)", introspect + records + vectors);
330        return Ok(());
331    }
332
333    let output = if cli.json {
334        Output::Json
335    } else if cli.ndjson {
336        Output::Ndjson
337    } else {
338        Output::Text {
339            descriptions: !cli.no_description,
340        }
341    };
342
343    let kind: Option<Kind> = match &cli.kind {
344        Some(s) => Some(s.parse()?),
345        None => None,
346    };
347
348    // The schema source. With `--warm` and no explicit source, the sole
349    // positional is the schema (there's no query to warm), so `gqls --warm
350    // schema.graphql` — and the background spawn — target the right file.
351    let source = if let Some(s) = cli.source.clone() {
352        s
353    } else if cli.warm {
354        match cli.query.clone() {
355            Some(s) => s,
356            None => load::discover()?,
357        }
358    } else {
359        load::discover()?
360    };
361    let load_opts = load::LoadOptions {
362        headers: parse_headers(&cli.header)?,
363        refresh: cli.refresh,
364    };
365    let t_load = std::time::Instant::now();
366    let records = load::load(&source, &load_opts)?;
367    crate::detail!(
368        "loaded {} records in {:.1?}",
369        records.len(),
370        t_load.elapsed()
371    );
372
373    // --warm: embed + cache the schema's vectors, then exit (no query needed).
374    // Also the primitive the background auto-warm spawns.
375    if cli.warm {
376        #[cfg(feature = "_semantic")]
377        {
378            let n = crate::semantic::warm(&records, cli.model.as_deref(), cli.refresh);
379            crate::status!("cached vectors for {n} record(s)");
380            return Ok(());
381        }
382        #[cfg(not(feature = "_semantic"))]
383        {
384            let _ = (&cli.model, cli.refresh);
385            anyhow::bail!("--warm needs a semantic build");
386        }
387    }
388
389    // clap guarantees a query unless --clear-cache/--completions/--warm.
390    let query = cli
391        .query
392        .as_deref()
393        .ok_or_else(|| anyhow::anyhow!("a QUERY is required (see --help)"))?;
394
395    // A wildcard query (`User.*`) enumerates rather than searches: the pattern
396    // does its own scoping and every match is exact, so the qualifier rewrites
397    // and the semantic combine below are both bypassed.
398    let pattern = search::glob::is_pattern(query);
399    if pattern {
400        crate::detail!("wildcard query — enumerating matches for {query:?}");
401    }
402
403    // `User name` — a two-word query whose first word exactly names a type —
404    // is the qualified form typed with a space. Rewrite it, but remember the
405    // loose intent: unlike the dot form, an exact hit here keeps the semantic
406    // combine on ("around this", not "exactly this").
407    let spaced = (!pattern)
408        .then(|| search::spaced_qualifier(query, &records))
409        .flatten();
410    let loose = spaced.is_some();
411    let query = spaced.as_deref().unwrap_or(query);
412    if loose {
413        crate::detail!("two-word query names a type — searching as {query:?}");
414    }
415
416    // A `Type.field` query whose qualifier names a schema type — exactly, or
417    // as its unique closest misspelling — becomes a hard filter to that type's
418    // members, in every search mode. A silent correction would be confusing,
419    // so that case is announced at normal verbosity.
420    let parent = (!pattern)
421        .then(|| search::parent_filter(query, &records))
422        .flatten();
423    if let Some(p) = parent {
424        let (_, qualifier) = search::score::parse_qualified(query);
425        if qualifier.is_some_and(|q| q.eq_ignore_ascii_case(p)) {
426            crate::detail!("qualifier {p:?} names a type — restricting to its members");
427        } else {
428            crate::status!(
429                "no type named {:?} — using closest match {p:?}",
430                qualifier.unwrap_or_default()
431            );
432        }
433    }
434
435    if cli.resolve {
436        return run_resolve(
437            query,
438            &source,
439            &records,
440            kind,
441            parent,
442            cli.code.as_deref(),
443            cli.limit,
444            output,
445        );
446    }
447
448    // `total` is the fuzzy match count before the display limit, so the footer
449    // can say how much a raised -l would reveal. Semantic-only mode has no
450    // meaningful total (cosine ranks every record), so it never shows one.
451    let t_rank = std::time::Instant::now();
452    let (matches, total): (Vec<Match>, usize) = if cli.fuzzy {
453        let (mut fuzzy, _) = fuzzy_matches(query, &records, kind, parent);
454        let total = fuzzy.len();
455        fuzzy.truncate(cli.limit);
456        (fuzzy, total)
457    } else if cli.semantic {
458        #[cfg(feature = "_semantic")]
459        {
460            if pattern {
461                crate::status!("--semantic ranks by meaning and ignores wildcards in {query:?}");
462            }
463            let matches = semantic_matches(query, &records, kind, parent, &cli);
464            let total = matches.len();
465            (matches, total)
466        }
467        #[cfg(not(feature = "_semantic"))]
468        {
469            let _ = (&cli.model, cli.refresh);
470            anyhow::bail!(
471                "this build has no semantic search — install it with \
472                 `cargo install gqls-cli --features semantic` or `brew install dpep/tools/gqls`"
473            );
474        }
475    } else {
476        // Default: combine fuzzy + semantic when the cache is warm; when cold,
477        // return fuzzy now and warm the vectors in the background for next time.
478        // A strong name hit (exact, or the leaf whole at a word boundary —
479        // `name` → `lastName`) skips the combine outright: the user typed a
480        // word that exists, so semantic ranking would only append lookalike
481        // filler below it (and cost the model load).
482        let (mut fuzzy, named) = fuzzy_matches(query, &records, kind, parent);
483        let total = fuzzy.len();
484        fuzzy.truncate(cli.limit);
485        #[cfg(feature = "_semantic")]
486        {
487            // A wildcard enumerates exact matches, and a strong name hit means
488            // fuzzy already found the word typed — neither wants meaning-based
489            // lookalikes appended (nor the model load they cost).
490            let skip = if pattern {
491                Some("wildcard enumeration")
492            } else if named && !loose {
493                Some("strong name match")
494            } else {
495                None
496            };
497            if let Some(why) = skip {
498                crate::detail!("{why} — semantic ranking skipped (--semantic to force)");
499                (fuzzy, total)
500            } else if crate::semantic::is_cached(&records, cli.model.as_deref()) {
501                let semantic = semantic_matches(query, &records, kind, parent, &cli);
502                (combine(fuzzy, semantic, cli.limit), total)
503            } else {
504                spawn_background_warm(&source, &cli.header);
505                crate::status!(
506                    "building the semantic index in the background; next run ranks by \
507                     meaning (--semantic to wait, --fuzzy to skip)"
508                );
509                (fuzzy, total)
510            }
511        }
512        #[cfg(not(feature = "_semantic"))]
513        {
514            let _ = (&cli.model, cli.refresh, named, loose);
515            (fuzzy, total)
516        }
517    };
518
519    crate::detail!("ranked in {:.1?}", t_rank.elapsed());
520
521    if matches.is_empty() {
522        crate::status!("no matches for {query:?}");
523    }
524    output.write_matches(&matches)?;
525    if total > matches.len() {
526        crate::detail!(
527            "{total} matches; showing top {} (-l to adjust)",
528            matches.len()
529        );
530    }
531    Ok(())
532}
533
534impl Output {
535    fn write_matches(self, matches: &[Match]) -> Result<()> {
536        #[derive(Serialize)]
537        struct Row<'a> {
538            #[serde(flatten)]
539            record: &'a SchemaRecord,
540            score: f64,
541        }
542        let rows = || {
543            matches.iter().map(|m| Row {
544                record: m.record,
545                score: m.score,
546            })
547        };
548        match self {
549            Output::Json => println!(
550                "{}",
551                serde_json::to_string_pretty(&rows().collect::<Vec<_>>())?
552            ),
553            Output::Ndjson => {
554                for row in rows() {
555                    println!("{}", serde_json::to_string(&row)?);
556                }
557            }
558            Output::Text { descriptions } => print_text(matches, descriptions),
559        }
560        Ok(())
561    }
562}
563
564/// Longest description rendered inline. A schema doc can run to paragraphs;
565/// one line per result keeps the output greppable, so the rest is elided
566/// (`--json` carries the full text).
567const DESCRIPTION_WIDTH: usize = 72;
568
569fn print_text(matches: &[Match], descriptions: bool) {
570    let width = matches
571        .iter()
572        .map(|m| display_path(m.record).len())
573        .max()
574        .unwrap_or(0)
575        .min(48);
576
577    for m in matches {
578        let r = m.record;
579        let path = display_path(r);
580        let ret = r
581            .type_ref
582            .as_deref()
583            .map(|t| format!(" -> {t}"))
584            .unwrap_or_default();
585        let dep = if r.deprecated.is_some() {
586            " (deprecated)"
587        } else {
588            ""
589        };
590        let desc = if descriptions {
591            r.description
592                .as_deref()
593                .map(summarize)
594                .filter(|d| !d.is_empty())
595                .map(|d| format!(" — {d}"))
596                .unwrap_or_default()
597        } else {
598            String::new()
599        };
600        println!(
601            "{path:<width$}{ret}  [{kind}]{dep}{desc}",
602            kind = r.kind.as_str()
603        );
604    }
605}
606
607/// A schema description as one line: whitespace (including the newlines of a
608/// block description) collapsed, then elided at [`DESCRIPTION_WIDTH`].
609fn summarize(description: &str) -> String {
610    let mut out = String::new();
611    for (i, word) in description.split_whitespace().enumerate() {
612        if i > 0 {
613            out.push(' ');
614        }
615        out.push_str(word);
616        if out.chars().count() > DESCRIPTION_WIDTH {
617            let kept: String = out.chars().take(DESCRIPTION_WIDTH).collect();
618            return format!("{}…", kept.trim_end());
619        }
620    }
621    out
622}
623
624/// Fuzzy-find the field, then hand it to rq to locate its resolver in code.
625#[allow(clippy::too_many_arguments)]
626fn run_resolve(
627    query: &str,
628    source: &str,
629    records: &[SchemaRecord],
630    kind: Option<Kind>,
631    parent: Option<&str>,
632    code: Option<&str>,
633    limit: usize,
634    output: Output,
635) -> Result<()> {
636    if code.is_none() {
637        crate::status!("searching code in the current directory (--code to search elsewhere)");
638    }
639    let Some(top) = search::search(query, records, kind, parent)
640        .into_iter()
641        .next()
642    else {
643        anyhow::bail!("no schema entity matches {query:?} to resolve");
644    };
645    crate::status!("resolving {} …", top.record.path);
646    // a local file schema (not a URL) enables package-proximity ranking
647    let schema_path = (!source.starts_with("http://") && !source.starts_with("https://"))
648        .then(|| std::path::Path::new(source))
649        .filter(|p| p.exists());
650    let hits = crate::resolve::resolve(top.record, code, schema_path, limit.min(10))?;
651
652    match output {
653        Output::Json => println!("{}", serde_json::to_string_pretty(&hits)?),
654        Output::Ndjson => {
655            for h in &hits {
656                println!("{}", serde_json::to_string(h)?);
657            }
658        }
659        Output::Text { .. } => {
660            if hits.is_empty() {
661                crate::status!(
662                    "no code definition found for {} (-v shows what was tried)",
663                    top.record.path
664                );
665            }
666            for h in &hits {
667                println!("{}:{}  {}  (via {})", h.file, h.line, h.name, h.via);
668            }
669        }
670    }
671    Ok(())
672}
673
674/// `Query.user(id: ID!, first: Int)` — path plus a compact arg signature.
675fn display_path(r: &SchemaRecord) -> String {
676    if r.args.is_empty() {
677        r.path.clone()
678    } else {
679        format!("{}({})", r.path, r.args.join(", "))
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::{summarize, DESCRIPTION_WIDTH};
686
687    #[test]
688    fn collapses_block_descriptions_to_one_line() {
689        assert_eq!(summarize("  Look up\n  a user.\n"), "Look up a user.");
690        assert_eq!(summarize("   "), "");
691    }
692
693    #[test]
694    fn elides_past_the_width() {
695        let long = "word ".repeat(60);
696        let out = summarize(&long);
697        assert!(out.ends_with('…'), "{out}");
698        assert!(out.chars().count() <= DESCRIPTION_WIDTH + 1, "{out}");
699    }
700
701    #[test]
702    fn keeps_a_description_that_fits() {
703        let s = "An account.";
704        assert_eq!(summarize(s), s);
705    }
706}