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