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