Skip to main content

typg_cli/
lib.rs

1//! typg CLI.
2//!
3//! The CLI exposes three user-facing modes:
4//! - `find` for live scans over directories,
5//! - `cache` for reusing saved metadata or an LMDB index,
6//! - `serve` for HTTP access to the same query model.
7//!
8//! The command-line flags map directly onto the shared `Query` type in
9//! `typg-core`, so the same filter semantics apply across live, cached, HTTP,
10//! and Python entry points.
11//!
12//! Made by FontLab https://www.fontlab.com/
13
14mod server;
15
16use std::collections::HashMap;
17use std::env;
18use std::fs::{self, File};
19use std::io::{self, BufRead, BufReader, BufWriter, IsTerminal, Write};
20use std::ops::RangeInclusive;
21use std::path::{Path, PathBuf};
22
23use anyhow::{anyhow, Context, Result};
24use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum, ValueHint};
25use regex::Regex;
26use serde_json::Deserializer;
27use tokio::runtime::Builder;
28
29// Removed unused imports write_json_pretty and write_ndjson
30use typg_core::query::{
31    parse_codepoint_list, parse_family_class, parse_tag_list, parse_u16_range, FamilyClassFilter,
32    Query,
33};
34use typg_core::search::{
35    filter_cached, search, search_streaming, SearchOptions, TypgFontFaceMatch,
36};
37use typg_core::tags::tag_to_string;
38
39#[cfg(feature = "hpindex")]
40use typg_core::index::FontIndex;
41
42/// Top-level CLI definition.
43#[derive(Debug, Parser)]
44#[command(
45    name = "typg",
46    version,
47    about = "Fast font search (made by FontLab https://www.fontlab.com/)"
48)]
49pub struct Cli {
50    /// Suppress informational messages
51    #[arg(short = 'q', long = "quiet", global = true, action = ArgAction::SetTrue)]
52    quiet: bool,
53
54    #[command(subcommand)]
55    command: Command,
56}
57
58/// Available subcommands.
59#[derive(Debug, Subcommand)]
60enum Command {
61    /// Search directories for fonts matching a query
62    Find(Box<FindArgs>),
63
64    /// Manage the font metadata cache
65    #[command(subcommand)]
66    Cache(CacheCommand),
67
68    /// Start an HTTP search server
69    Serve(ServeArgs),
70}
71
72/// Cache management subcommands.
73#[derive(Debug, Subcommand)]
74enum CacheCommand {
75    /// Scan paths and add font metadata to the cache
76    Add(CacheAddArgs),
77    /// List all cached font entries
78    List(CacheListArgs),
79    /// Query the cache without scanning the filesystem
80    Find(Box<CacheFindArgs>),
81    /// Remove entries for fonts that no longer exist on disk
82    Clean(CacheCleanArgs),
83    /// Show cache location, size, and entry count
84    Info(CacheInfoArgs),
85}
86
87/// HTTP server configuration.
88#[derive(Debug, Args)]
89struct ServeArgs {
90    /// Address to bind (host:port)
91    #[arg(long = "bind", default_value = "127.0.0.1:8765")]
92    bind: String,
93}
94
95/// Arguments for `cache add`.
96#[derive(Debug, Args)]
97struct CacheAddArgs {
98    /// Paths to scan (directories or individual font files)
99    #[arg(
100        value_hint = ValueHint::DirPath,
101        required_unless_present_any = ["system_fonts", "stdin_paths"]
102    )]
103    paths: Vec<PathBuf>,
104
105    /// Read additional paths from stdin, one per line
106    #[arg(long = "stdin-paths", action = ArgAction::SetTrue)]
107    stdin_paths: bool,
108
109    /// Include platform-default system font directories
110    #[arg(long = "system-fonts", action = ArgAction::SetTrue)]
111    system_fonts: bool,
112
113    /// Follow symlinks during directory traversal
114    #[arg(long = "follow-symlinks", action = ArgAction::SetTrue)]
115    follow_symlinks: bool,
116
117    /// Number of parallel worker threads
118    #[arg(short = 'J', long = "jobs", value_hint = ValueHint::Other)]
119    jobs: Option<usize>,
120
121    /// Override cache file location
122    #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
123    cache_path: Option<PathBuf>,
124
125    /// Use LMDB index backend instead of JSON cache
126    #[arg(long = "index", action = ArgAction::SetTrue)]
127    use_index: bool,
128
129    /// Override LMDB index directory
130    #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
131    index_path: Option<PathBuf>,
132}
133
134/// Output format options shared across subcommands.
135#[derive(Debug, Args, Clone)]
136struct OutputArgs {
137    /// Output as a single JSON array
138    #[arg(long = "json", action = ArgAction::SetTrue, conflicts_with_all = ["ndjson", "csv"])]
139    json: bool,
140
141    /// Output as newline-delimited JSON (one object per line)
142    #[arg(long = "ndjson", action = ArgAction::SetTrue, conflicts_with_all = ["json", "csv"])]
143    ndjson: bool,
144
145    /// Output as CSV
146    #[arg(
147        long = "csv",
148        action = ArgAction::SetTrue,
149        conflicts_with_all = ["json", "ndjson", "paths", "columns"]
150    )]
151    csv: bool,
152
153    /// Output file paths only (with #index for TTC faces)
154    #[arg(
155        long = "paths",
156        action = ArgAction::SetTrue,
157        conflicts_with_all = ["json", "ndjson", "csv", "columns"]
158    )]
159    paths: bool,
160
161    /// Output as aligned columns
162    #[arg(long = "columns", action = ArgAction::SetTrue, conflicts_with_all = ["json", "ndjson", "csv", "paths"])]
163    columns: bool,
164
165    /// Show individual TTC/OTC collection faces (path#index); default deduplicates by path
166    #[arg(long = "collections", action = ArgAction::SetTrue)]
167    collections: bool,
168
169    /// Colorize output (auto detects terminal)
170    #[arg(long = "color", default_value_t = ColorChoice::Auto, value_enum)]
171    color: ColorChoice,
172
173    /// Output custom details (comma-separated list of keywords: path, path_r, fmt, fname, sname, psname, tfname, lfname, tsname, lsname, var, wt, wd, panf, axes, axes_n, fea, fea_n, scr, scr_n, tab, tab_n, crea, lic, or preset 0..9)
174    #[arg(short = 'd', long = "details")]
175    details: Option<String>,
176}
177
178#[derive(Debug, Args)]
179struct CacheListArgs {
180    /// Override cache location (defaults to ~/.cache/typg/cache.json)
181    #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
182    cache_path: Option<PathBuf>,
183
184    /// Use high-performance LMDB index instead of JSON cache (requires hpindex feature)
185    #[arg(long = "index", action = ArgAction::SetTrue)]
186    use_index: bool,
187
188    /// Override index directory (defaults to ~/.cache/typg/index/)
189    #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
190    index_path: Option<PathBuf>,
191
192    #[command(flatten)]
193    output: OutputArgs,
194}
195
196#[derive(Debug, Args)]
197struct CacheFindArgs {
198    /// Override cache location (defaults to ~/.cache/typg/cache.json)
199    #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
200    cache_path: Option<PathBuf>,
201
202    /// Use high-performance LMDB index instead of JSON cache (requires hpindex feature)
203    #[arg(long = "index", action = ArgAction::SetTrue)]
204    use_index: bool,
205
206    /// Override index directory (defaults to ~/.cache/typg/index/)
207    #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
208    index_path: Option<PathBuf>,
209
210    /// Require fonts to define these axis tags
211    #[arg(short = 'a', long = "axes", value_delimiter = ',', value_hint = ValueHint::Other)]
212    axes: Vec<String>,
213
214    /// Require fonts to define these OpenType feature tags
215    #[arg(short = 'f', long = "features", value_delimiter = ',', value_hint = ValueHint::Other)]
216    features: Vec<String>,
217
218    /// Require fonts to cover these script tags
219    #[arg(short = 's', long = "scripts", value_delimiter = ',', value_hint = ValueHint::Other)]
220    scripts: Vec<String>,
221
222    /// Require fonts to contain these table tags
223    #[arg(short = 'T', long = "tables", value_delimiter = ',', value_hint = ValueHint::Other)]
224    tables: Vec<String>,
225
226    /// Regex patterns that must match at least one font name
227    #[arg(short = 'n', long = "name", value_hint = ValueHint::Other)]
228    name_patterns: Vec<String>,
229
230    /// Regex patterns that must match creator info (copyright, trademark, manufacturer, designer, description, URLs, license)
231    #[arg(short = 'c', long = "creator", value_hint = ValueHint::Other)]
232    creator_patterns: Vec<String>,
233
234    /// Regex patterns that must match license info (copyright, license description, license URL)
235    #[arg(short = 'l', long = "license", value_hint = ValueHint::Other)]
236    license_patterns: Vec<String>,
237
238    /// Unicode codepoints or ranges (e.g. U+0041-U+0044,B)
239    #[arg(short = 'u', long = "codepoints", value_delimiter = ',', value_hint = ValueHint::Other)]
240    codepoints: Vec<String>,
241
242    /// Require fonts to cover this text sample
243    #[arg(short = 't', long = "text")]
244    text: Option<String>,
245
246    /// Only include variable fonts
247    #[arg(short = 'v', long = "variable", action = ArgAction::SetTrue)]
248    variable: bool,
249
250    /// Match OS/2 weight class (single value like 400 or range like 300-500)
251    #[arg(short = 'w', long = "weight", value_hint = ValueHint::Other)]
252    weight: Option<String>,
253
254    /// Match OS/2 width class (1-9, single value or range)
255    #[arg(short = 'W', long = "width", value_hint = ValueHint::Other)]
256    width: Option<String>,
257
258    /// Match OS/2 family class (major like 8 or major.subclass like 8.11; accepts names like sans)
259    #[arg(long = "family-class", value_hint = ValueHint::Other)]
260    family_class: Option<String>,
261
262    /// Only output the count of matching fonts (useful for scripting)
263    #[arg(long = "count", action = ArgAction::SetTrue, conflicts_with_all = ["json", "ndjson", "paths", "columns"])]
264    count_only: bool,
265
266    #[command(flatten)]
267    output: OutputArgs,
268}
269
270#[derive(Debug, Args)]
271struct CacheCleanArgs {
272    /// Override cache location (defaults to ~/.cache/typg/cache.json)
273    #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
274    cache_path: Option<PathBuf>,
275
276    /// Use high-performance LMDB index instead of JSON cache (requires hpindex feature)
277    #[arg(long = "index", action = ArgAction::SetTrue)]
278    use_index: bool,
279
280    /// Override index directory (defaults to ~/.cache/typg/index/)
281    #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
282    index_path: Option<PathBuf>,
283}
284
285#[derive(Debug, Args)]
286struct CacheInfoArgs {
287    /// Override cache location (defaults to ~/.cache/typg/cache.json)
288    #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
289    cache_path: Option<PathBuf>,
290
291    /// Use high-performance LMDB index instead of JSON cache (requires hpindex feature)
292    #[arg(long = "index", action = ArgAction::SetTrue)]
293    use_index: bool,
294
295    /// Override index directory (defaults to ~/.cache/typg/index/)
296    #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
297    index_path: Option<PathBuf>,
298
299    /// Output as JSON
300    #[arg(long = "json", action = ArgAction::SetTrue)]
301    json: bool,
302}
303
304#[derive(Debug, Args)]
305struct FindArgs {
306    /// Paths to search (directories or files)
307    #[arg(
308        value_hint = ValueHint::DirPath,
309        required_unless_present_any = ["system_fonts", "stdin_paths"]
310    )]
311    paths: Vec<PathBuf>,
312
313    /// Read newline-delimited paths from STDIN
314    #[arg(long = "stdin-paths", action = ArgAction::SetTrue)]
315    stdin_paths: bool,
316
317    /// Include common system font directories automatically
318    #[arg(long = "system-fonts", action = ArgAction::SetTrue)]
319    system_fonts: bool,
320
321    /// Require fonts to define these axis tags
322    #[arg(short = 'a', long = "axes", value_delimiter = ',', value_hint = ValueHint::Other)]
323    axes: Vec<String>,
324
325    /// Require fonts to define these OpenType feature tags
326    #[arg(short = 'f', long = "features", value_delimiter = ',', value_hint = ValueHint::Other)]
327    features: Vec<String>,
328
329    /// Require fonts to cover these script tags
330    #[arg(short = 's', long = "scripts", value_delimiter = ',', value_hint = ValueHint::Other)]
331    scripts: Vec<String>,
332
333    /// Require fonts to contain these table tags
334    #[arg(short = 'T', long = "tables", value_delimiter = ',', value_hint = ValueHint::Other)]
335    tables: Vec<String>,
336
337    /// Regex patterns that must match at least one font name
338    #[arg(short = 'n', long = "name", value_hint = ValueHint::Other)]
339    name_patterns: Vec<String>,
340
341    /// Regex patterns that must match creator info (copyright, trademark, manufacturer, designer, description, URLs, license)
342    #[arg(short = 'c', long = "creator", value_hint = ValueHint::Other)]
343    creator_patterns: Vec<String>,
344
345    /// Regex patterns that must match license info (copyright, license description, license URL)
346    #[arg(short = 'l', long = "license", value_hint = ValueHint::Other)]
347    license_patterns: Vec<String>,
348
349    /// Unicode codepoints or ranges (e.g. U+0041-U+0044,B)
350    #[arg(short = 'u', long = "codepoints", value_delimiter = ',', value_hint = ValueHint::Other)]
351    codepoints: Vec<String>,
352
353    /// Require fonts to cover this text sample
354    #[arg(short = 't', long = "text")]
355    text: Option<String>,
356
357    /// Only include variable fonts
358    #[arg(short = 'v', long = "variable", action = ArgAction::SetTrue)]
359    variable: bool,
360
361    /// Match OS/2 weight class (single value like 400 or range like 300-500)
362    #[arg(short = 'w', long = "weight", value_hint = ValueHint::Other)]
363    weight: Option<String>,
364
365    /// Match OS/2 width class (1-9, single value or range)
366    #[arg(short = 'W', long = "width", value_hint = ValueHint::Other)]
367    width: Option<String>,
368
369    /// Match OS/2 family class (major like 8 or major.subclass like 8.11; accepts names like sans)
370    #[arg(long = "family-class", value_hint = ValueHint::Other)]
371    family_class: Option<String>,
372
373    /// Follow symlinks while walking paths
374    #[arg(long = "follow-symlinks", action = ArgAction::SetTrue)]
375    follow_symlinks: bool,
376
377    /// Number of worker threads (defaults to CPU count)
378    #[arg(short = 'J', long = "jobs", value_hint = ValueHint::Other)]
379    jobs: Option<usize>,
380
381    /// Emit a single JSON array
382    #[arg(long = "json", action = ArgAction::SetTrue, conflicts_with_all = ["ndjson", "csv"])]
383    json: bool,
384
385    /// Emit newline-delimited JSON
386    #[arg(long = "ndjson", action = ArgAction::SetTrue, conflicts_with_all = ["json", "csv"])]
387    ndjson: bool,
388
389    /// Emit CSV
390    #[arg(
391        long = "csv",
392        action = ArgAction::SetTrue,
393        conflicts_with_all = ["json", "ndjson", "paths_only", "columns"]
394    )]
395    csv: bool,
396
397    /// Emit newline-delimited font paths (with #index for TTC)
398    #[arg(
399        long = "paths",
400        action = ArgAction::SetTrue,
401        conflicts_with_all = ["json", "ndjson", "csv", "columns"]
402    )]
403    paths_only: bool,
404
405    /// Format output as padded columns
406    #[arg(long = "columns", action = ArgAction::SetTrue, conflicts_with_all = ["json", "ndjson", "csv", "paths_only"])]
407    columns: bool,
408
409    /// Show individual TTC/OTC collection faces (path#index); default deduplicates by path
410    #[arg(long = "collections", action = ArgAction::SetTrue)]
411    collections: bool,
412
413    /// Only output the count of matching fonts (useful for scripting)
414    #[arg(long = "count", action = ArgAction::SetTrue, conflicts_with_all = ["json", "ndjson", "csv", "paths_only", "columns"])]
415    count_only: bool,
416
417    /// Control colorized output (auto|always|never)
418    #[arg(long = "color", default_value_t = ColorChoice::Auto, value_enum)]
419    color: ColorChoice,
420
421    /// Output custom details (comma-separated list of keywords: path, path_r, fmt, fname, sname, psname, tfname, lfname, tsname, lsname, var, wt, wd, panf, axes, axes_n, fea, fea_n, scr, scr_n, tab, tab_n, crea, lic, or preset 0..9)
422    #[arg(short = 'd', long = "details")]
423    details: Option<String>,
424}
425
426#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
427enum ColorChoice {
428    Auto,
429    Always,
430    Never,
431}
432
433/// Parse CLI arguments and dispatch to the appropriate handler.
434///
435/// This is the single entry point called from `main`. Every subcommand
436/// (`find`, `cache add/list/find/clean/info`, `serve`) passes through here.
437/// Errors bubble up as `anyhow::Error` and are printed by `main` before exit.
438pub fn run() -> Result<()> {
439    let cli = Cli::parse();
440    let quiet = cli.quiet;
441
442    match cli.command {
443        Command::Find(args) => run_find(*args),
444        Command::Cache(cmd) => match cmd {
445            CacheCommand::Add(args) => run_cache_add(args, quiet),
446            CacheCommand::List(args) => run_cache_list(args),
447            CacheCommand::Find(args) => run_cache_find(*args),
448            CacheCommand::Clean(args) => run_cache_clean(args, quiet),
449            CacheCommand::Info(args) => run_cache_info(args),
450        },
451        Command::Serve(args) => run_serve(args),
452    }
453}
454
455/// Live-scan directories for fonts matching the user's query.
456///
457/// The pipeline is: gather paths → build query → run search → write output.
458///
459/// Output mode determines whether we can stream (NDJSON, plain, paths) or
460/// must collect first (JSON array, columns, count). JSON and columns need all
461/// results before writing because JSON requires a closing `]` and columns need
462/// to measure the longest path for alignment. Everything else streams — the
463/// first matches appear on stdout while the scan is still running.
464///
465/// The `--collections` flag controls deduplication: by default we emit each
466/// *file* once (even if it contains many faces), because most downstream tools
467/// want paths. With `--collections` you get one line per *face* — useful when
468/// you need the `path#index` notation to address individual faces inside a TTC.
469fn run_find(args: FindArgs) -> Result<()> {
470    if matches!(args.jobs, Some(0)) {
471        return Err(anyhow!("--jobs must be at least 1"));
472    }
473
474    let stdin = io::stdin();
475    let paths = gather_paths(
476        &args.paths,
477        args.stdin_paths,
478        args.system_fonts,
479        stdin.lock(),
480    )?;
481    let query = build_query(&args)?;
482    let opts = SearchOptions {
483        follow_symlinks: args.follow_symlinks,
484        jobs: args.jobs,
485    };
486
487    let output = OutputFormat::from_find(&args);
488
489    // Validate details keywords/presets early.
490    let _ = get_effective_properties(&output)?;
491
492    // JSON arrays and column output need all results in memory first.
493    // JSON needs a closing `]`; columns need to measure the longest path for
494    // alignment. Count-only is cheap but also needs the full set.
495    if args.count_only || output.json || output.columns {
496        let matches = search(&paths, &query, &opts)?;
497        if args.count_only {
498            println!("{}", matches.len());
499            return Ok(());
500        }
501        return write_matches(&matches, &output, &paths);
502    }
503
504    // Stream results to stdout as they're found
505    let (tx, rx) = std::sync::mpsc::channel();
506
507    std::thread::scope(|s| -> Result<()> {
508        let handle = s.spawn(|| search_streaming(&paths, &query, &opts, tx));
509
510        let stdout = io::stdout();
511        let mut w = stdout.lock();
512        let use_color = match output.color {
513            ColorChoice::Always => true,
514            ColorChoice::Never => false,
515            ColorChoice::Auto => w.is_terminal(),
516        };
517
518        // If CSV is active, print header first!
519        if output.csv {
520            if let Ok(props) = get_effective_properties(&output) {
521                let header_cols: Vec<String> = props.iter().map(|p| escape_csv_field(p)).collect();
522                let _ = writeln!(w, "{}", header_cols.join(","));
523            }
524        }
525
526        let mut seen = std::collections::HashSet::new();
527        for m in rx {
528            if output.paths {
529                if output.collections {
530                    let _ = writeln!(w, "{}", m.source.path_with_index());
531                } else if seen.insert(m.source.path.clone()) {
532                    let _ = writeln!(w, "{}", m.source.path.display());
533                }
534            } else if output.csv {
535                if output.collections || seen.insert(m.source.path.clone()) {
536                    if let Ok(props) = get_effective_properties(&output) {
537                        let mut row_cols = Vec::new();
538                        for prop in &props {
539                            let val = extract_property_value(&m, prop, &paths, output.collections);
540                            let fmt_val = format_value_csv(&val);
541                            row_cols.push(escape_csv_field(&fmt_val));
542                        }
543                        let _ = writeln!(w, "{}", row_cols.join(","));
544                    }
545                }
546            } else if output.ndjson {
547                if output.collections || seen.insert(m.source.path.clone()) {
548                    if let Ok(props) = get_effective_properties(&output) {
549                        if !props.is_empty() {
550                            if props.len() == 1 && props[0] == "path" {
551                                let path_str = if output.collections {
552                                    m.source.path_with_index()
553                                } else {
554                                    m.source.path.to_string_lossy().to_string()
555                                };
556                                if let Ok(line) = serde_json::to_string(&path_str) {
557                                    let _ = w.write_all(line.as_bytes());
558                                    let _ = w.write_all(b"\n");
559                                }
560                            } else {
561                                let mut map = serde_json::Map::new();
562                                for prop in &props {
563                                    let val = extract_property_value(
564                                        &m,
565                                        prop,
566                                        &paths,
567                                        output.collections,
568                                    );
569                                    map.insert(prop.clone(), val);
570                                }
571                                if let Ok(line) =
572                                    serde_json::to_string(&serde_json::Value::Object(map))
573                                {
574                                    let _ = w.write_all(line.as_bytes());
575                                    let _ = w.write_all(b"\n");
576                                }
577                            }
578                        } else if let Ok(line) = serde_json::to_string(&m) {
579                            let _ = w.write_all(line.as_bytes());
580                            let _ = w.write_all(b"\n");
581                        }
582                    }
583                }
584            } else {
585                // Plain format with details support
586                if output.collections || seen.insert(m.source.path.clone()) {
587                    if let Ok(props) = get_effective_properties(&output) {
588                        if !props.is_empty() {
589                            let mut row_vals = Vec::new();
590                            for prop in &props {
591                                let val =
592                                    extract_property_value(&m, prop, &paths, output.collections);
593                                let fmt_val = match val {
594                                    serde_json::Value::String(s) => s,
595                                    other => other.to_string(),
596                                };
597                                row_vals.push(fmt_val);
598                            }
599                            let _ = writeln!(w, "{}", row_vals.join("  "));
600                        } else if output.collections {
601                            let rendered = render_path(&m, use_color, true);
602                            let _ = writeln!(w, "{rendered}");
603                        } else {
604                            let rendered = render_path(&m, use_color, false);
605                            let _ = writeln!(w, "{rendered}");
606                        }
607                    }
608                }
609            }
610        }
611
612        match handle.join() {
613            Ok(result) => result,
614            Err(_) => Err(anyhow!("search thread panicked")),
615        }
616    })
617}
618
619fn run_serve(args: ServeArgs) -> Result<()> {
620    let runtime = Builder::new_multi_thread().enable_all().build()?;
621    runtime.block_on(server::serve(&args.bind))
622}
623
624#[derive(Clone, Debug)]
625struct OutputFormat {
626    json: bool,
627    ndjson: bool,
628    csv: bool,
629    paths: bool,
630    columns: bool,
631    collections: bool,
632    color: ColorChoice,
633    details: Option<String>,
634}
635
636impl OutputFormat {
637    fn from_find(args: &FindArgs) -> Self {
638        Self {
639            json: args.json,
640            ndjson: args.ndjson,
641            csv: args.csv,
642            paths: args.paths_only,
643            columns: args.columns,
644            collections: args.collections,
645            color: args.color,
646            details: args.details.clone(),
647        }
648    }
649
650    fn from_output(args: &OutputArgs) -> Self {
651        Self {
652            json: args.json,
653            ndjson: args.ndjson,
654            csv: args.csv,
655            paths: args.paths,
656            columns: args.columns,
657            collections: args.collections,
658            color: args.color,
659            details: args.details.clone(),
660        }
661    }
662}
663
664fn write_matches(
665    matches: &[TypgFontFaceMatch],
666    format: &OutputFormat,
667    roots: &[PathBuf],
668) -> Result<()> {
669    let stdout = io::stdout();
670    let mut handle = stdout.lock();
671    let use_color = match format.color {
672        ColorChoice::Always => true,
673        ColorChoice::Never => false,
674        ColorChoice::Auto => handle.is_terminal(),
675    };
676
677    if format.paths {
678        write_paths(matches, &mut handle, format.collections)?;
679    } else if format.ndjson {
680        write_ndjson_filtered(matches, &mut handle, format, roots)?;
681    } else if format.json {
682        write_json_pretty_filtered(matches, &mut handle, format, roots)?;
683    } else if format.csv {
684        write_csv(matches, &mut handle, format, roots)?;
685    } else if format.columns {
686        write_columns_filtered(matches, &mut handle, format, use_color, roots)?;
687    } else {
688        write_plain_filtered(matches, &mut handle, format, use_color, roots)?;
689    }
690
691    Ok(())
692}
693
694fn get_relative_path(font_path: &Path, roots: &[PathBuf]) -> String {
695    for root in roots {
696        if let Ok(rel) = font_path.strip_prefix(root) {
697            return rel.to_string_lossy().to_string();
698        }
699        if let (Ok(abs_font), Ok(abs_root)) = (
700            std::fs::canonicalize(font_path),
701            std::fs::canonicalize(root),
702        ) {
703            if let Ok(rel) = abs_font.strip_prefix(&abs_root) {
704                return rel.to_string_lossy().to_string();
705            }
706        }
707    }
708    if let Ok(cwd) = std::env::current_dir() {
709        if let Ok(rel) = font_path.strip_prefix(&cwd) {
710            return rel.to_string_lossy().to_string();
711        }
712        if let (Ok(abs_font), Ok(abs_cwd)) = (
713            std::fs::canonicalize(font_path),
714            std::fs::canonicalize(&cwd),
715        ) {
716            if let Ok(rel) = abs_font.strip_prefix(&abs_cwd) {
717                return rel.to_string_lossy().to_string();
718            }
719        }
720    }
721    font_path
722        .file_name()
723        .map(|n| n.to_string_lossy().to_string())
724        .unwrap_or_else(|| font_path.to_string_lossy().to_string())
725}
726
727fn parse_details_list(details: &str) -> Result<Vec<String>> {
728    let presets = match details.trim() {
729        "0" => vec!["path".to_string()],
730        "1" => vec![
731            "path".to_string(),
732            "fname".to_string(),
733            "sname".to_string(),
734            "fmt".to_string(),
735        ],
736        "2" => vec![
737            "path".to_string(),
738            "fname".to_string(),
739            "sname".to_string(),
740            "fmt".to_string(),
741            "psname".to_string(),
742            "var".to_string(),
743        ],
744        "5" => vec![
745            "path".to_string(),
746            "fname".to_string(),
747            "sname".to_string(),
748            "fmt".to_string(),
749            "psname".to_string(),
750            "var".to_string(),
751            "wt".to_string(),
752            "wd".to_string(),
753            "panf".to_string(),
754        ],
755        "8" => vec![
756            "path".to_string(),
757            "fname".to_string(),
758            "sname".to_string(),
759            "fmt".to_string(),
760            "psname".to_string(),
761            "var".to_string(),
762            "wt".to_string(),
763            "wd".to_string(),
764            "panf".to_string(),
765            "axes".to_string(),
766            "fea_n".to_string(),
767            "scr".to_string(),
768        ],
769        "9" => vec![
770            "path".to_string(),
771            "path_r".to_string(),
772            "fmt".to_string(),
773            "fname".to_string(),
774            "sname".to_string(),
775            "psname".to_string(),
776            "tfname".to_string(),
777            "lfname".to_string(),
778            "tsname".to_string(),
779            "lsname".to_string(),
780            "var".to_string(),
781            "wt".to_string(),
782            "wd".to_string(),
783            "panf".to_string(),
784            "axes".to_string(),
785            "axes_n".to_string(),
786            "fea".to_string(),
787            "fea_n".to_string(),
788            "scr".to_string(),
789            "scr_n".to_string(),
790            "tab".to_string(),
791            "tab_n".to_string(),
792            "crea".to_string(),
793            "lic".to_string(),
794        ],
795        other => {
796            let mut parts = Vec::new();
797            for part in other.split(',') {
798                let trimmed = part.trim();
799                if !trimmed.is_empty() {
800                    let valid_keywords = [
801                        "path", "path_r", "fmt", "fname", "sname", "psname", "tfname", "lfname",
802                        "tsname", "lsname", "var", "wt", "wd", "panf", "axes", "axes_n", "fea",
803                        "fea_n", "scr", "scr_n", "tab", "tab_n", "crea", "lic",
804                    ];
805                    if !valid_keywords.contains(&trimmed) {
806                        return Err(anyhow!("invalid details keyword: {}", trimmed));
807                    }
808                    parts.push(trimmed.to_string());
809                }
810            }
811            if parts.is_empty() {
812                return Err(anyhow!("empty details option"));
813            }
814            parts
815        }
816    };
817    Ok(presets)
818}
819
820fn get_effective_properties(format: &OutputFormat) -> Result<Vec<String>> {
821    if let Some(ref d) = format.details {
822        parse_details_list(d)
823    } else if format.csv {
824        Ok(vec![
825            "path".to_string(),
826            "fname".to_string(),
827            "sname".to_string(),
828            "fmt".to_string(),
829            "var".to_string(),
830            "wt".to_string(),
831            "wd".to_string(),
832        ])
833    } else {
834        Ok(Vec::new())
835    }
836}
837
838fn extract_property_value(
839    m: &TypgFontFaceMatch,
840    prop: &str,
841    roots: &[PathBuf],
842    collections: bool,
843) -> serde_json::Value {
844    match prop {
845        "path" => {
846            if collections {
847                serde_json::Value::String(m.source.path_with_index())
848            } else {
849                serde_json::Value::String(m.source.path.to_string_lossy().to_string())
850            }
851        }
852        "path_r" => {
853            let rel = get_relative_path(&m.source.path, roots);
854            if collections {
855                if let Some(idx) = m.source.ttc_index {
856                    serde_json::Value::String(format!("{}#{}", rel, idx))
857                } else {
858                    serde_json::Value::String(rel)
859                }
860            } else {
861                serde_json::Value::String(rel)
862            }
863        }
864        "fmt" => {
865            let ext = m
866                .source
867                .path
868                .extension()
869                .and_then(|e| e.to_str())
870                .map(|e| e.to_ascii_lowercase())
871                .unwrap_or_else(|| "unknown".to_string());
872            serde_json::Value::String(ext)
873        }
874        "fname" => {
875            let name = m
876                .metadata
877                .tfname
878                .clone()
879                .or_else(|| m.metadata.lfname.clone())
880                .unwrap_or_else(|| m.metadata.names.first().cloned().unwrap_or_default());
881            serde_json::Value::String(name)
882        }
883        "sname" => {
884            let style = m
885                .metadata
886                .tsname
887                .clone()
888                .or_else(|| m.metadata.lsname.clone())
889                .unwrap_or_default();
890            serde_json::Value::String(style)
891        }
892        "psname" => serde_json::Value::String(m.metadata.psname.clone().unwrap_or_default()),
893        "tfname" => serde_json::Value::String(m.metadata.tfname.clone().unwrap_or_default()),
894        "lfname" => serde_json::Value::String(m.metadata.lfname.clone().unwrap_or_default()),
895        "tsname" => serde_json::Value::String(m.metadata.tsname.clone().unwrap_or_default()),
896        "lsname" => serde_json::Value::String(m.metadata.lsname.clone().unwrap_or_default()),
897        "var" => serde_json::Value::Bool(m.metadata.is_variable),
898        "wt" => serde_json::Value::Number(m.metadata.weight_class.unwrap_or_default().into()),
899        "wd" => serde_json::Value::Number(m.metadata.width_class.unwrap_or_default().into()),
900        "panf" => {
901            let fc = m
902                .metadata
903                .family_class
904                .map(|(maj, sub)| format!("{}.{}", maj, sub))
905                .unwrap_or_default();
906            serde_json::Value::String(fc)
907        }
908        "axes" => {
909            let list: Vec<String> = m
910                .metadata
911                .axis_tags
912                .iter()
913                .map(|t| tag_to_string(*t))
914                .collect();
915            serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
916        }
917        "axes_n" => serde_json::Value::Number(m.metadata.axis_tags.len().into()),
918        "fea" => {
919            let list: Vec<String> = m
920                .metadata
921                .feature_tags
922                .iter()
923                .map(|t| tag_to_string(*t))
924                .collect();
925            serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
926        }
927        "fea_n" => serde_json::Value::Number(m.metadata.feature_tags.len().into()),
928        "scr" => {
929            let list: Vec<String> = m
930                .metadata
931                .script_tags
932                .iter()
933                .map(|t| tag_to_string(*t))
934                .collect();
935            serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
936        }
937        "scr_n" => serde_json::Value::Number(m.metadata.script_tags.len().into()),
938        "tab" => {
939            let list: Vec<String> = m
940                .metadata
941                .table_tags
942                .iter()
943                .map(|t| tag_to_string(*t))
944                .collect();
945            serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
946        }
947        "tab_n" => serde_json::Value::Number(m.metadata.table_tags.len().into()),
948        "crea" => serde_json::Value::Array(
949            m.metadata
950                .creator_names
951                .iter()
952                .map(|s| serde_json::Value::String(s.clone()))
953                .collect(),
954        ),
955        "lic" => serde_json::Value::Array(
956            m.metadata
957                .license_names
958                .iter()
959                .map(|s| serde_json::Value::String(s.clone()))
960                .collect(),
961        ),
962        _ => serde_json::Value::Null,
963    }
964}
965
966fn format_value_csv(val: &serde_json::Value) -> String {
967    match val {
968        serde_json::Value::Null => String::new(),
969        serde_json::Value::Bool(b) => b.to_string(),
970        serde_json::Value::Number(n) => n.to_string(),
971        serde_json::Value::String(s) => s.clone(),
972        serde_json::Value::Array(arr) => arr
973            .iter()
974            .map(|item| match item {
975                serde_json::Value::String(s) => s.clone(),
976                other => other.to_string(),
977            })
978            .collect::<Vec<_>>()
979            .join(";"),
980        serde_json::Value::Object(obj) => serde_json::to_string(obj).unwrap_or_default(),
981    }
982}
983
984fn escape_csv_field(field: &str) -> String {
985    if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
986        let escaped = field.replace('"', "\"\"");
987        format!("\"{}\"", escaped)
988    } else {
989        field.to_string()
990    }
991}
992
993fn write_csv(
994    matches: &[TypgFontFaceMatch],
995    mut w: impl Write,
996    format: &OutputFormat,
997    roots: &[PathBuf],
998) -> Result<()> {
999    let props = get_effective_properties(format)?;
1000    let header_cols: Vec<String> = props.iter().map(|p| escape_csv_field(p)).collect();
1001    writeln!(w, "{}", header_cols.join(","))?;
1002
1003    let mut seen = std::collections::HashSet::new();
1004    for m in matches {
1005        if format.collections || seen.insert(m.source.path.clone()) {
1006            let mut row_cols = Vec::new();
1007            for prop in &props {
1008                let val = extract_property_value(m, prop, roots, format.collections);
1009                let fmt_val = format_value_csv(&val);
1010                row_cols.push(escape_csv_field(&fmt_val));
1011            }
1012            writeln!(w, "{}", row_cols.join(","))?;
1013        }
1014    }
1015    Ok(())
1016}
1017
1018fn write_json_pretty_filtered(
1019    matches: &[TypgFontFaceMatch],
1020    mut w: impl Write,
1021    format: &OutputFormat,
1022    roots: &[PathBuf],
1023) -> Result<()> {
1024    let props = get_effective_properties(format)?;
1025    if props.is_empty() {
1026        let json = serde_json::to_string_pretty(matches)?;
1027        w.write_all(json.as_bytes())?;
1028        return Ok(());
1029    }
1030
1031    if props.len() == 1 && props[0] == "path" {
1032        let mut paths = Vec::new();
1033        let mut seen = std::collections::HashSet::new();
1034        for m in matches {
1035            if format.collections || seen.insert(m.source.path.clone()) {
1036                let path_str = if format.collections {
1037                    m.source.path_with_index()
1038                } else {
1039                    m.source.path.to_string_lossy().to_string()
1040                };
1041                paths.push(path_str);
1042            }
1043        }
1044        let json = serde_json::to_string_pretty(&paths)?;
1045        w.write_all(json.as_bytes())?;
1046    } else {
1047        let mut objects = Vec::new();
1048        let mut seen = std::collections::HashSet::new();
1049        for m in matches {
1050            if format.collections || seen.insert(m.source.path.clone()) {
1051                let mut map = serde_json::Map::new();
1052                for prop in &props {
1053                    let val = extract_property_value(m, prop, roots, format.collections);
1054                    map.insert(prop.clone(), val);
1055                }
1056                objects.push(serde_json::Value::Object(map));
1057            }
1058        }
1059        let json = serde_json::to_string_pretty(&objects)?;
1060        w.write_all(json.as_bytes())?;
1061    }
1062    Ok(())
1063}
1064
1065fn write_ndjson_filtered(
1066    matches: &[TypgFontFaceMatch],
1067    mut w: impl Write,
1068    format: &OutputFormat,
1069    roots: &[PathBuf],
1070) -> Result<()> {
1071    let props = get_effective_properties(format)?;
1072    if props.is_empty() {
1073        for m in matches {
1074            let line = serde_json::to_string(m)?;
1075            w.write_all(line.as_bytes())?;
1076            w.write_all(b"\n")?;
1077        }
1078        return Ok(());
1079    }
1080
1081    let mut seen = std::collections::HashSet::new();
1082    for m in matches {
1083        if format.collections || seen.insert(m.source.path.clone()) {
1084            if props.len() == 1 && props[0] == "path" {
1085                let path_str = if format.collections {
1086                    m.source.path_with_index()
1087                } else {
1088                    m.source.path.to_string_lossy().to_string()
1089                };
1090                let line = serde_json::to_string(&path_str)?;
1091                w.write_all(line.as_bytes())?;
1092                w.write_all(b"\n")?;
1093            } else {
1094                let mut map = serde_json::Map::new();
1095                for prop in &props {
1096                    let val = extract_property_value(m, prop, roots, format.collections);
1097                    map.insert(prop.clone(), val);
1098                }
1099                let line = serde_json::to_string(&serde_json::Value::Object(map))?;
1100                w.write_all(line.as_bytes())?;
1101                w.write_all(b"\n")?;
1102            }
1103        }
1104    }
1105    Ok(())
1106}
1107
1108fn write_columns_filtered(
1109    matches: &[TypgFontFaceMatch],
1110    mut w: impl Write,
1111    format: &OutputFormat,
1112    color: bool,
1113    roots: &[PathBuf],
1114) -> Result<()> {
1115    let props = get_effective_properties(format)?;
1116    if props.is_empty() {
1117        return write_columns(matches, w, color, format.collections);
1118    }
1119
1120    let mut seen = std::collections::HashSet::new();
1121    let mut rows = Vec::new();
1122    for m in matches {
1123        if format.collections || seen.insert(m.source.path.clone()) {
1124            let mut row = Vec::new();
1125            for prop in &props {
1126                let val = extract_property_value(m, prop, roots, format.collections);
1127                let str_val = match val {
1128                    serde_json::Value::Null => String::new(),
1129                    serde_json::Value::String(s) => s,
1130                    serde_json::Value::Array(arr) => arr
1131                        .iter()
1132                        .map(|item| match item {
1133                            serde_json::Value::String(s) => s.clone(),
1134                            other => other.to_string(),
1135                        })
1136                        .collect::<Vec<_>>()
1137                        .join(","),
1138                    other => other.to_string(),
1139                };
1140                row.push(str_val);
1141            }
1142            rows.push(row);
1143        }
1144    }
1145
1146    if rows.is_empty() {
1147        return Ok(());
1148    }
1149
1150    let num_cols = props.len();
1151    let mut col_widths = vec![0; num_cols];
1152    for row in &rows {
1153        for (i, col) in row.iter().enumerate() {
1154            col_widths[i] = col_widths[i].max(col.len());
1155        }
1156    }
1157
1158    for row in rows {
1159        let mut cols = Vec::new();
1160        for (i, col) in row.iter().enumerate() {
1161            let width = col_widths[i];
1162            let padded = format!("{:<width$}", col);
1163            let rendered_col = if i == 0 {
1164                apply_color(&padded, color, AnsiColor::Cyan)
1165            } else if i == 1 {
1166                apply_color(&padded, color, AnsiColor::Yellow)
1167            } else {
1168                apply_color(&padded, color, AnsiColor::Green)
1169            };
1170            cols.push(rendered_col);
1171        }
1172        writeln!(w, "{}", cols.join("  "))?;
1173    }
1174
1175    Ok(())
1176}
1177
1178fn write_plain_filtered(
1179    matches: &[TypgFontFaceMatch],
1180    mut w: impl Write,
1181    format: &OutputFormat,
1182    color: bool,
1183    roots: &[PathBuf],
1184) -> Result<()> {
1185    let props = get_effective_properties(format)?;
1186    if props.is_empty() {
1187        return write_plain(matches, w, color, format.collections);
1188    }
1189
1190    let mut seen = std::collections::HashSet::new();
1191    for m in matches {
1192        if format.collections || seen.insert(m.source.path.clone()) {
1193            let mut row = Vec::new();
1194            for prop in &props {
1195                let val = extract_property_value(m, prop, roots, format.collections);
1196                let str_val = match val {
1197                    serde_json::Value::Null => String::new(),
1198                    serde_json::Value::String(s) => s,
1199                    serde_json::Value::Array(arr) => arr
1200                        .iter()
1201                        .map(|item| match item {
1202                            serde_json::Value::String(s) => s.clone(),
1203                            other => other.to_string(),
1204                        })
1205                        .collect::<Vec<_>>()
1206                        .join(","),
1207                    other => other.to_string(),
1208                };
1209                row.push(str_val);
1210            }
1211            writeln!(w, "{}", row.join("  "))?;
1212        }
1213    }
1214    Ok(())
1215}
1216
1217/// Translate `FindArgs` into a `Query`.
1218///
1219/// Thin adapter — unpacks the struct fields and delegates to the shared
1220/// `build_query_from_parts` so `cache find` can reuse the same logic.
1221fn build_query(args: &FindArgs) -> Result<Query> {
1222    build_query_from_parts(
1223        &args.axes,
1224        &args.features,
1225        &args.scripts,
1226        &args.tables,
1227        &args.name_patterns,
1228        &args.creator_patterns,
1229        &args.license_patterns,
1230        &args.codepoints,
1231        &args.text,
1232        args.variable,
1233        &args.weight,
1234        &args.width,
1235        &args.family_class,
1236    )
1237}
1238
1239/// Shared query builder used by both `find` and `cache find`.
1240///
1241/// Parses every raw string argument into typed values, then chains the
1242/// `Query` builder methods. The `text` argument is a convenience shortcut:
1243/// its characters are appended to the explicit `codepoints` list so
1244/// `--text "Ñoño"` is equivalent to listing every codepoint in that string.
1245///
1246/// All criteria are AND-combined — the more you add, the narrower the match.
1247#[allow(clippy::too_many_arguments)]
1248fn build_query_from_parts(
1249    axes: &[String],
1250    features: &[String],
1251    scripts: &[String],
1252    tables: &[String],
1253    name_patterns: &[String],
1254    creator_patterns: &[String],
1255    license_patterns: &[String],
1256    codepoints: &[String],
1257    text: &Option<String>,
1258    variable: bool,
1259    weight: &Option<String>,
1260    width: &Option<String>,
1261    family_class: &Option<String>,
1262) -> Result<Query> {
1263    let axes = parse_tag_list(axes)?;
1264    let features = parse_tag_list(features)?;
1265    let scripts = parse_tag_list(scripts)?;
1266    let tables = parse_tag_list(tables)?;
1267    let name_patterns = compile_patterns(name_patterns)?;
1268    let creator_patterns = compile_patterns(creator_patterns)?;
1269    let license_patterns = compile_patterns(license_patterns)?;
1270    let mut codepoints = parse_codepoints(codepoints)?;
1271    let weight_range = parse_optional_range(weight)?;
1272    let width_range = parse_optional_range(width)?;
1273    let family_class = parse_optional_family_class(family_class)?;
1274
1275    if let Some(text) = text {
1276        codepoints.extend(text.chars());
1277    }
1278
1279    dedup_chars(&mut codepoints);
1280
1281    Ok(Query::new()
1282        .with_axes(axes)
1283        .with_features(features)
1284        .with_scripts(scripts)
1285        .with_tables(tables)
1286        .with_name_patterns(name_patterns)
1287        .with_creator_patterns(creator_patterns)
1288        .with_license_patterns(license_patterns)
1289        .with_codepoints(codepoints)
1290        .require_variable(variable)
1291        .with_weight_range(weight_range)
1292        .with_width_range(width_range)
1293        .with_family_class(family_class))
1294}
1295
1296fn dedup_chars(cps: &mut Vec<char>) {
1297    cps.sort();
1298    cps.dedup();
1299}
1300
1301fn compile_patterns(patterns: &[String]) -> Result<Vec<Regex>> {
1302    patterns
1303        .iter()
1304        .map(|p| Regex::new(p).with_context(|| format!("invalid regex: {p}")))
1305        .collect()
1306}
1307
1308fn parse_codepoints(raw: &[String]) -> Result<Vec<char>> {
1309    let mut cps = Vec::new();
1310    for chunk in raw {
1311        cps.extend(parse_codepoint_list(chunk)?);
1312    }
1313    Ok(cps)
1314}
1315
1316fn parse_optional_range(raw: &Option<String>) -> Result<Option<RangeInclusive<u16>>> {
1317    match raw {
1318        Some(value) => Ok(Some(parse_u16_range(value)?)),
1319        None => Ok(None),
1320    }
1321}
1322
1323fn parse_optional_family_class(raw: &Option<String>) -> Result<Option<FamilyClassFilter>> {
1324    match raw {
1325        Some(value) => Ok(Some(parse_family_class(value)?)),
1326        None => Ok(None),
1327    }
1328}
1329
1330/// Collect the final list of paths to search, merging all input sources.
1331///
1332/// Three sources combine in order:
1333/// 1. `--stdin-paths` flag: read one path per line from stdin before any
1334///    positional args. Useful when piping from `find` or `fd`.
1335/// 2. Positional paths: each is appended as-is, except the special value `-`
1336///    which triggers another stdin read (matches Unix convention).
1337/// 3. `--system-fonts` flag: appends OS-default font directories. On macOS
1338///    that's `/System/Library/Fonts`, `/Library/Fonts`, and `~/Library/Fonts`.
1339///    On Linux it's `/usr/share/fonts` and `~/.local/share/fonts`.
1340///    On Windows it's `%SYSTEMROOT%\Fonts` and the per-user fonts folder.
1341///    Override with `TYPOG_SYSTEM_FONT_DIRS` (colon-separated on Unix, semicolon on Windows).
1342fn gather_paths(
1343    raw_paths: &[PathBuf],
1344    read_stdin: bool,
1345    include_system: bool,
1346    mut stdin: impl BufRead,
1347) -> Result<Vec<PathBuf>> {
1348    let mut paths = Vec::new();
1349
1350    if read_stdin {
1351        paths.extend(read_paths_from(&mut stdin)?);
1352    }
1353
1354    for path in raw_paths {
1355        if path == Path::new("-") {
1356            paths.extend(read_paths_from(&mut stdin)?);
1357        } else {
1358            paths.push(path.clone());
1359        }
1360    }
1361
1362    if include_system {
1363        paths.extend(system_font_roots()?);
1364    }
1365
1366    if paths.is_empty() {
1367        return Err(anyhow!("no search paths provided"));
1368    }
1369
1370    Ok(paths)
1371}
1372
1373fn read_paths_from(reader: &mut impl BufRead) -> Result<Vec<PathBuf>> {
1374    let mut buf = String::new();
1375    let mut paths = Vec::new();
1376
1377    loop {
1378        buf.clear();
1379        let read = reader.read_line(&mut buf)?;
1380        if read == 0 {
1381            break;
1382        }
1383
1384        let trimmed = buf.trim();
1385        if !trimmed.is_empty() {
1386            paths.push(PathBuf::from(trimmed));
1387        }
1388    }
1389
1390    Ok(paths)
1391}
1392
1393/// Return the OS-default directories where fonts are installed.
1394///
1395/// `TYPOG_SYSTEM_FONT_DIRS` overrides the defaults entirely (colon-separated
1396/// on Unix, semicolon accepted on both platforms). Non-existent paths are
1397/// filtered out silently — a freshly installed OS might not have a per-user
1398/// fonts folder yet. Returns an error only when every candidate is missing.
1399///
1400/// Platform defaults (compiled in, not checked at startup):
1401/// - macOS: `/System/Library/Fonts`, `/Library/Fonts`, `~/Library/Fonts`
1402/// - Linux: `/usr/share/fonts`, `/usr/local/share/fonts`, `~/.local/share/fonts`
1403/// - Windows: `%SYSTEMROOT%\Fonts`, `%LOCALAPPDATA%\Microsoft\Windows\Fonts`
1404fn system_font_roots() -> Result<Vec<PathBuf>> {
1405    if let Ok(raw) = env::var("TYPOG_SYSTEM_FONT_DIRS") {
1406        let mut overrides: Vec<PathBuf> = raw
1407            .split([':', ';'])
1408            .filter(|s| !s.is_empty())
1409            .map(PathBuf::from)
1410            .filter(|p| p.exists())
1411            .collect();
1412
1413        overrides.sort();
1414        overrides.dedup();
1415
1416        return if overrides.is_empty() {
1417            Err(anyhow!("TYPOG_SYSTEM_FONT_DIRS is set but no paths exist"))
1418        } else {
1419            Ok(overrides)
1420        };
1421    }
1422
1423    let mut candidates: Vec<PathBuf> = Vec::new();
1424
1425    #[cfg(target_os = "macos")]
1426    {
1427        candidates.push(PathBuf::from("/System/Library/Fonts"));
1428        candidates.push(PathBuf::from("/Library/Fonts"));
1429        if let Some(home) = env::var_os("HOME") {
1430            candidates.push(PathBuf::from(home).join("Library/Fonts"));
1431        }
1432    }
1433
1434    #[cfg(target_os = "linux")]
1435    {
1436        candidates.push(PathBuf::from("/usr/share/fonts"));
1437        candidates.push(PathBuf::from("/usr/local/share/fonts"));
1438        if let Some(home) = env::var_os("HOME") {
1439            candidates.push(PathBuf::from(home).join(".local/share/fonts"));
1440        }
1441    }
1442
1443    #[cfg(target_os = "windows")]
1444    {
1445        if let Some(system_root) = env::var_os("SYSTEMROOT") {
1446            candidates.push(PathBuf::from(system_root).join("Fonts"));
1447        }
1448        if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1449            candidates.push(PathBuf::from(local_appdata).join("Microsoft/Windows/Fonts"));
1450        }
1451    }
1452
1453    candidates.retain(|p| p.exists());
1454    candidates.sort();
1455    candidates.dedup();
1456
1457    if candidates.is_empty() {
1458        return Err(anyhow!(
1459            "no system font directories found for this platform"
1460        ));
1461    }
1462
1463    Ok(candidates)
1464}
1465
1466/// Write matches as plain text: one path (or path#index) per line.
1467///
1468/// Without `--collections`, each *file* path appears at most once — faces
1469/// from the same TTC/OTC are deduplicated down to the container path.
1470/// With `--collections`, every face gets its own line with a `#N` suffix,
1471/// allowing callers to address individual faces inside a collection file.
1472fn write_plain(
1473    matches: &[TypgFontFaceMatch],
1474    mut w: impl Write,
1475    color: bool,
1476    collections: bool,
1477) -> Result<()> {
1478    if collections {
1479        for item in matches {
1480            let rendered = render_path(item, color, true);
1481            writeln!(w, "{rendered}")?;
1482        }
1483    } else {
1484        let mut seen = std::collections::HashSet::new();
1485        for item in matches {
1486            if seen.insert(item.source.path.clone()) {
1487                let rendered = render_path(item, color, false);
1488                writeln!(w, "{rendered}")?;
1489            }
1490        }
1491    }
1492    Ok(())
1493}
1494
1495/// Write bare file paths, one per line, no color or metadata.
1496///
1497/// This is the `--paths` output mode, designed for piping. The output is
1498/// stable: each path appears exactly once (unless `--collections` is set,
1499/// in which case TTC faces appear as `path#0`, `path#1`, etc.).
1500/// Downstream tools like `typf`, `fontlift`, or shell loops can consume
1501/// this directly.
1502fn write_paths(matches: &[TypgFontFaceMatch], mut w: impl Write, collections: bool) -> Result<()> {
1503    if collections {
1504        for item in matches {
1505            writeln!(w, "{}", item.source.path_with_index())?;
1506        }
1507    } else {
1508        let mut seen = std::collections::HashSet::new();
1509        for item in matches {
1510            if seen.insert(item.source.path.clone()) {
1511                writeln!(w, "{}", item.source.path.display())?;
1512            }
1513        }
1514    }
1515    Ok(())
1516}
1517
1518/// Write a human-readable aligned column layout: path | name | tag counts.
1519///
1520/// Requires all matches in memory first (to measure the longest path and name
1521/// for alignment). Each row shows:
1522///   - File path (or path#index with `--collections`), cyan
1523///   - First name string from the font's `name` table, yellow
1524///   - Summary counts: axes, features, scripts, tables, plus "var" if variable, green
1525///
1526/// Column widths are clamped to 120 (path) and 80 (name) to stay readable
1527/// on standard terminal widths even with very long paths.
1528fn write_columns(
1529    matches: &[TypgFontFaceMatch],
1530    mut w: impl Write,
1531    color: bool,
1532    collections: bool,
1533) -> Result<()> {
1534    let mut rows: Vec<(String, String, String)> = matches
1535        .iter()
1536        .map(|m| {
1537            let path = if collections {
1538                m.source.path_with_index()
1539            } else {
1540                m.source.path.display().to_string()
1541            };
1542            let name = m
1543                .metadata
1544                .names
1545                .first()
1546                .cloned()
1547                .unwrap_or_else(|| "(unnamed)".to_string());
1548
1549            let tags = format!(
1550                "axes:{:<2} feats:{:<2} scripts:{:<2} tables:{:<2}{}",
1551                m.metadata.axis_tags.len(),
1552                m.metadata.feature_tags.len(),
1553                m.metadata.script_tags.len(),
1554                m.metadata.table_tags.len(),
1555                if m.metadata.is_variable { " var" } else { "" },
1556            );
1557
1558            (path, name, tags)
1559        })
1560        .collect();
1561
1562    let path_width = rows
1563        .iter()
1564        .map(|r| r.0.len())
1565        .max()
1566        .unwrap_or(0)
1567        .clamp(0, 120);
1568    let name_width = rows
1569        .iter()
1570        .map(|r| r.1.len())
1571        .max()
1572        .unwrap_or(0)
1573        .clamp(0, 80);
1574
1575    for (path, name, tags) in rows.drain(..) {
1576        let padded_path = format!("{:<path_width$}", path);
1577        let padded_name = format!("{:<name_width$}", name);
1578        let rendered_path = apply_color(&padded_path, color, AnsiColor::Cyan);
1579        let rendered_name = apply_color(&padded_name, color, AnsiColor::Yellow);
1580        let rendered_tags = apply_color(&tags, color, AnsiColor::Green);
1581
1582        writeln!(w, "{rendered_path}  {rendered_name}  {rendered_tags}")?;
1583    }
1584
1585    Ok(())
1586}
1587
1588#[derive(Copy, Clone)]
1589enum AnsiColor {
1590    Cyan,
1591    Yellow,
1592    Green,
1593}
1594
1595fn apply_color(text: &str, color: bool, code: AnsiColor) -> String {
1596    if !color {
1597        return text.to_string();
1598    }
1599
1600    let code_str = match code {
1601        AnsiColor::Cyan => "36",
1602        AnsiColor::Yellow => "33",
1603        AnsiColor::Green => "32",
1604    };
1605
1606    format!("\u{1b}[{}m{}\u{1b}[0m", code_str, text)
1607}
1608
1609fn render_path(item: &TypgFontFaceMatch, color: bool, collections: bool) -> String {
1610    let rendered = if collections {
1611        item.source.path_with_index()
1612    } else {
1613        item.source.path.display().to_string()
1614    };
1615    apply_color(&rendered, color, AnsiColor::Cyan)
1616}
1617
1618fn run_cache_add(args: CacheAddArgs, quiet: bool) -> Result<()> {
1619    if matches!(args.jobs, Some(0)) {
1620        return Err(anyhow!("--jobs must be at least 1"));
1621    }
1622
1623    #[cfg(feature = "hpindex")]
1624    if args.use_index {
1625        return run_cache_add_index(args, quiet);
1626    }
1627
1628    #[cfg(not(feature = "hpindex"))]
1629    if args.use_index {
1630        return Err(anyhow!(
1631            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1632        ));
1633    }
1634
1635    let stdin = io::stdin();
1636    let paths = gather_paths(
1637        &args.paths,
1638        args.stdin_paths,
1639        args.system_fonts,
1640        stdin.lock(),
1641    )?;
1642
1643    let opts = SearchOptions {
1644        follow_symlinks: args.follow_symlinks,
1645        jobs: args.jobs,
1646    };
1647    let additions = search(&paths, &Query::new(), &opts)?;
1648
1649    let cache_path = resolve_cache_path(&args.cache_path)?;
1650    let existing = if cache_path.exists() {
1651        load_cache(&cache_path)?
1652    } else {
1653        Vec::new()
1654    };
1655
1656    let merged = merge_entries(existing, additions);
1657    write_cache(&cache_path, &merged)?;
1658
1659    if !quiet {
1660        eprintln!(
1661            "cached {} font faces at {}",
1662            merged.len(),
1663            cache_path.display()
1664        );
1665    }
1666    Ok(())
1667}
1668
1669fn run_cache_list(args: CacheListArgs) -> Result<()> {
1670    #[cfg(feature = "hpindex")]
1671    if args.use_index {
1672        return run_cache_list_index(args);
1673    }
1674
1675    #[cfg(not(feature = "hpindex"))]
1676    if args.use_index {
1677        return Err(anyhow!(
1678            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1679        ));
1680    }
1681
1682    let cache_path = resolve_cache_path(&args.cache_path)?;
1683    let entries = load_cache(&cache_path)?;
1684    let output = OutputFormat::from_output(&args.output);
1685    write_matches(&entries, &output, &[])
1686}
1687
1688fn run_cache_find(args: CacheFindArgs) -> Result<()> {
1689    #[cfg(feature = "hpindex")]
1690    if args.use_index {
1691        return run_cache_find_index(args);
1692    }
1693
1694    #[cfg(not(feature = "hpindex"))]
1695    if args.use_index {
1696        return Err(anyhow!(
1697            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1698        ));
1699    }
1700
1701    let cache_path = resolve_cache_path(&args.cache_path)?;
1702    let entries = load_cache(&cache_path)?;
1703    let query = build_query_from_parts(
1704        &args.axes,
1705        &args.features,
1706        &args.scripts,
1707        &args.tables,
1708        &args.name_patterns,
1709        &args.creator_patterns,
1710        &args.license_patterns,
1711        &args.codepoints,
1712        &args.text,
1713        args.variable,
1714        &args.weight,
1715        &args.width,
1716        &args.family_class,
1717    )?;
1718
1719    let matches = filter_cached(&entries, &query);
1720
1721    if args.count_only {
1722        println!("{}", matches.len());
1723        return Ok(());
1724    }
1725
1726    let output = OutputFormat::from_output(&args.output);
1727    write_matches(&matches, &output, &[])
1728}
1729
1730fn run_cache_clean(args: CacheCleanArgs, quiet: bool) -> Result<()> {
1731    #[cfg(feature = "hpindex")]
1732    if args.use_index {
1733        return run_cache_clean_index(args, quiet);
1734    }
1735
1736    #[cfg(not(feature = "hpindex"))]
1737    if args.use_index {
1738        return Err(anyhow!(
1739            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1740        ));
1741    }
1742
1743    let cache_path = resolve_cache_path(&args.cache_path)?;
1744    let entries = load_cache(&cache_path)?;
1745    let before = entries.len();
1746    let pruned = prune_missing(entries);
1747    let after = pruned.len();
1748
1749    write_cache(&cache_path, &pruned)?;
1750    if !quiet {
1751        eprintln!(
1752            "removed {} missing entries ({} → {})",
1753            before.saturating_sub(after),
1754            before,
1755            after
1756        );
1757    }
1758    Ok(())
1759}
1760
1761fn run_cache_info(args: CacheInfoArgs) -> Result<()> {
1762    #[cfg(feature = "hpindex")]
1763    if args.use_index {
1764        return run_cache_info_index(args);
1765    }
1766
1767    #[cfg(not(feature = "hpindex"))]
1768    if args.use_index {
1769        return Err(anyhow!(
1770            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1771        ));
1772    }
1773
1774    let cache_path = resolve_cache_path(&args.cache_path)?;
1775
1776    if !cache_path.exists() {
1777        if args.json {
1778            println!(r#"{{"exists":false,"path":"{}"}}"#, cache_path.display());
1779        } else {
1780            println!("Cache does not exist at {}", cache_path.display());
1781        }
1782        return Ok(());
1783    }
1784
1785    let entries = load_cache(&cache_path)?;
1786    let file_meta = fs::metadata(&cache_path)?;
1787    let size_bytes = file_meta.len();
1788
1789    if args.json {
1790        let info = serde_json::json!({
1791            "exists": true,
1792            "path": cache_path.display().to_string(),
1793            "type": "json",
1794            "entries": entries.len(),
1795            "size_bytes": size_bytes,
1796        });
1797        println!("{}", serde_json::to_string_pretty(&info)?);
1798    } else {
1799        println!("Cache: {}", cache_path.display());
1800        println!("Type:  JSON");
1801        println!("Fonts: {}", entries.len());
1802        println!("Size:  {} bytes", size_bytes);
1803    }
1804
1805    Ok(())
1806}
1807
1808fn resolve_cache_path(custom: &Option<PathBuf>) -> Result<PathBuf> {
1809    if let Some(path) = custom {
1810        return Ok(path.clone());
1811    }
1812
1813    if let Ok(env_override) = env::var("TYPOG_CACHE_PATH") {
1814        return Ok(PathBuf::from(env_override));
1815    }
1816
1817    #[cfg(target_os = "windows")]
1818    {
1819        if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1820            return Ok(PathBuf::from(local_appdata).join("typg").join("cache.json"));
1821        }
1822        if let Some(home) = env::var_os("HOME") {
1823            return Ok(PathBuf::from(home).join("AppData/Local/typg/cache.json"));
1824        }
1825    }
1826
1827    #[cfg(not(target_os = "windows"))]
1828    {
1829        if let Some(xdg) = env::var_os("XDG_CACHE_HOME") {
1830            return Ok(PathBuf::from(xdg).join("typg").join("cache.json"));
1831        }
1832        if let Some(home) = env::var_os("HOME") {
1833            return Ok(PathBuf::from(home)
1834                .join(".cache")
1835                .join("typg")
1836                .join("cache.json"));
1837        }
1838    }
1839
1840    Err(anyhow!(
1841        "--cache-path is required because no cache directory could be detected"
1842    ))
1843}
1844
1845/// Resolve the index directory path.
1846#[cfg_attr(not(feature = "hpindex"), allow(dead_code))]
1847fn resolve_index_path(custom: &Option<PathBuf>) -> Result<PathBuf> {
1848    if let Some(path) = custom {
1849        return Ok(path.clone());
1850    }
1851
1852    if let Ok(env_override) = env::var("TYPOG_INDEX_PATH") {
1853        return Ok(PathBuf::from(env_override));
1854    }
1855
1856    #[cfg(target_os = "windows")]
1857    {
1858        if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1859            return Ok(PathBuf::from(local_appdata).join("typg").join("index"));
1860        }
1861        if let Some(home) = env::var_os("HOME") {
1862            return Ok(PathBuf::from(home).join("AppData/Local/typg/index"));
1863        }
1864    }
1865
1866    #[cfg(not(target_os = "windows"))]
1867    {
1868        if let Some(xdg) = env::var_os("XDG_CACHE_HOME") {
1869            return Ok(PathBuf::from(xdg).join("typg").join("index"));
1870        }
1871        if let Some(home) = env::var_os("HOME") {
1872            return Ok(PathBuf::from(home)
1873                .join(".cache")
1874                .join("typg")
1875                .join("index"));
1876        }
1877    }
1878
1879    Err(anyhow!(
1880        "--index-path is required because no cache directory could be detected"
1881    ))
1882}
1883
1884/// Load cached font entries from disk. Tries JSON array first, falls back to NDJSON.
1885fn load_cache(path: &Path) -> Result<Vec<TypgFontFaceMatch>> {
1886    let file = File::open(path).with_context(|| format!("opening cache {}", path.display()))?;
1887    let reader = BufReader::new(file);
1888
1889    match serde_json::from_reader(reader) {
1890        Ok(entries) => Ok(entries),
1891        Err(_) => {
1892            // Fall back to NDJSON parsing for forward compatibility
1893            let file =
1894                File::open(path).with_context(|| format!("re-opening cache {}", path.display()))?;
1895            let reader = BufReader::new(file);
1896            let stream = Deserializer::from_reader(reader).into_iter::<TypgFontFaceMatch>();
1897            let mut entries = Vec::new();
1898            for item in stream {
1899                entries.push(item?);
1900            }
1901            Ok(entries)
1902        }
1903    }
1904}
1905
1906/// Write font entries to the cache file as pretty-printed JSON.
1907fn write_cache(path: &Path, entries: &[TypgFontFaceMatch]) -> Result<()> {
1908    if let Some(parent) = path.parent() {
1909        fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
1910    }
1911
1912    let file = File::create(path).with_context(|| format!("creating cache {}", path.display()))?;
1913    let mut writer = BufWriter::new(file);
1914    serde_json::to_writer_pretty(&mut writer, entries)
1915        .with_context(|| format!("writing cache {}", path.display()))?;
1916    writer.flush()?;
1917    Ok(())
1918}
1919
1920fn merge_entries(
1921    existing: Vec<TypgFontFaceMatch>,
1922    additions: Vec<TypgFontFaceMatch>,
1923) -> Vec<TypgFontFaceMatch> {
1924    let mut map: HashMap<(PathBuf, Option<u32>), TypgFontFaceMatch> = HashMap::new();
1925
1926    for entry in existing.into_iter().chain(additions.into_iter()) {
1927        map.insert(cache_key(&entry), entry);
1928    }
1929
1930    let mut merged: Vec<TypgFontFaceMatch> = map.into_values().collect();
1931    sort_entries(&mut merged);
1932    merged
1933}
1934
1935fn prune_missing(entries: Vec<TypgFontFaceMatch>) -> Vec<TypgFontFaceMatch> {
1936    let mut pruned: Vec<TypgFontFaceMatch> = entries
1937        .into_iter()
1938        .filter(|entry| entry.source.path.exists())
1939        .collect();
1940    sort_entries(&mut pruned);
1941    pruned
1942}
1943
1944fn sort_entries(entries: &mut [TypgFontFaceMatch]) {
1945    entries.sort_by(|a, b| {
1946        a.source
1947            .path
1948            .cmp(&b.source.path)
1949            .then_with(|| a.source.ttc_index.cmp(&b.source.ttc_index))
1950    });
1951}
1952
1953fn cache_key(entry: &TypgFontFaceMatch) -> (PathBuf, Option<u32>) {
1954    (entry.source.path.clone(), entry.source.ttc_index)
1955}
1956
1957// ============================================================================
1958// High-performance index implementations (LMDB + Roaring Bitmaps)
1959// ============================================================================
1960
1961#[cfg(feature = "hpindex")]
1962fn run_cache_add_index(args: CacheAddArgs, quiet: bool) -> Result<()> {
1963    use std::time::SystemTime;
1964
1965    let stdin = io::stdin();
1966    let paths = gather_paths(
1967        &args.paths,
1968        args.stdin_paths,
1969        args.system_fonts,
1970        stdin.lock(),
1971    )?;
1972
1973    let index_path = resolve_index_path(&args.index_path)?;
1974    let index = FontIndex::open(&index_path)?;
1975
1976    // Use the existing search pipeline to discover and extract metadata.
1977    let opts = SearchOptions {
1978        follow_symlinks: args.follow_symlinks,
1979        jobs: args.jobs,
1980    };
1981    let additions = search(&paths, &Query::new(), &opts)?;
1982
1983    // Write to index in a single transaction.
1984    let mut writer = index.writer()?;
1985    let mut added = 0usize;
1986    let mut skipped = 0usize;
1987
1988    for entry in additions {
1989        // Get file mtime for incremental update detection.
1990        let mtime = entry
1991            .source
1992            .path
1993            .metadata()
1994            .and_then(|m| m.modified())
1995            .unwrap_or(SystemTime::UNIX_EPOCH);
1996
1997        // Check if update is needed.
1998        if !writer.needs_update(&entry.source.path, mtime)? {
1999            skipped += 1;
2000            continue;
2001        }
2002
2003        writer.add_font(
2004            &entry.source.path,
2005            entry.source.ttc_index,
2006            mtime,
2007            &entry.metadata,
2008        )?;
2009        added += 1;
2010    }
2011
2012    writer.commit()?;
2013
2014    if !quiet {
2015        let total = index.count()?;
2016        eprintln!(
2017            "indexed {} font faces at {} (added: {}, skipped: {})",
2018            total,
2019            index_path.display(),
2020            added,
2021            skipped
2022        );
2023    }
2024
2025    Ok(())
2026}
2027
2028#[cfg(feature = "hpindex")]
2029fn run_cache_list_index(args: CacheListArgs) -> Result<()> {
2030    let index_path = resolve_index_path(&args.index_path)?;
2031    let index = FontIndex::open(&index_path)?;
2032    let reader = index.reader()?;
2033    let entries = reader.list_all()?;
2034    let output = OutputFormat::from_output(&args.output);
2035    write_matches(&entries, &output, &[])
2036}
2037
2038#[cfg(feature = "hpindex")]
2039fn run_cache_find_index(args: CacheFindArgs) -> Result<()> {
2040    let index_path = resolve_index_path(&args.index_path)?;
2041    let index = FontIndex::open(&index_path)?;
2042
2043    let query = build_query_from_parts(
2044        &args.axes,
2045        &args.features,
2046        &args.scripts,
2047        &args.tables,
2048        &args.name_patterns,
2049        &args.creator_patterns,
2050        &args.license_patterns,
2051        &args.codepoints,
2052        &args.text,
2053        args.variable,
2054        &args.weight,
2055        &args.width,
2056        &args.family_class,
2057    )?;
2058
2059    let reader = index.reader()?;
2060    let matches = reader.find(&query)?;
2061
2062    if args.count_only {
2063        println!("{}", matches.len());
2064        return Ok(());
2065    }
2066
2067    let output = OutputFormat::from_output(&args.output);
2068    write_matches(&matches, &output, &[])
2069}
2070
2071#[cfg(feature = "hpindex")]
2072fn run_cache_clean_index(args: CacheCleanArgs, quiet: bool) -> Result<()> {
2073    let index_path = resolve_index_path(&args.index_path)?;
2074    let index = FontIndex::open(&index_path)?;
2075
2076    let mut writer = index.writer()?;
2077    let (before, after) = writer.prune_missing()?;
2078    writer.commit()?;
2079
2080    if !quiet {
2081        eprintln!(
2082            "removed {} missing entries ({} → {})",
2083            before.saturating_sub(after),
2084            before,
2085            after
2086        );
2087    }
2088    Ok(())
2089}
2090
2091#[cfg(feature = "hpindex")]
2092fn run_cache_info_index(args: CacheInfoArgs) -> Result<()> {
2093    let index_path = resolve_index_path(&args.index_path)?;
2094
2095    if !index_path.exists() {
2096        if args.json {
2097            println!(r#"{{"exists":false,"path":"{}"}}"#, index_path.display());
2098        } else {
2099            println!("Index does not exist at {}", index_path.display());
2100        }
2101        return Ok(());
2102    }
2103
2104    let index = FontIndex::open(&index_path)?;
2105    let count = index.count()?;
2106
2107    // Calculate total directory size (non-recursive, LMDB is flat).
2108    let size_bytes: u64 = fs::read_dir(&index_path)?
2109        .filter_map(|e| e.ok())
2110        .filter_map(|e| e.metadata().ok())
2111        .filter(|m| m.is_file())
2112        .map(|m| m.len())
2113        .sum();
2114
2115    if args.json {
2116        let info = serde_json::json!({
2117            "exists": true,
2118            "path": index_path.display().to_string(),
2119            "type": "lmdb",
2120            "entries": count,
2121            "size_bytes": size_bytes,
2122        });
2123        println!("{}", serde_json::to_string_pretty(&info)?);
2124    } else {
2125        println!("Index: {}", index_path.display());
2126        println!("Type:  LMDB");
2127        println!("Fonts: {}", count);
2128        println!("Size:  {} bytes", size_bytes);
2129    }
2130
2131    Ok(())
2132}
2133
2134#[cfg(test)]
2135mod tests;