Skip to main content

typg_cli/
lib.rs

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