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 properties or preset integer 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 properties or preset integer 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            "is_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            "is_var".to_string(),
751            "weight".to_string(),
752            "width".to_string(),
753            "family_class".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            "is_var".to_string(),
762            "weight".to_string(),
763            "width".to_string(),
764            "family_class".to_string(),
765            "axes".to_string(),
766            "fea_n".to_string(),
767            "scripts".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            "is_var".to_string(),
781            "weight".to_string(),
782            "width".to_string(),
783            "family_class".to_string(),
784            "axes".to_string(),
785            "axes_n".to_string(),
786            "fea".to_string(),
787            "fea_n".to_string(),
788            "scripts".to_string(),
789            "scripts_n".to_string(),
790            "tables".to_string(),
791            "tables_n".to_string(),
792            "creator_names".to_string(),
793            "license_names".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 canon = match trimmed {
801                        "variable" => "is_var",
802                        other => other,
803                    };
804                    let valid_keywords = [
805                        "path",
806                        "path_r",
807                        "fmt",
808                        "fname",
809                        "sname",
810                        "psname",
811                        "tfname",
812                        "lfname",
813                        "tsname",
814                        "lsname",
815                        "is_var",
816                        "weight",
817                        "width",
818                        "family_class",
819                        "axes",
820                        "axes_n",
821                        "fea",
822                        "fea_n",
823                        "scripts",
824                        "scripts_n",
825                        "tables",
826                        "tables_n",
827                        "creator_names",
828                        "license_names",
829                    ];
830                    if !valid_keywords.contains(&canon) {
831                        return Err(anyhow!("invalid details keyword: {}", trimmed));
832                    }
833                    parts.push(canon.to_string());
834                }
835            }
836            if parts.is_empty() {
837                return Err(anyhow!("empty details option"));
838            }
839            parts
840        }
841    };
842    Ok(presets)
843}
844
845fn get_effective_properties(format: &OutputFormat) -> Result<Vec<String>> {
846    if let Some(ref d) = format.details {
847        parse_details_list(d)
848    } else if format.csv {
849        Ok(vec![
850            "path".to_string(),
851            "fname".to_string(),
852            "sname".to_string(),
853            "fmt".to_string(),
854            "is_var".to_string(),
855            "weight".to_string(),
856            "width".to_string(),
857        ])
858    } else {
859        Ok(Vec::new())
860    }
861}
862
863fn extract_property_value(
864    m: &TypgFontFaceMatch,
865    prop: &str,
866    roots: &[PathBuf],
867    collections: bool,
868) -> serde_json::Value {
869    match prop {
870        "path" => {
871            if collections {
872                serde_json::Value::String(m.source.path_with_index())
873            } else {
874                serde_json::Value::String(m.source.path.to_string_lossy().to_string())
875            }
876        }
877        "path_r" => {
878            let rel = get_relative_path(&m.source.path, roots);
879            if collections {
880                if let Some(idx) = m.source.ttc_index {
881                    serde_json::Value::String(format!("{}#{}", rel, idx))
882                } else {
883                    serde_json::Value::String(rel)
884                }
885            } else {
886                serde_json::Value::String(rel)
887            }
888        }
889        "fmt" => {
890            let ext = m
891                .source
892                .path
893                .extension()
894                .and_then(|e| e.to_str())
895                .map(|e| e.to_ascii_lowercase())
896                .unwrap_or_else(|| "unknown".to_string());
897            serde_json::Value::String(ext)
898        }
899        "fname" => {
900            let name = m
901                .metadata
902                .tfname
903                .clone()
904                .or_else(|| m.metadata.lfname.clone())
905                .unwrap_or_else(|| m.metadata.names.first().cloned().unwrap_or_default());
906            serde_json::Value::String(name)
907        }
908        "sname" => {
909            let style = m
910                .metadata
911                .tsname
912                .clone()
913                .or_else(|| m.metadata.lsname.clone())
914                .unwrap_or_default();
915            serde_json::Value::String(style)
916        }
917        "psname" => serde_json::Value::String(m.metadata.psname.clone().unwrap_or_default()),
918        "tfname" => serde_json::Value::String(m.metadata.tfname.clone().unwrap_or_default()),
919        "lfname" => serde_json::Value::String(m.metadata.lfname.clone().unwrap_or_default()),
920        "tsname" => serde_json::Value::String(m.metadata.tsname.clone().unwrap_or_default()),
921        "lsname" => serde_json::Value::String(m.metadata.lsname.clone().unwrap_or_default()),
922        "is_var" => serde_json::Value::Bool(m.metadata.is_variable),
923        "weight" => serde_json::Value::Number(m.metadata.weight_class.unwrap_or_default().into()),
924        "width" => serde_json::Value::Number(m.metadata.width_class.unwrap_or_default().into()),
925        "family_class" => {
926            let fc = m
927                .metadata
928                .family_class
929                .map(|(maj, sub)| format!("{}.{}", maj, sub))
930                .unwrap_or_default();
931            serde_json::Value::String(fc)
932        }
933        "axes" => {
934            let list: Vec<String> = m
935                .metadata
936                .axis_tags
937                .iter()
938                .map(|t| tag_to_string(*t))
939                .collect();
940            serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
941        }
942        "axes_n" => serde_json::Value::Number(m.metadata.axis_tags.len().into()),
943        "fea" => {
944            let list: Vec<String> = m
945                .metadata
946                .feature_tags
947                .iter()
948                .map(|t| tag_to_string(*t))
949                .collect();
950            serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
951        }
952        "fea_n" => serde_json::Value::Number(m.metadata.feature_tags.len().into()),
953        "scripts" => {
954            let list: Vec<String> = m
955                .metadata
956                .script_tags
957                .iter()
958                .map(|t| tag_to_string(*t))
959                .collect();
960            serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
961        }
962        "scripts_n" => serde_json::Value::Number(m.metadata.script_tags.len().into()),
963        "tables" => {
964            let list: Vec<String> = m
965                .metadata
966                .table_tags
967                .iter()
968                .map(|t| tag_to_string(*t))
969                .collect();
970            serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
971        }
972        "tables_n" => serde_json::Value::Number(m.metadata.table_tags.len().into()),
973        "creator_names" => serde_json::Value::Array(
974            m.metadata
975                .creator_names
976                .iter()
977                .map(|s| serde_json::Value::String(s.clone()))
978                .collect(),
979        ),
980        "license_names" => serde_json::Value::Array(
981            m.metadata
982                .license_names
983                .iter()
984                .map(|s| serde_json::Value::String(s.clone()))
985                .collect(),
986        ),
987        _ => serde_json::Value::Null,
988    }
989}
990
991fn format_value_csv(val: &serde_json::Value) -> String {
992    match val {
993        serde_json::Value::Null => String::new(),
994        serde_json::Value::Bool(b) => b.to_string(),
995        serde_json::Value::Number(n) => n.to_string(),
996        serde_json::Value::String(s) => s.clone(),
997        serde_json::Value::Array(arr) => arr
998            .iter()
999            .map(|item| match item {
1000                serde_json::Value::String(s) => s.clone(),
1001                other => other.to_string(),
1002            })
1003            .collect::<Vec<_>>()
1004            .join(";"),
1005        serde_json::Value::Object(obj) => serde_json::to_string(obj).unwrap_or_default(),
1006    }
1007}
1008
1009fn escape_csv_field(field: &str) -> String {
1010    if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
1011        let escaped = field.replace('"', "\"\"");
1012        format!("\"{}\"", escaped)
1013    } else {
1014        field.to_string()
1015    }
1016}
1017
1018fn write_csv(
1019    matches: &[TypgFontFaceMatch],
1020    mut w: impl Write,
1021    format: &OutputFormat,
1022    roots: &[PathBuf],
1023) -> Result<()> {
1024    let props = get_effective_properties(format)?;
1025    let header_cols: Vec<String> = props.iter().map(|p| escape_csv_field(p)).collect();
1026    writeln!(w, "{}", header_cols.join(","))?;
1027
1028    let mut seen = std::collections::HashSet::new();
1029    for m in matches {
1030        if format.collections || seen.insert(m.source.path.clone()) {
1031            let mut row_cols = Vec::new();
1032            for prop in &props {
1033                let val = extract_property_value(m, prop, roots, format.collections);
1034                let fmt_val = format_value_csv(&val);
1035                row_cols.push(escape_csv_field(&fmt_val));
1036            }
1037            writeln!(w, "{}", row_cols.join(","))?;
1038        }
1039    }
1040    Ok(())
1041}
1042
1043fn write_json_pretty_filtered(
1044    matches: &[TypgFontFaceMatch],
1045    mut w: impl Write,
1046    format: &OutputFormat,
1047    roots: &[PathBuf],
1048) -> Result<()> {
1049    let props = get_effective_properties(format)?;
1050    if props.is_empty() {
1051        let json = serde_json::to_string_pretty(matches)?;
1052        w.write_all(json.as_bytes())?;
1053        return Ok(());
1054    }
1055
1056    if props.len() == 1 && props[0] == "path" {
1057        let mut paths = Vec::new();
1058        let mut seen = std::collections::HashSet::new();
1059        for m in matches {
1060            if format.collections || seen.insert(m.source.path.clone()) {
1061                let path_str = if format.collections {
1062                    m.source.path_with_index()
1063                } else {
1064                    m.source.path.to_string_lossy().to_string()
1065                };
1066                paths.push(path_str);
1067            }
1068        }
1069        let json = serde_json::to_string_pretty(&paths)?;
1070        w.write_all(json.as_bytes())?;
1071    } else {
1072        let mut objects = Vec::new();
1073        let mut seen = std::collections::HashSet::new();
1074        for m in matches {
1075            if format.collections || seen.insert(m.source.path.clone()) {
1076                let mut map = serde_json::Map::new();
1077                for prop in &props {
1078                    let val = extract_property_value(m, prop, roots, format.collections);
1079                    map.insert(prop.clone(), val);
1080                }
1081                objects.push(serde_json::Value::Object(map));
1082            }
1083        }
1084        let json = serde_json::to_string_pretty(&objects)?;
1085        w.write_all(json.as_bytes())?;
1086    }
1087    Ok(())
1088}
1089
1090fn write_ndjson_filtered(
1091    matches: &[TypgFontFaceMatch],
1092    mut w: impl Write,
1093    format: &OutputFormat,
1094    roots: &[PathBuf],
1095) -> Result<()> {
1096    let props = get_effective_properties(format)?;
1097    if props.is_empty() {
1098        for m in matches {
1099            let line = serde_json::to_string(m)?;
1100            w.write_all(line.as_bytes())?;
1101            w.write_all(b"\n")?;
1102        }
1103        return Ok(());
1104    }
1105
1106    let mut seen = std::collections::HashSet::new();
1107    for m in matches {
1108        if format.collections || seen.insert(m.source.path.clone()) {
1109            if props.len() == 1 && props[0] == "path" {
1110                let path_str = if format.collections {
1111                    m.source.path_with_index()
1112                } else {
1113                    m.source.path.to_string_lossy().to_string()
1114                };
1115                let line = serde_json::to_string(&path_str)?;
1116                w.write_all(line.as_bytes())?;
1117                w.write_all(b"\n")?;
1118            } else {
1119                let mut map = serde_json::Map::new();
1120                for prop in &props {
1121                    let val = extract_property_value(m, prop, roots, format.collections);
1122                    map.insert(prop.clone(), val);
1123                }
1124                let line = serde_json::to_string(&serde_json::Value::Object(map))?;
1125                w.write_all(line.as_bytes())?;
1126                w.write_all(b"\n")?;
1127            }
1128        }
1129    }
1130    Ok(())
1131}
1132
1133fn write_columns_filtered(
1134    matches: &[TypgFontFaceMatch],
1135    mut w: impl Write,
1136    format: &OutputFormat,
1137    color: bool,
1138    roots: &[PathBuf],
1139) -> Result<()> {
1140    let props = get_effective_properties(format)?;
1141    if props.is_empty() {
1142        return write_columns(matches, w, color, format.collections);
1143    }
1144
1145    let mut seen = std::collections::HashSet::new();
1146    let mut rows = Vec::new();
1147    for m in matches {
1148        if format.collections || seen.insert(m.source.path.clone()) {
1149            let mut row = Vec::new();
1150            for prop in &props {
1151                let val = extract_property_value(m, prop, roots, format.collections);
1152                let str_val = match val {
1153                    serde_json::Value::Null => String::new(),
1154                    serde_json::Value::String(s) => s,
1155                    serde_json::Value::Array(arr) => arr
1156                        .iter()
1157                        .map(|item| match item {
1158                            serde_json::Value::String(s) => s.clone(),
1159                            other => other.to_string(),
1160                        })
1161                        .collect::<Vec<_>>()
1162                        .join(","),
1163                    other => other.to_string(),
1164                };
1165                row.push(str_val);
1166            }
1167            rows.push(row);
1168        }
1169    }
1170
1171    if rows.is_empty() {
1172        return Ok(());
1173    }
1174
1175    let num_cols = props.len();
1176    let mut col_widths = vec![0; num_cols];
1177    for row in &rows {
1178        for (i, col) in row.iter().enumerate() {
1179            col_widths[i] = col_widths[i].max(col.len());
1180        }
1181    }
1182
1183    for row in rows {
1184        let mut cols = Vec::new();
1185        for (i, col) in row.iter().enumerate() {
1186            let width = col_widths[i];
1187            let padded = format!("{:<width$}", col);
1188            let rendered_col = if i == 0 {
1189                apply_color(&padded, color, AnsiColor::Cyan)
1190            } else if i == 1 {
1191                apply_color(&padded, color, AnsiColor::Yellow)
1192            } else {
1193                apply_color(&padded, color, AnsiColor::Green)
1194            };
1195            cols.push(rendered_col);
1196        }
1197        writeln!(w, "{}", cols.join("  "))?;
1198    }
1199
1200    Ok(())
1201}
1202
1203fn write_plain_filtered(
1204    matches: &[TypgFontFaceMatch],
1205    mut w: impl Write,
1206    format: &OutputFormat,
1207    color: bool,
1208    roots: &[PathBuf],
1209) -> Result<()> {
1210    let props = get_effective_properties(format)?;
1211    if props.is_empty() {
1212        return write_plain(matches, w, color, format.collections);
1213    }
1214
1215    let mut seen = std::collections::HashSet::new();
1216    for m in matches {
1217        if format.collections || seen.insert(m.source.path.clone()) {
1218            let mut row = Vec::new();
1219            for prop in &props {
1220                let val = extract_property_value(m, prop, roots, format.collections);
1221                let str_val = match val {
1222                    serde_json::Value::Null => String::new(),
1223                    serde_json::Value::String(s) => s,
1224                    serde_json::Value::Array(arr) => arr
1225                        .iter()
1226                        .map(|item| match item {
1227                            serde_json::Value::String(s) => s.clone(),
1228                            other => other.to_string(),
1229                        })
1230                        .collect::<Vec<_>>()
1231                        .join(","),
1232                    other => other.to_string(),
1233                };
1234                row.push(str_val);
1235            }
1236            writeln!(w, "{}", row.join("  "))?;
1237        }
1238    }
1239    Ok(())
1240}
1241
1242/// Translate `FindArgs` into a `Query`.
1243///
1244/// Thin adapter — unpacks the struct fields and delegates to the shared
1245/// `build_query_from_parts` so `cache find` can reuse the same logic.
1246fn build_query(args: &FindArgs) -> Result<Query> {
1247    build_query_from_parts(
1248        &args.axes,
1249        &args.features,
1250        &args.scripts,
1251        &args.tables,
1252        &args.name_patterns,
1253        &args.creator_patterns,
1254        &args.license_patterns,
1255        &args.codepoints,
1256        &args.text,
1257        args.variable,
1258        &args.weight,
1259        &args.width,
1260        &args.family_class,
1261    )
1262}
1263
1264/// Shared query builder used by both `find` and `cache find`.
1265///
1266/// Parses every raw string argument into typed values, then chains the
1267/// `Query` builder methods. The `text` argument is a convenience shortcut:
1268/// its characters are appended to the explicit `codepoints` list so
1269/// `--text "Ñoño"` is equivalent to listing every codepoint in that string.
1270///
1271/// All criteria are AND-combined — the more you add, the narrower the match.
1272#[allow(clippy::too_many_arguments)]
1273fn build_query_from_parts(
1274    axes: &[String],
1275    features: &[String],
1276    scripts: &[String],
1277    tables: &[String],
1278    name_patterns: &[String],
1279    creator_patterns: &[String],
1280    license_patterns: &[String],
1281    codepoints: &[String],
1282    text: &Option<String>,
1283    variable: bool,
1284    weight: &Option<String>,
1285    width: &Option<String>,
1286    family_class: &Option<String>,
1287) -> Result<Query> {
1288    let axes = parse_tag_list(axes)?;
1289    let features = parse_tag_list(features)?;
1290    let scripts = parse_tag_list(scripts)?;
1291    let tables = parse_tag_list(tables)?;
1292    let name_patterns = compile_patterns(name_patterns)?;
1293    let creator_patterns = compile_patterns(creator_patterns)?;
1294    let license_patterns = compile_patterns(license_patterns)?;
1295    let mut codepoints = parse_codepoints(codepoints)?;
1296    let weight_range = parse_optional_range(weight)?;
1297    let width_range = parse_optional_range(width)?;
1298    let family_class = parse_optional_family_class(family_class)?;
1299
1300    if let Some(text) = text {
1301        codepoints.extend(text.chars());
1302    }
1303
1304    dedup_chars(&mut codepoints);
1305
1306    Ok(Query::new()
1307        .with_axes(axes)
1308        .with_features(features)
1309        .with_scripts(scripts)
1310        .with_tables(tables)
1311        .with_name_patterns(name_patterns)
1312        .with_creator_patterns(creator_patterns)
1313        .with_license_patterns(license_patterns)
1314        .with_codepoints(codepoints)
1315        .require_variable(variable)
1316        .with_weight_range(weight_range)
1317        .with_width_range(width_range)
1318        .with_family_class(family_class))
1319}
1320
1321fn dedup_chars(cps: &mut Vec<char>) {
1322    cps.sort();
1323    cps.dedup();
1324}
1325
1326fn compile_patterns(patterns: &[String]) -> Result<Vec<Regex>> {
1327    patterns
1328        .iter()
1329        .map(|p| Regex::new(p).with_context(|| format!("invalid regex: {p}")))
1330        .collect()
1331}
1332
1333fn parse_codepoints(raw: &[String]) -> Result<Vec<char>> {
1334    let mut cps = Vec::new();
1335    for chunk in raw {
1336        cps.extend(parse_codepoint_list(chunk)?);
1337    }
1338    Ok(cps)
1339}
1340
1341fn parse_optional_range(raw: &Option<String>) -> Result<Option<RangeInclusive<u16>>> {
1342    match raw {
1343        Some(value) => Ok(Some(parse_u16_range(value)?)),
1344        None => Ok(None),
1345    }
1346}
1347
1348fn parse_optional_family_class(raw: &Option<String>) -> Result<Option<FamilyClassFilter>> {
1349    match raw {
1350        Some(value) => Ok(Some(parse_family_class(value)?)),
1351        None => Ok(None),
1352    }
1353}
1354
1355/// Collect the final list of paths to search, merging all input sources.
1356///
1357/// Three sources combine in order:
1358/// 1. `--stdin-paths` flag: read one path per line from stdin before any
1359///    positional args. Useful when piping from `find` or `fd`.
1360/// 2. Positional paths: each is appended as-is, except the special value `-`
1361///    which triggers another stdin read (matches Unix convention).
1362/// 3. `--system-fonts` flag: appends OS-default font directories. On macOS
1363///    that's `/System/Library/Fonts`, `/Library/Fonts`, and `~/Library/Fonts`.
1364///    On Linux it's `/usr/share/fonts` and `~/.local/share/fonts`.
1365///    On Windows it's `%SYSTEMROOT%\Fonts` and the per-user fonts folder.
1366///    Override with `TYPOG_SYSTEM_FONT_DIRS` (colon-separated on Unix, semicolon on Windows).
1367fn gather_paths(
1368    raw_paths: &[PathBuf],
1369    read_stdin: bool,
1370    include_system: bool,
1371    mut stdin: impl BufRead,
1372) -> Result<Vec<PathBuf>> {
1373    let mut paths = Vec::new();
1374
1375    if read_stdin {
1376        paths.extend(read_paths_from(&mut stdin)?);
1377    }
1378
1379    for path in raw_paths {
1380        if path == Path::new("-") {
1381            paths.extend(read_paths_from(&mut stdin)?);
1382        } else {
1383            paths.push(path.clone());
1384        }
1385    }
1386
1387    if include_system {
1388        paths.extend(system_font_roots()?);
1389    }
1390
1391    if paths.is_empty() {
1392        return Err(anyhow!("no search paths provided"));
1393    }
1394
1395    Ok(paths)
1396}
1397
1398fn read_paths_from(reader: &mut impl BufRead) -> Result<Vec<PathBuf>> {
1399    let mut buf = String::new();
1400    let mut paths = Vec::new();
1401
1402    loop {
1403        buf.clear();
1404        let read = reader.read_line(&mut buf)?;
1405        if read == 0 {
1406            break;
1407        }
1408
1409        let trimmed = buf.trim();
1410        if !trimmed.is_empty() {
1411            paths.push(PathBuf::from(trimmed));
1412        }
1413    }
1414
1415    Ok(paths)
1416}
1417
1418/// Return the OS-default directories where fonts are installed.
1419///
1420/// `TYPOG_SYSTEM_FONT_DIRS` overrides the defaults entirely (colon-separated
1421/// on Unix, semicolon accepted on both platforms). Non-existent paths are
1422/// filtered out silently — a freshly installed OS might not have a per-user
1423/// fonts folder yet. Returns an error only when every candidate is missing.
1424///
1425/// Platform defaults (compiled in, not checked at startup):
1426/// - macOS: `/System/Library/Fonts`, `/Library/Fonts`, `~/Library/Fonts`
1427/// - Linux: `/usr/share/fonts`, `/usr/local/share/fonts`, `~/.local/share/fonts`
1428/// - Windows: `%SYSTEMROOT%\Fonts`, `%LOCALAPPDATA%\Microsoft\Windows\Fonts`
1429fn system_font_roots() -> Result<Vec<PathBuf>> {
1430    if let Ok(raw) = env::var("TYPOG_SYSTEM_FONT_DIRS") {
1431        let mut overrides: Vec<PathBuf> = raw
1432            .split([':', ';'])
1433            .filter(|s| !s.is_empty())
1434            .map(PathBuf::from)
1435            .filter(|p| p.exists())
1436            .collect();
1437
1438        overrides.sort();
1439        overrides.dedup();
1440
1441        return if overrides.is_empty() {
1442            Err(anyhow!("TYPOG_SYSTEM_FONT_DIRS is set but no paths exist"))
1443        } else {
1444            Ok(overrides)
1445        };
1446    }
1447
1448    let mut candidates: Vec<PathBuf> = Vec::new();
1449
1450    #[cfg(target_os = "macos")]
1451    {
1452        candidates.push(PathBuf::from("/System/Library/Fonts"));
1453        candidates.push(PathBuf::from("/Library/Fonts"));
1454        if let Some(home) = env::var_os("HOME") {
1455            candidates.push(PathBuf::from(home).join("Library/Fonts"));
1456        }
1457    }
1458
1459    #[cfg(target_os = "linux")]
1460    {
1461        candidates.push(PathBuf::from("/usr/share/fonts"));
1462        candidates.push(PathBuf::from("/usr/local/share/fonts"));
1463        if let Some(home) = env::var_os("HOME") {
1464            candidates.push(PathBuf::from(home).join(".local/share/fonts"));
1465        }
1466    }
1467
1468    #[cfg(target_os = "windows")]
1469    {
1470        if let Some(system_root) = env::var_os("SYSTEMROOT") {
1471            candidates.push(PathBuf::from(system_root).join("Fonts"));
1472        }
1473        if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1474            candidates.push(PathBuf::from(local_appdata).join("Microsoft/Windows/Fonts"));
1475        }
1476    }
1477
1478    candidates.retain(|p| p.exists());
1479    candidates.sort();
1480    candidates.dedup();
1481
1482    if candidates.is_empty() {
1483        return Err(anyhow!(
1484            "no system font directories found for this platform"
1485        ));
1486    }
1487
1488    Ok(candidates)
1489}
1490
1491/// Write matches as plain text: one path (or path#index) per line.
1492///
1493/// Without `--collections`, each *file* path appears at most once — faces
1494/// from the same TTC/OTC are deduplicated down to the container path.
1495/// With `--collections`, every face gets its own line with a `#N` suffix,
1496/// allowing callers to address individual faces inside a collection file.
1497fn write_plain(
1498    matches: &[TypgFontFaceMatch],
1499    mut w: impl Write,
1500    color: bool,
1501    collections: bool,
1502) -> Result<()> {
1503    if collections {
1504        for item in matches {
1505            let rendered = render_path(item, color, true);
1506            writeln!(w, "{rendered}")?;
1507        }
1508    } else {
1509        let mut seen = std::collections::HashSet::new();
1510        for item in matches {
1511            if seen.insert(item.source.path.clone()) {
1512                let rendered = render_path(item, color, false);
1513                writeln!(w, "{rendered}")?;
1514            }
1515        }
1516    }
1517    Ok(())
1518}
1519
1520/// Write bare file paths, one per line, no color or metadata.
1521///
1522/// This is the `--paths` output mode, designed for piping. The output is
1523/// stable: each path appears exactly once (unless `--collections` is set,
1524/// in which case TTC faces appear as `path#0`, `path#1`, etc.).
1525/// Downstream tools like `typf`, `fontlift`, or shell loops can consume
1526/// this directly.
1527fn write_paths(matches: &[TypgFontFaceMatch], mut w: impl Write, collections: bool) -> Result<()> {
1528    if collections {
1529        for item in matches {
1530            writeln!(w, "{}", item.source.path_with_index())?;
1531        }
1532    } else {
1533        let mut seen = std::collections::HashSet::new();
1534        for item in matches {
1535            if seen.insert(item.source.path.clone()) {
1536                writeln!(w, "{}", item.source.path.display())?;
1537            }
1538        }
1539    }
1540    Ok(())
1541}
1542
1543/// Write a human-readable aligned column layout: path | name | tag counts.
1544///
1545/// Requires all matches in memory first (to measure the longest path and name
1546/// for alignment). Each row shows:
1547///   - File path (or path#index with `--collections`), cyan
1548///   - First name string from the font's `name` table, yellow
1549///   - Summary counts: axes, features, scripts, tables, plus "var" if variable, green
1550///
1551/// Column widths are clamped to 120 (path) and 80 (name) to stay readable
1552/// on standard terminal widths even with very long paths.
1553fn write_columns(
1554    matches: &[TypgFontFaceMatch],
1555    mut w: impl Write,
1556    color: bool,
1557    collections: bool,
1558) -> Result<()> {
1559    let mut rows: Vec<(String, String, String)> = matches
1560        .iter()
1561        .map(|m| {
1562            let path = if collections {
1563                m.source.path_with_index()
1564            } else {
1565                m.source.path.display().to_string()
1566            };
1567            let name = m
1568                .metadata
1569                .names
1570                .first()
1571                .cloned()
1572                .unwrap_or_else(|| "(unnamed)".to_string());
1573
1574            let tags = format!(
1575                "axes:{:<2} feats:{:<2} scripts:{:<2} tables:{:<2}{}",
1576                m.metadata.axis_tags.len(),
1577                m.metadata.feature_tags.len(),
1578                m.metadata.script_tags.len(),
1579                m.metadata.table_tags.len(),
1580                if m.metadata.is_variable { " var" } else { "" },
1581            );
1582
1583            (path, name, tags)
1584        })
1585        .collect();
1586
1587    let path_width = rows
1588        .iter()
1589        .map(|r| r.0.len())
1590        .max()
1591        .unwrap_or(0)
1592        .clamp(0, 120);
1593    let name_width = rows
1594        .iter()
1595        .map(|r| r.1.len())
1596        .max()
1597        .unwrap_or(0)
1598        .clamp(0, 80);
1599
1600    for (path, name, tags) in rows.drain(..) {
1601        let padded_path = format!("{:<path_width$}", path);
1602        let padded_name = format!("{:<name_width$}", name);
1603        let rendered_path = apply_color(&padded_path, color, AnsiColor::Cyan);
1604        let rendered_name = apply_color(&padded_name, color, AnsiColor::Yellow);
1605        let rendered_tags = apply_color(&tags, color, AnsiColor::Green);
1606
1607        writeln!(w, "{rendered_path}  {rendered_name}  {rendered_tags}")?;
1608    }
1609
1610    Ok(())
1611}
1612
1613#[derive(Copy, Clone)]
1614enum AnsiColor {
1615    Cyan,
1616    Yellow,
1617    Green,
1618}
1619
1620fn apply_color(text: &str, color: bool, code: AnsiColor) -> String {
1621    if !color {
1622        return text.to_string();
1623    }
1624
1625    let code_str = match code {
1626        AnsiColor::Cyan => "36",
1627        AnsiColor::Yellow => "33",
1628        AnsiColor::Green => "32",
1629    };
1630
1631    format!("\u{1b}[{}m{}\u{1b}[0m", code_str, text)
1632}
1633
1634fn render_path(item: &TypgFontFaceMatch, color: bool, collections: bool) -> String {
1635    let rendered = if collections {
1636        item.source.path_with_index()
1637    } else {
1638        item.source.path.display().to_string()
1639    };
1640    apply_color(&rendered, color, AnsiColor::Cyan)
1641}
1642
1643fn run_cache_add(args: CacheAddArgs, quiet: bool) -> Result<()> {
1644    if matches!(args.jobs, Some(0)) {
1645        return Err(anyhow!("--jobs must be at least 1"));
1646    }
1647
1648    #[cfg(feature = "hpindex")]
1649    if args.use_index {
1650        return run_cache_add_index(args, quiet);
1651    }
1652
1653    #[cfg(not(feature = "hpindex"))]
1654    if args.use_index {
1655        return Err(anyhow!(
1656            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1657        ));
1658    }
1659
1660    let stdin = io::stdin();
1661    let paths = gather_paths(
1662        &args.paths,
1663        args.stdin_paths,
1664        args.system_fonts,
1665        stdin.lock(),
1666    )?;
1667
1668    let opts = SearchOptions {
1669        follow_symlinks: args.follow_symlinks,
1670        jobs: args.jobs,
1671    };
1672    let additions = search(&paths, &Query::new(), &opts)?;
1673
1674    let cache_path = resolve_cache_path(&args.cache_path)?;
1675    let existing = if cache_path.exists() {
1676        load_cache(&cache_path)?
1677    } else {
1678        Vec::new()
1679    };
1680
1681    let merged = merge_entries(existing, additions);
1682    write_cache(&cache_path, &merged)?;
1683
1684    if !quiet {
1685        eprintln!(
1686            "cached {} font faces at {}",
1687            merged.len(),
1688            cache_path.display()
1689        );
1690    }
1691    Ok(())
1692}
1693
1694fn run_cache_list(args: CacheListArgs) -> Result<()> {
1695    #[cfg(feature = "hpindex")]
1696    if args.use_index {
1697        return run_cache_list_index(args);
1698    }
1699
1700    #[cfg(not(feature = "hpindex"))]
1701    if args.use_index {
1702        return Err(anyhow!(
1703            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1704        ));
1705    }
1706
1707    let cache_path = resolve_cache_path(&args.cache_path)?;
1708    let entries = load_cache(&cache_path)?;
1709    let output = OutputFormat::from_output(&args.output);
1710    write_matches(&entries, &output, &[])
1711}
1712
1713fn run_cache_find(args: CacheFindArgs) -> Result<()> {
1714    #[cfg(feature = "hpindex")]
1715    if args.use_index {
1716        return run_cache_find_index(args);
1717    }
1718
1719    #[cfg(not(feature = "hpindex"))]
1720    if args.use_index {
1721        return Err(anyhow!(
1722            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1723        ));
1724    }
1725
1726    let cache_path = resolve_cache_path(&args.cache_path)?;
1727    let entries = load_cache(&cache_path)?;
1728    let query = build_query_from_parts(
1729        &args.axes,
1730        &args.features,
1731        &args.scripts,
1732        &args.tables,
1733        &args.name_patterns,
1734        &args.creator_patterns,
1735        &args.license_patterns,
1736        &args.codepoints,
1737        &args.text,
1738        args.variable,
1739        &args.weight,
1740        &args.width,
1741        &args.family_class,
1742    )?;
1743
1744    let matches = filter_cached(&entries, &query);
1745
1746    if args.count_only {
1747        println!("{}", matches.len());
1748        return Ok(());
1749    }
1750
1751    let output = OutputFormat::from_output(&args.output);
1752    write_matches(&matches, &output, &[])
1753}
1754
1755fn run_cache_clean(args: CacheCleanArgs, quiet: bool) -> Result<()> {
1756    #[cfg(feature = "hpindex")]
1757    if args.use_index {
1758        return run_cache_clean_index(args, quiet);
1759    }
1760
1761    #[cfg(not(feature = "hpindex"))]
1762    if args.use_index {
1763        return Err(anyhow!(
1764            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1765        ));
1766    }
1767
1768    let cache_path = resolve_cache_path(&args.cache_path)?;
1769    let entries = load_cache(&cache_path)?;
1770    let before = entries.len();
1771    let pruned = prune_missing(entries);
1772    let after = pruned.len();
1773
1774    write_cache(&cache_path, &pruned)?;
1775    if !quiet {
1776        eprintln!(
1777            "removed {} missing entries ({} → {})",
1778            before.saturating_sub(after),
1779            before,
1780            after
1781        );
1782    }
1783    Ok(())
1784}
1785
1786fn run_cache_info(args: CacheInfoArgs) -> Result<()> {
1787    #[cfg(feature = "hpindex")]
1788    if args.use_index {
1789        return run_cache_info_index(args);
1790    }
1791
1792    #[cfg(not(feature = "hpindex"))]
1793    if args.use_index {
1794        return Err(anyhow!(
1795            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1796        ));
1797    }
1798
1799    let cache_path = resolve_cache_path(&args.cache_path)?;
1800
1801    if !cache_path.exists() {
1802        if args.json {
1803            println!(r#"{{"exists":false,"path":"{}"}}"#, cache_path.display());
1804        } else {
1805            println!("Cache does not exist at {}", cache_path.display());
1806        }
1807        return Ok(());
1808    }
1809
1810    let entries = load_cache(&cache_path)?;
1811    let file_meta = fs::metadata(&cache_path)?;
1812    let size_bytes = file_meta.len();
1813
1814    if args.json {
1815        let info = serde_json::json!({
1816            "exists": true,
1817            "path": cache_path.display().to_string(),
1818            "type": "json",
1819            "entries": entries.len(),
1820            "size_bytes": size_bytes,
1821        });
1822        println!("{}", serde_json::to_string_pretty(&info)?);
1823    } else {
1824        println!("Cache: {}", cache_path.display());
1825        println!("Type:  JSON");
1826        println!("Fonts: {}", entries.len());
1827        println!("Size:  {} bytes", size_bytes);
1828    }
1829
1830    Ok(())
1831}
1832
1833fn resolve_cache_path(custom: &Option<PathBuf>) -> Result<PathBuf> {
1834    if let Some(path) = custom {
1835        return Ok(path.clone());
1836    }
1837
1838    if let Ok(env_override) = env::var("TYPOG_CACHE_PATH") {
1839        return Ok(PathBuf::from(env_override));
1840    }
1841
1842    #[cfg(target_os = "windows")]
1843    {
1844        if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1845            return Ok(PathBuf::from(local_appdata).join("typg").join("cache.json"));
1846        }
1847        if let Some(home) = env::var_os("HOME") {
1848            return Ok(PathBuf::from(home).join("AppData/Local/typg/cache.json"));
1849        }
1850    }
1851
1852    #[cfg(not(target_os = "windows"))]
1853    {
1854        if let Some(xdg) = env::var_os("XDG_CACHE_HOME") {
1855            return Ok(PathBuf::from(xdg).join("typg").join("cache.json"));
1856        }
1857        if let Some(home) = env::var_os("HOME") {
1858            return Ok(PathBuf::from(home)
1859                .join(".cache")
1860                .join("typg")
1861                .join("cache.json"));
1862        }
1863    }
1864
1865    Err(anyhow!(
1866        "--cache-path is required because no cache directory could be detected"
1867    ))
1868}
1869
1870/// Resolve the index directory path.
1871#[cfg_attr(not(feature = "hpindex"), allow(dead_code))]
1872fn resolve_index_path(custom: &Option<PathBuf>) -> Result<PathBuf> {
1873    if let Some(path) = custom {
1874        return Ok(path.clone());
1875    }
1876
1877    if let Ok(env_override) = env::var("TYPOG_INDEX_PATH") {
1878        return Ok(PathBuf::from(env_override));
1879    }
1880
1881    #[cfg(target_os = "windows")]
1882    {
1883        if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1884            return Ok(PathBuf::from(local_appdata).join("typg").join("index"));
1885        }
1886        if let Some(home) = env::var_os("HOME") {
1887            return Ok(PathBuf::from(home).join("AppData/Local/typg/index"));
1888        }
1889    }
1890
1891    #[cfg(not(target_os = "windows"))]
1892    {
1893        if let Some(xdg) = env::var_os("XDG_CACHE_HOME") {
1894            return Ok(PathBuf::from(xdg).join("typg").join("index"));
1895        }
1896        if let Some(home) = env::var_os("HOME") {
1897            return Ok(PathBuf::from(home)
1898                .join(".cache")
1899                .join("typg")
1900                .join("index"));
1901        }
1902    }
1903
1904    Err(anyhow!(
1905        "--index-path is required because no cache directory could be detected"
1906    ))
1907}
1908
1909/// Load cached font entries from disk. Tries JSON array first, falls back to NDJSON.
1910fn load_cache(path: &Path) -> Result<Vec<TypgFontFaceMatch>> {
1911    let file = File::open(path).with_context(|| format!("opening cache {}", path.display()))?;
1912    let reader = BufReader::new(file);
1913
1914    match serde_json::from_reader(reader) {
1915        Ok(entries) => Ok(entries),
1916        Err(_) => {
1917            // Fall back to NDJSON parsing for forward compatibility
1918            let file =
1919                File::open(path).with_context(|| format!("re-opening cache {}", path.display()))?;
1920            let reader = BufReader::new(file);
1921            let stream = Deserializer::from_reader(reader).into_iter::<TypgFontFaceMatch>();
1922            let mut entries = Vec::new();
1923            for item in stream {
1924                entries.push(item?);
1925            }
1926            Ok(entries)
1927        }
1928    }
1929}
1930
1931/// Write font entries to the cache file as pretty-printed JSON.
1932fn write_cache(path: &Path, entries: &[TypgFontFaceMatch]) -> Result<()> {
1933    if let Some(parent) = path.parent() {
1934        fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
1935    }
1936
1937    let file = File::create(path).with_context(|| format!("creating cache {}", path.display()))?;
1938    let mut writer = BufWriter::new(file);
1939    serde_json::to_writer_pretty(&mut writer, entries)
1940        .with_context(|| format!("writing cache {}", path.display()))?;
1941    writer.flush()?;
1942    Ok(())
1943}
1944
1945fn merge_entries(
1946    existing: Vec<TypgFontFaceMatch>,
1947    additions: Vec<TypgFontFaceMatch>,
1948) -> Vec<TypgFontFaceMatch> {
1949    let mut map: HashMap<(PathBuf, Option<u32>), TypgFontFaceMatch> = HashMap::new();
1950
1951    for entry in existing.into_iter().chain(additions.into_iter()) {
1952        map.insert(cache_key(&entry), entry);
1953    }
1954
1955    let mut merged: Vec<TypgFontFaceMatch> = map.into_values().collect();
1956    sort_entries(&mut merged);
1957    merged
1958}
1959
1960fn prune_missing(entries: Vec<TypgFontFaceMatch>) -> Vec<TypgFontFaceMatch> {
1961    let mut pruned: Vec<TypgFontFaceMatch> = entries
1962        .into_iter()
1963        .filter(|entry| entry.source.path.exists())
1964        .collect();
1965    sort_entries(&mut pruned);
1966    pruned
1967}
1968
1969fn sort_entries(entries: &mut [TypgFontFaceMatch]) {
1970    entries.sort_by(|a, b| {
1971        a.source
1972            .path
1973            .cmp(&b.source.path)
1974            .then_with(|| a.source.ttc_index.cmp(&b.source.ttc_index))
1975    });
1976}
1977
1978fn cache_key(entry: &TypgFontFaceMatch) -> (PathBuf, Option<u32>) {
1979    (entry.source.path.clone(), entry.source.ttc_index)
1980}
1981
1982// ============================================================================
1983// High-performance index implementations (LMDB + Roaring Bitmaps)
1984// ============================================================================
1985
1986#[cfg(feature = "hpindex")]
1987fn run_cache_add_index(args: CacheAddArgs, quiet: bool) -> Result<()> {
1988    use std::time::SystemTime;
1989
1990    let stdin = io::stdin();
1991    let paths = gather_paths(
1992        &args.paths,
1993        args.stdin_paths,
1994        args.system_fonts,
1995        stdin.lock(),
1996    )?;
1997
1998    let index_path = resolve_index_path(&args.index_path)?;
1999    let index = FontIndex::open(&index_path)?;
2000
2001    // Use the existing search pipeline to discover and extract metadata.
2002    let opts = SearchOptions {
2003        follow_symlinks: args.follow_symlinks,
2004        jobs: args.jobs,
2005    };
2006    let additions = search(&paths, &Query::new(), &opts)?;
2007
2008    // Write to index in a single transaction.
2009    let mut writer = index.writer()?;
2010    let mut added = 0usize;
2011    let mut skipped = 0usize;
2012
2013    for entry in additions {
2014        // Get file mtime for incremental update detection.
2015        let mtime = entry
2016            .source
2017            .path
2018            .metadata()
2019            .and_then(|m| m.modified())
2020            .unwrap_or(SystemTime::UNIX_EPOCH);
2021
2022        // Check if update is needed.
2023        if !writer.needs_update(&entry.source.path, mtime)? {
2024            skipped += 1;
2025            continue;
2026        }
2027
2028        writer.add_font(
2029            &entry.source.path,
2030            entry.source.ttc_index,
2031            mtime,
2032            &entry.metadata,
2033        )?;
2034        added += 1;
2035    }
2036
2037    writer.commit()?;
2038
2039    if !quiet {
2040        let total = index.count()?;
2041        eprintln!(
2042            "indexed {} font faces at {} (added: {}, skipped: {})",
2043            total,
2044            index_path.display(),
2045            added,
2046            skipped
2047        );
2048    }
2049
2050    Ok(())
2051}
2052
2053#[cfg(feature = "hpindex")]
2054fn run_cache_list_index(args: CacheListArgs) -> Result<()> {
2055    let index_path = resolve_index_path(&args.index_path)?;
2056    let index = FontIndex::open(&index_path)?;
2057    let reader = index.reader()?;
2058    let entries = reader.list_all()?;
2059    let output = OutputFormat::from_output(&args.output);
2060    write_matches(&entries, &output, &[])
2061}
2062
2063#[cfg(feature = "hpindex")]
2064fn run_cache_find_index(args: CacheFindArgs) -> Result<()> {
2065    let index_path = resolve_index_path(&args.index_path)?;
2066    let index = FontIndex::open(&index_path)?;
2067
2068    let query = build_query_from_parts(
2069        &args.axes,
2070        &args.features,
2071        &args.scripts,
2072        &args.tables,
2073        &args.name_patterns,
2074        &args.creator_patterns,
2075        &args.license_patterns,
2076        &args.codepoints,
2077        &args.text,
2078        args.variable,
2079        &args.weight,
2080        &args.width,
2081        &args.family_class,
2082    )?;
2083
2084    let reader = index.reader()?;
2085    let matches = reader.find(&query)?;
2086
2087    if args.count_only {
2088        println!("{}", matches.len());
2089        return Ok(());
2090    }
2091
2092    let output = OutputFormat::from_output(&args.output);
2093    write_matches(&matches, &output, &[])
2094}
2095
2096#[cfg(feature = "hpindex")]
2097fn run_cache_clean_index(args: CacheCleanArgs, quiet: bool) -> Result<()> {
2098    let index_path = resolve_index_path(&args.index_path)?;
2099    let index = FontIndex::open(&index_path)?;
2100
2101    let mut writer = index.writer()?;
2102    let (before, after) = writer.prune_missing()?;
2103    writer.commit()?;
2104
2105    if !quiet {
2106        eprintln!(
2107            "removed {} missing entries ({} → {})",
2108            before.saturating_sub(after),
2109            before,
2110            after
2111        );
2112    }
2113    Ok(())
2114}
2115
2116#[cfg(feature = "hpindex")]
2117fn run_cache_info_index(args: CacheInfoArgs) -> Result<()> {
2118    let index_path = resolve_index_path(&args.index_path)?;
2119
2120    if !index_path.exists() {
2121        if args.json {
2122            println!(r#"{{"exists":false,"path":"{}"}}"#, index_path.display());
2123        } else {
2124            println!("Index does not exist at {}", index_path.display());
2125        }
2126        return Ok(());
2127    }
2128
2129    let index = FontIndex::open(&index_path)?;
2130    let count = index.count()?;
2131
2132    // Calculate total directory size (non-recursive, LMDB is flat).
2133    let size_bytes: u64 = fs::read_dir(&index_path)?
2134        .filter_map(|e| e.ok())
2135        .filter_map(|e| e.metadata().ok())
2136        .filter(|m| m.is_file())
2137        .map(|m| m.len())
2138        .sum();
2139
2140    if args.json {
2141        let info = serde_json::json!({
2142            "exists": true,
2143            "path": index_path.display().to_string(),
2144            "type": "lmdb",
2145            "entries": count,
2146            "size_bytes": size_bytes,
2147        });
2148        println!("{}", serde_json::to_string_pretty(&info)?);
2149    } else {
2150        println!("Index: {}", index_path.display());
2151        println!("Type:  LMDB");
2152        println!("Fonts: {}", count);
2153        println!("Size:  {} bytes", size_bytes);
2154    }
2155
2156    Ok(())
2157}
2158
2159#[cfg(test)]
2160mod tests;