Skip to main content

typg_cli/
lib.rs

1//! typg CLI — font search and cache management.
2//!
3//! Made by FontLab https://www.fontlab.com/
4
5mod server;
6
7use std::collections::HashMap;
8use std::env;
9use std::fs::{self, File};
10use std::io::{self, BufRead, BufReader, BufWriter, IsTerminal, Write};
11use std::ops::RangeInclusive;
12use std::path::{Path, PathBuf};
13
14use anyhow::{anyhow, Context, Result};
15use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum, ValueHint};
16use regex::Regex;
17use serde_json::Deserializer;
18use tokio::runtime::Builder;
19
20use typg_core::output::{write_json_pretty, write_ndjson};
21use typg_core::query::{
22    parse_codepoint_list, parse_family_class, parse_tag_list, parse_u16_range, FamilyClassFilter,
23    Query,
24};
25use typg_core::search::{
26    filter_cached, search, search_streaming, SearchOptions, TypgFontFaceMatch,
27};
28
29#[cfg(feature = "hpindex")]
30use typg_core::index::FontIndex;
31
32/// Top-level CLI definition.
33#[derive(Debug, Parser)]
34#[command(
35    name = "typg",
36    version,
37    about = "Fast font search (made by FontLab https://www.fontlab.com/)"
38)]
39pub struct Cli {
40    /// Suppress informational messages
41    #[arg(short = 'q', long = "quiet", global = true, action = ArgAction::SetTrue)]
42    quiet: bool,
43
44    #[command(subcommand)]
45    command: Command,
46}
47
48/// Available subcommands.
49#[derive(Debug, Subcommand)]
50enum Command {
51    /// Search directories for fonts matching a query
52    Find(Box<FindArgs>),
53
54    /// Manage the font metadata cache
55    #[command(subcommand)]
56    Cache(CacheCommand),
57
58    /// Start an HTTP search server
59    Serve(ServeArgs),
60}
61
62/// Cache management subcommands.
63#[derive(Debug, Subcommand)]
64enum CacheCommand {
65    /// Scan paths and add font metadata to the cache
66    Add(CacheAddArgs),
67    /// List all cached font entries
68    List(CacheListArgs),
69    /// Query the cache without scanning the filesystem
70    Find(Box<CacheFindArgs>),
71    /// Remove entries for fonts that no longer exist on disk
72    Clean(CacheCleanArgs),
73    /// Show cache location, size, and entry count
74    Info(CacheInfoArgs),
75}
76
77/// HTTP server configuration.
78#[derive(Debug, Args)]
79struct ServeArgs {
80    /// Address to bind (host:port)
81    #[arg(long = "bind", default_value = "127.0.0.1:8765")]
82    bind: String,
83}
84
85/// Arguments for `cache add`.
86#[derive(Debug, Args)]
87struct CacheAddArgs {
88    /// Paths to scan (directories or individual font files)
89    #[arg(
90        value_hint = ValueHint::DirPath,
91        required_unless_present_any = ["system_fonts", "stdin_paths"]
92    )]
93    paths: Vec<PathBuf>,
94
95    /// Read additional paths from stdin, one per line
96    #[arg(long = "stdin-paths", action = ArgAction::SetTrue)]
97    stdin_paths: bool,
98
99    /// Include platform-default system font directories
100    #[arg(long = "system-fonts", action = ArgAction::SetTrue)]
101    system_fonts: bool,
102
103    /// Follow symlinks during directory traversal
104    #[arg(long = "follow-symlinks", action = ArgAction::SetTrue)]
105    follow_symlinks: bool,
106
107    /// Number of parallel worker threads
108    #[arg(short = 'J', long = "jobs", value_hint = ValueHint::Other)]
109    jobs: Option<usize>,
110
111    /// Override cache file location
112    #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
113    cache_path: Option<PathBuf>,
114
115    /// Use LMDB index backend instead of JSON cache
116    #[arg(long = "index", action = ArgAction::SetTrue)]
117    use_index: bool,
118
119    /// Override LMDB index directory
120    #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
121    index_path: Option<PathBuf>,
122}
123
124/// Output format options shared across subcommands.
125#[derive(Debug, Args, Clone)]
126struct OutputArgs {
127    /// Output as a single JSON array
128    #[arg(long = "json", action = ArgAction::SetTrue, conflicts_with = "ndjson")]
129    json: bool,
130
131    /// Output as newline-delimited JSON (one object per line)
132    #[arg(long = "ndjson", action = ArgAction::SetTrue)]
133    ndjson: bool,
134
135    /// Output file paths only (with #index for TTC faces)
136    #[arg(
137        long = "paths",
138        action = ArgAction::SetTrue,
139        conflicts_with_all = ["json", "ndjson", "columns"]
140    )]
141    paths: bool,
142
143    /// Output as aligned columns
144    #[arg(long = "columns", action = ArgAction::SetTrue)]
145    columns: bool,
146
147    /// Show individual TTC/OTC collection faces (path#index); default deduplicates by path
148    #[arg(long = "collections", action = ArgAction::SetTrue)]
149    collections: bool,
150
151    /// Colorize output (auto detects terminal)
152    #[arg(long = "color", default_value_t = ColorChoice::Auto, value_enum)]
153    color: ColorChoice,
154}
155
156#[derive(Debug, Args)]
157struct CacheListArgs {
158    /// Override cache location (defaults to ~/.cache/typg/cache.json)
159    #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
160    cache_path: Option<PathBuf>,
161
162    /// Use high-performance LMDB index instead of JSON cache (requires hpindex feature)
163    #[arg(long = "index", action = ArgAction::SetTrue)]
164    use_index: bool,
165
166    /// Override index directory (defaults to ~/.cache/typg/index/)
167    #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
168    index_path: Option<PathBuf>,
169
170    #[command(flatten)]
171    output: OutputArgs,
172}
173
174#[derive(Debug, Args)]
175struct CacheFindArgs {
176    /// Override cache location (defaults to ~/.cache/typg/cache.json)
177    #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
178    cache_path: Option<PathBuf>,
179
180    /// Use high-performance LMDB index instead of JSON cache (requires hpindex feature)
181    #[arg(long = "index", action = ArgAction::SetTrue)]
182    use_index: bool,
183
184    /// Override index directory (defaults to ~/.cache/typg/index/)
185    #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
186    index_path: Option<PathBuf>,
187
188    /// Require fonts to define these axis tags
189    #[arg(short = 'a', long = "axes", value_delimiter = ',', value_hint = ValueHint::Other)]
190    axes: Vec<String>,
191
192    /// Require fonts to define these OpenType feature tags
193    #[arg(short = 'f', long = "features", value_delimiter = ',', value_hint = ValueHint::Other)]
194    features: Vec<String>,
195
196    /// Require fonts to cover these script tags
197    #[arg(short = 's', long = "scripts", value_delimiter = ',', value_hint = ValueHint::Other)]
198    scripts: Vec<String>,
199
200    /// Require fonts to contain these table tags
201    #[arg(short = 'T', long = "tables", value_delimiter = ',', value_hint = ValueHint::Other)]
202    tables: Vec<String>,
203
204    /// Regex patterns that must match at least one font name
205    #[arg(short = 'n', long = "name", value_hint = ValueHint::Other)]
206    name_patterns: Vec<String>,
207
208    /// Regex patterns that must match creator info (copyright, trademark, manufacturer, designer, description, URLs, license)
209    #[arg(short = 'c', long = "creator", value_hint = ValueHint::Other)]
210    creator_patterns: Vec<String>,
211
212    /// Regex patterns that must match license info (copyright, license description, license URL)
213    #[arg(short = 'l', long = "license", value_hint = ValueHint::Other)]
214    license_patterns: Vec<String>,
215
216    /// Unicode codepoints or ranges (e.g. U+0041-U+0044,B)
217    #[arg(short = 'u', long = "codepoints", value_delimiter = ',', value_hint = ValueHint::Other)]
218    codepoints: Vec<String>,
219
220    /// Require fonts to cover this text sample
221    #[arg(short = 't', long = "text")]
222    text: Option<String>,
223
224    /// Only include variable fonts
225    #[arg(short = 'v', long = "variable", action = ArgAction::SetTrue)]
226    variable: bool,
227
228    /// Match OS/2 weight class (single value like 400 or range like 300-500)
229    #[arg(short = 'w', long = "weight", value_hint = ValueHint::Other)]
230    weight: Option<String>,
231
232    /// Match OS/2 width class (1-9, single value or range)
233    #[arg(short = 'W', long = "width", value_hint = ValueHint::Other)]
234    width: Option<String>,
235
236    /// Match OS/2 family class (major like 8 or major.subclass like 8.11; accepts names like sans)
237    #[arg(long = "family-class", value_hint = ValueHint::Other)]
238    family_class: Option<String>,
239
240    /// Only output the count of matching fonts (useful for scripting)
241    #[arg(long = "count", action = ArgAction::SetTrue, conflicts_with_all = ["json", "ndjson", "paths", "columns"])]
242    count_only: bool,
243
244    #[command(flatten)]
245    output: OutputArgs,
246}
247
248#[derive(Debug, Args)]
249struct CacheCleanArgs {
250    /// Override cache location (defaults to ~/.cache/typg/cache.json)
251    #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
252    cache_path: Option<PathBuf>,
253
254    /// Use high-performance LMDB index instead of JSON cache (requires hpindex feature)
255    #[arg(long = "index", action = ArgAction::SetTrue)]
256    use_index: bool,
257
258    /// Override index directory (defaults to ~/.cache/typg/index/)
259    #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
260    index_path: Option<PathBuf>,
261}
262
263#[derive(Debug, Args)]
264struct CacheInfoArgs {
265    /// Override cache location (defaults to ~/.cache/typg/cache.json)
266    #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
267    cache_path: Option<PathBuf>,
268
269    /// Use high-performance LMDB index instead of JSON cache (requires hpindex feature)
270    #[arg(long = "index", action = ArgAction::SetTrue)]
271    use_index: bool,
272
273    /// Override index directory (defaults to ~/.cache/typg/index/)
274    #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
275    index_path: Option<PathBuf>,
276
277    /// Output as JSON
278    #[arg(long = "json", action = ArgAction::SetTrue)]
279    json: bool,
280}
281
282#[derive(Debug, Args)]
283struct FindArgs {
284    /// Paths to search (directories or files)
285    #[arg(
286        value_hint = ValueHint::DirPath,
287        required_unless_present_any = ["system_fonts", "stdin_paths"]
288    )]
289    paths: Vec<PathBuf>,
290
291    /// Read newline-delimited paths from STDIN
292    #[arg(long = "stdin-paths", action = ArgAction::SetTrue)]
293    stdin_paths: bool,
294
295    /// Include common system font directories automatically
296    #[arg(long = "system-fonts", action = ArgAction::SetTrue)]
297    system_fonts: bool,
298
299    /// Require fonts to define these axis tags
300    #[arg(short = 'a', long = "axes", value_delimiter = ',', value_hint = ValueHint::Other)]
301    axes: Vec<String>,
302
303    /// Require fonts to define these OpenType feature tags
304    #[arg(short = 'f', long = "features", value_delimiter = ',', value_hint = ValueHint::Other)]
305    features: Vec<String>,
306
307    /// Require fonts to cover these script tags
308    #[arg(short = 's', long = "scripts", value_delimiter = ',', value_hint = ValueHint::Other)]
309    scripts: Vec<String>,
310
311    /// Require fonts to contain these table tags
312    #[arg(short = 'T', long = "tables", value_delimiter = ',', value_hint = ValueHint::Other)]
313    tables: Vec<String>,
314
315    /// Regex patterns that must match at least one font name
316    #[arg(short = 'n', long = "name", value_hint = ValueHint::Other)]
317    name_patterns: Vec<String>,
318
319    /// Regex patterns that must match creator info (copyright, trademark, manufacturer, designer, description, URLs, license)
320    #[arg(short = 'c', long = "creator", value_hint = ValueHint::Other)]
321    creator_patterns: Vec<String>,
322
323    /// Regex patterns that must match license info (copyright, license description, license URL)
324    #[arg(short = 'l', long = "license", value_hint = ValueHint::Other)]
325    license_patterns: Vec<String>,
326
327    /// Unicode codepoints or ranges (e.g. U+0041-U+0044,B)
328    #[arg(short = 'u', long = "codepoints", value_delimiter = ',', value_hint = ValueHint::Other)]
329    codepoints: Vec<String>,
330
331    /// Require fonts to cover this text sample
332    #[arg(short = 't', long = "text")]
333    text: Option<String>,
334
335    /// Only include variable fonts
336    #[arg(short = 'v', long = "variable", action = ArgAction::SetTrue)]
337    variable: bool,
338
339    /// Match OS/2 weight class (single value like 400 or range like 300-500)
340    #[arg(short = 'w', long = "weight", value_hint = ValueHint::Other)]
341    weight: Option<String>,
342
343    /// Match OS/2 width class (1-9, single value or range)
344    #[arg(short = 'W', long = "width", value_hint = ValueHint::Other)]
345    width: Option<String>,
346
347    /// Match OS/2 family class (major like 8 or major.subclass like 8.11; accepts names like sans)
348    #[arg(long = "family-class", value_hint = ValueHint::Other)]
349    family_class: Option<String>,
350
351    /// Follow symlinks while walking paths
352    #[arg(long = "follow-symlinks", action = ArgAction::SetTrue)]
353    follow_symlinks: bool,
354
355    /// Number of worker threads (defaults to CPU count)
356    #[arg(short = 'J', long = "jobs", value_hint = ValueHint::Other)]
357    jobs: Option<usize>,
358
359    /// Emit a single JSON array
360    #[arg(long = "json", action = ArgAction::SetTrue, conflicts_with = "ndjson")]
361    json: bool,
362
363    /// Emit newline-delimited JSON
364    #[arg(long = "ndjson", action = ArgAction::SetTrue)]
365    ndjson: bool,
366
367    /// Emit newline-delimited font paths (with #index for TTC)
368    #[arg(
369        long = "paths",
370        action = ArgAction::SetTrue,
371        conflicts_with_all = ["json", "ndjson", "columns"]
372    )]
373    paths_only: bool,
374
375    /// Format output as padded columns
376    #[arg(long = "columns", action = ArgAction::SetTrue)]
377    columns: bool,
378
379    /// Show individual TTC/OTC collection faces (path#index); default deduplicates by path
380    #[arg(long = "collections", action = ArgAction::SetTrue)]
381    collections: bool,
382
383    /// Only output the count of matching fonts (useful for scripting)
384    #[arg(long = "count", action = ArgAction::SetTrue, conflicts_with_all = ["json", "ndjson", "paths_only", "columns"])]
385    count_only: bool,
386
387    /// Control colorized output (auto|always|never)
388    #[arg(long = "color", default_value_t = ColorChoice::Auto, value_enum)]
389    color: ColorChoice,
390}
391
392#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
393enum ColorChoice {
394    Auto,
395    Always,
396    Never,
397}
398
399/// Parse CLI arguments and dispatch to the appropriate handler.
400pub fn run() -> Result<()> {
401    let cli = Cli::parse();
402    let quiet = cli.quiet;
403
404    match cli.command {
405        Command::Find(args) => run_find(*args),
406        Command::Cache(cmd) => match cmd {
407            CacheCommand::Add(args) => run_cache_add(args, quiet),
408            CacheCommand::List(args) => run_cache_list(args),
409            CacheCommand::Find(args) => run_cache_find(*args),
410            CacheCommand::Clean(args) => run_cache_clean(args, quiet),
411            CacheCommand::Info(args) => run_cache_info(args),
412        },
413        Command::Serve(args) => run_serve(args),
414    }
415}
416
417/// Search directories for matching fonts, streaming results where possible.
418fn run_find(args: FindArgs) -> Result<()> {
419    if matches!(args.jobs, Some(0)) {
420        return Err(anyhow!("--jobs must be at least 1"));
421    }
422
423    let stdin = io::stdin();
424    let paths = gather_paths(
425        &args.paths,
426        args.stdin_paths,
427        args.system_fonts,
428        stdin.lock(),
429    )?;
430    let query = build_query(&args)?;
431    let opts = SearchOptions {
432        follow_symlinks: args.follow_symlinks,
433        jobs: args.jobs,
434    };
435
436    let output = OutputFormat::from_find(&args);
437
438    // Formats that need all results before writing
439    if args.count_only || output.json || output.columns {
440        let matches = search(&paths, &query, &opts)?;
441        if args.count_only {
442            println!("{}", matches.len());
443            return Ok(());
444        }
445        return write_matches(&matches, &output);
446    }
447
448    // Stream results to stdout as they're found
449    let (tx, rx) = std::sync::mpsc::channel();
450
451    std::thread::scope(|s| -> Result<()> {
452        let handle = s.spawn(|| search_streaming(&paths, &query, &opts, tx));
453
454        let stdout = io::stdout();
455        let mut w = stdout.lock();
456        let use_color = match output.color {
457            ColorChoice::Always => true,
458            ColorChoice::Never => false,
459            ColorChoice::Auto => w.is_terminal(),
460        };
461
462        let mut seen = std::collections::HashSet::new();
463        for m in rx {
464            if output.paths {
465                if output.collections {
466                    let _ = writeln!(w, "{}", m.source.path_with_index());
467                } else if seen.insert(m.source.path.clone()) {
468                    let _ = writeln!(w, "{}", m.source.path.display());
469                }
470            } else if output.ndjson {
471                if let Ok(line) = serde_json::to_string(&m) {
472                    let _ = w.write_all(line.as_bytes());
473                    let _ = w.write_all(b"\n");
474                }
475            } else if output.collections {
476                let rendered = render_path(&m, use_color, true);
477                let _ = writeln!(w, "{rendered}");
478            } else if seen.insert(m.source.path.clone()) {
479                let rendered = render_path(&m, use_color, false);
480                let _ = writeln!(w, "{rendered}");
481            }
482        }
483
484        match handle.join() {
485            Ok(result) => result,
486            Err(_) => Err(anyhow!("search thread panicked")),
487        }
488    })
489}
490
491fn run_serve(args: ServeArgs) -> Result<()> {
492    let runtime = Builder::new_multi_thread().enable_all().build()?;
493    runtime.block_on(server::serve(&args.bind))
494}
495
496#[derive(Clone, Debug)]
497struct OutputFormat {
498    json: bool,
499    ndjson: bool,
500    paths: bool,
501    columns: bool,
502    collections: bool,
503    color: ColorChoice,
504}
505
506impl OutputFormat {
507    fn from_find(args: &FindArgs) -> Self {
508        Self {
509            json: args.json,
510            ndjson: args.ndjson,
511            paths: args.paths_only,
512            columns: args.columns,
513            collections: args.collections,
514            color: args.color,
515        }
516    }
517
518    fn from_output(args: &OutputArgs) -> Self {
519        Self {
520            json: args.json,
521            ndjson: args.ndjson,
522            paths: args.paths,
523            columns: args.columns,
524            collections: args.collections,
525            color: args.color,
526        }
527    }
528}
529
530fn write_matches(matches: &[TypgFontFaceMatch], format: &OutputFormat) -> Result<()> {
531    let stdout = io::stdout();
532    let mut handle = stdout.lock();
533    let use_color = match format.color {
534        ColorChoice::Always => true,
535        ColorChoice::Never => false,
536        ColorChoice::Auto => handle.is_terminal(),
537    };
538
539    if format.paths {
540        write_paths(matches, &mut handle, format.collections)?;
541    } else if format.ndjson {
542        write_ndjson(matches, &mut handle)?;
543    } else if format.json {
544        write_json_pretty(matches, &mut handle)?;
545    } else if format.columns {
546        write_columns(matches, &mut handle, use_color, format.collections)?;
547    } else {
548        write_plain(matches, &mut handle, use_color, format.collections)?;
549    }
550
551    Ok(())
552}
553
554/// Build a `Query` from the CLI filter arguments.
555fn build_query(args: &FindArgs) -> Result<Query> {
556    build_query_from_parts(
557        &args.axes,
558        &args.features,
559        &args.scripts,
560        &args.tables,
561        &args.name_patterns,
562        &args.creator_patterns,
563        &args.license_patterns,
564        &args.codepoints,
565        &args.text,
566        args.variable,
567        &args.weight,
568        &args.width,
569        &args.family_class,
570    )
571}
572
573#[allow(clippy::too_many_arguments)]
574fn build_query_from_parts(
575    axes: &[String],
576    features: &[String],
577    scripts: &[String],
578    tables: &[String],
579    name_patterns: &[String],
580    creator_patterns: &[String],
581    license_patterns: &[String],
582    codepoints: &[String],
583    text: &Option<String>,
584    variable: bool,
585    weight: &Option<String>,
586    width: &Option<String>,
587    family_class: &Option<String>,
588) -> Result<Query> {
589    let axes = parse_tag_list(axes)?;
590    let features = parse_tag_list(features)?;
591    let scripts = parse_tag_list(scripts)?;
592    let tables = parse_tag_list(tables)?;
593    let name_patterns = compile_patterns(name_patterns)?;
594    let creator_patterns = compile_patterns(creator_patterns)?;
595    let license_patterns = compile_patterns(license_patterns)?;
596    let mut codepoints = parse_codepoints(codepoints)?;
597    let weight_range = parse_optional_range(weight)?;
598    let width_range = parse_optional_range(width)?;
599    let family_class = parse_optional_family_class(family_class)?;
600
601    if let Some(text) = text {
602        codepoints.extend(text.chars());
603    }
604
605    dedup_chars(&mut codepoints);
606
607    Ok(Query::new()
608        .with_axes(axes)
609        .with_features(features)
610        .with_scripts(scripts)
611        .with_tables(tables)
612        .with_name_patterns(name_patterns)
613        .with_creator_patterns(creator_patterns)
614        .with_license_patterns(license_patterns)
615        .with_codepoints(codepoints)
616        .require_variable(variable)
617        .with_weight_range(weight_range)
618        .with_width_range(width_range)
619        .with_family_class(family_class))
620}
621
622fn dedup_chars(cps: &mut Vec<char>) {
623    cps.sort();
624    cps.dedup();
625}
626
627fn compile_patterns(patterns: &[String]) -> Result<Vec<Regex>> {
628    patterns
629        .iter()
630        .map(|p| Regex::new(p).with_context(|| format!("invalid regex: {p}")))
631        .collect()
632}
633
634fn parse_codepoints(raw: &[String]) -> Result<Vec<char>> {
635    let mut cps = Vec::new();
636    for chunk in raw {
637        cps.extend(parse_codepoint_list(chunk)?);
638    }
639    Ok(cps)
640}
641
642fn parse_optional_range(raw: &Option<String>) -> Result<Option<RangeInclusive<u16>>> {
643    match raw {
644        Some(value) => Ok(Some(parse_u16_range(value)?)),
645        None => Ok(None),
646    }
647}
648
649fn parse_optional_family_class(raw: &Option<String>) -> Result<Option<FamilyClassFilter>> {
650    match raw {
651        Some(value) => Ok(Some(parse_family_class(value)?)),
652        None => Ok(None),
653    }
654}
655
656fn gather_paths(
657    raw_paths: &[PathBuf],
658    read_stdin: bool,
659    include_system: bool,
660    mut stdin: impl BufRead,
661) -> Result<Vec<PathBuf>> {
662    let mut paths = Vec::new();
663
664    if read_stdin {
665        paths.extend(read_paths_from(&mut stdin)?);
666    }
667
668    for path in raw_paths {
669        if path == Path::new("-") {
670            paths.extend(read_paths_from(&mut stdin)?);
671        } else {
672            paths.push(path.clone());
673        }
674    }
675
676    if include_system {
677        paths.extend(system_font_roots()?);
678    }
679
680    if paths.is_empty() {
681        return Err(anyhow!("no search paths provided"));
682    }
683
684    Ok(paths)
685}
686
687fn read_paths_from(reader: &mut impl BufRead) -> Result<Vec<PathBuf>> {
688    let mut buf = String::new();
689    let mut paths = Vec::new();
690
691    loop {
692        buf.clear();
693        let read = reader.read_line(&mut buf)?;
694        if read == 0 {
695            break;
696        }
697
698        let trimmed = buf.trim();
699        if !trimmed.is_empty() {
700            paths.push(PathBuf::from(trimmed));
701        }
702    }
703
704    Ok(paths)
705}
706
707fn system_font_roots() -> Result<Vec<PathBuf>> {
708    if let Ok(raw) = env::var("TYPOG_SYSTEM_FONT_DIRS") {
709        let mut overrides: Vec<PathBuf> = raw
710            .split([':', ';'])
711            .filter(|s| !s.is_empty())
712            .map(PathBuf::from)
713            .filter(|p| p.exists())
714            .collect();
715
716        overrides.sort();
717        overrides.dedup();
718
719        return if overrides.is_empty() {
720            Err(anyhow!("TYPOG_SYSTEM_FONT_DIRS is set but no paths exist"))
721        } else {
722            Ok(overrides)
723        };
724    }
725
726    let mut candidates: Vec<PathBuf> = Vec::new();
727
728    #[cfg(target_os = "macos")]
729    {
730        candidates.push(PathBuf::from("/System/Library/Fonts"));
731        candidates.push(PathBuf::from("/Library/Fonts"));
732        if let Some(home) = env::var_os("HOME") {
733            candidates.push(PathBuf::from(home).join("Library/Fonts"));
734        }
735    }
736
737    #[cfg(target_os = "linux")]
738    {
739        candidates.push(PathBuf::from("/usr/share/fonts"));
740        candidates.push(PathBuf::from("/usr/local/share/fonts"));
741        if let Some(home) = env::var_os("HOME") {
742            candidates.push(PathBuf::from(home).join(".local/share/fonts"));
743        }
744    }
745
746    #[cfg(target_os = "windows")]
747    {
748        if let Some(system_root) = env::var_os("SYSTEMROOT") {
749            candidates.push(PathBuf::from(system_root).join("Fonts"));
750        }
751        if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
752            candidates.push(PathBuf::from(local_appdata).join("Microsoft/Windows/Fonts"));
753        }
754    }
755
756    candidates.retain(|p| p.exists());
757    candidates.sort();
758    candidates.dedup();
759
760    if candidates.is_empty() {
761        return Err(anyhow!(
762            "no system font directories found for this platform"
763        ));
764    }
765
766    Ok(candidates)
767}
768
769fn write_plain(
770    matches: &[TypgFontFaceMatch],
771    mut w: impl Write,
772    color: bool,
773    collections: bool,
774) -> Result<()> {
775    if collections {
776        for item in matches {
777            let rendered = render_path(item, color, true);
778            writeln!(w, "{rendered}")?;
779        }
780    } else {
781        let mut seen = std::collections::HashSet::new();
782        for item in matches {
783            if seen.insert(item.source.path.clone()) {
784                let rendered = render_path(item, color, false);
785                writeln!(w, "{rendered}")?;
786            }
787        }
788    }
789    Ok(())
790}
791
792fn write_paths(
793    matches: &[TypgFontFaceMatch],
794    mut w: impl Write,
795    collections: bool,
796) -> Result<()> {
797    if collections {
798        for item in matches {
799            writeln!(w, "{}", item.source.path_with_index())?;
800        }
801    } else {
802        let mut seen = std::collections::HashSet::new();
803        for item in matches {
804            if seen.insert(item.source.path.clone()) {
805                writeln!(w, "{}", item.source.path.display())?;
806            }
807        }
808    }
809    Ok(())
810}
811
812fn write_columns(
813    matches: &[TypgFontFaceMatch],
814    mut w: impl Write,
815    color: bool,
816    collections: bool,
817) -> Result<()> {
818    let mut rows: Vec<(String, String, String)> = matches
819        .iter()
820        .map(|m| {
821            let path = if collections {
822                m.source.path_with_index()
823            } else {
824                m.source.path.display().to_string()
825            };
826            let name = m
827                .metadata
828                .names
829                .first()
830                .cloned()
831                .unwrap_or_else(|| "(unnamed)".to_string());
832
833            let tags = format!(
834                "axes:{:<2} feats:{:<2} scripts:{:<2} tables:{:<2}{}",
835                m.metadata.axis_tags.len(),
836                m.metadata.feature_tags.len(),
837                m.metadata.script_tags.len(),
838                m.metadata.table_tags.len(),
839                if m.metadata.is_variable { " var" } else { "" },
840            );
841
842            (path, name, tags)
843        })
844        .collect();
845
846    let path_width = rows
847        .iter()
848        .map(|r| r.0.len())
849        .max()
850        .unwrap_or(0)
851        .clamp(0, 120);
852    let name_width = rows
853        .iter()
854        .map(|r| r.1.len())
855        .max()
856        .unwrap_or(0)
857        .clamp(0, 80);
858
859    for (path, name, tags) in rows.drain(..) {
860        let padded_path = format!("{:<path_width$}", path);
861        let padded_name = format!("{:<name_width$}", name);
862        let rendered_path = apply_color(&padded_path, color, AnsiColor::Cyan);
863        let rendered_name = apply_color(&padded_name, color, AnsiColor::Yellow);
864        let rendered_tags = apply_color(&tags, color, AnsiColor::Green);
865
866        writeln!(w, "{rendered_path}  {rendered_name}  {rendered_tags}")?;
867    }
868
869    Ok(())
870}
871
872#[derive(Copy, Clone)]
873enum AnsiColor {
874    Cyan,
875    Yellow,
876    Green,
877}
878
879fn apply_color(text: &str, color: bool, code: AnsiColor) -> String {
880    if !color {
881        return text.to_string();
882    }
883
884    let code_str = match code {
885        AnsiColor::Cyan => "36",
886        AnsiColor::Yellow => "33",
887        AnsiColor::Green => "32",
888    };
889
890    format!("\u{1b}[{}m{}\u{1b}[0m", code_str, text)
891}
892
893fn render_path(item: &TypgFontFaceMatch, color: bool, collections: bool) -> String {
894    let rendered = if collections {
895        item.source.path_with_index()
896    } else {
897        item.source.path.display().to_string()
898    };
899    apply_color(&rendered, color, AnsiColor::Cyan)
900}
901
902fn run_cache_add(args: CacheAddArgs, quiet: bool) -> Result<()> {
903    if matches!(args.jobs, Some(0)) {
904        return Err(anyhow!("--jobs must be at least 1"));
905    }
906
907    #[cfg(feature = "hpindex")]
908    if args.use_index {
909        return run_cache_add_index(args, quiet);
910    }
911
912    #[cfg(not(feature = "hpindex"))]
913    if args.use_index {
914        return Err(anyhow!(
915            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
916        ));
917    }
918
919    let stdin = io::stdin();
920    let paths = gather_paths(
921        &args.paths,
922        args.stdin_paths,
923        args.system_fonts,
924        stdin.lock(),
925    )?;
926
927    let opts = SearchOptions {
928        follow_symlinks: args.follow_symlinks,
929        jobs: args.jobs,
930    };
931    let additions = search(&paths, &Query::new(), &opts)?;
932
933    let cache_path = resolve_cache_path(&args.cache_path)?;
934    let existing = if cache_path.exists() {
935        load_cache(&cache_path)?
936    } else {
937        Vec::new()
938    };
939
940    let merged = merge_entries(existing, additions);
941    write_cache(&cache_path, &merged)?;
942
943    if !quiet {
944        eprintln!(
945            "cached {} font faces at {}",
946            merged.len(),
947            cache_path.display()
948        );
949    }
950    Ok(())
951}
952
953fn run_cache_list(args: CacheListArgs) -> Result<()> {
954    #[cfg(feature = "hpindex")]
955    if args.use_index {
956        return run_cache_list_index(args);
957    }
958
959    #[cfg(not(feature = "hpindex"))]
960    if args.use_index {
961        return Err(anyhow!(
962            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
963        ));
964    }
965
966    let cache_path = resolve_cache_path(&args.cache_path)?;
967    let entries = load_cache(&cache_path)?;
968    let output = OutputFormat::from_output(&args.output);
969    write_matches(&entries, &output)
970}
971
972fn run_cache_find(args: CacheFindArgs) -> Result<()> {
973    #[cfg(feature = "hpindex")]
974    if args.use_index {
975        return run_cache_find_index(args);
976    }
977
978    #[cfg(not(feature = "hpindex"))]
979    if args.use_index {
980        return Err(anyhow!(
981            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
982        ));
983    }
984
985    let cache_path = resolve_cache_path(&args.cache_path)?;
986    let entries = load_cache(&cache_path)?;
987    let query = build_query_from_parts(
988        &args.axes,
989        &args.features,
990        &args.scripts,
991        &args.tables,
992        &args.name_patterns,
993        &args.creator_patterns,
994        &args.license_patterns,
995        &args.codepoints,
996        &args.text,
997        args.variable,
998        &args.weight,
999        &args.width,
1000        &args.family_class,
1001    )?;
1002
1003    let matches = filter_cached(&entries, &query);
1004
1005    if args.count_only {
1006        println!("{}", matches.len());
1007        return Ok(());
1008    }
1009
1010    let output = OutputFormat::from_output(&args.output);
1011    write_matches(&matches, &output)
1012}
1013
1014fn run_cache_clean(args: CacheCleanArgs, quiet: bool) -> Result<()> {
1015    #[cfg(feature = "hpindex")]
1016    if args.use_index {
1017        return run_cache_clean_index(args, quiet);
1018    }
1019
1020    #[cfg(not(feature = "hpindex"))]
1021    if args.use_index {
1022        return Err(anyhow!(
1023            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1024        ));
1025    }
1026
1027    let cache_path = resolve_cache_path(&args.cache_path)?;
1028    let entries = load_cache(&cache_path)?;
1029    let before = entries.len();
1030    let pruned = prune_missing(entries);
1031    let after = pruned.len();
1032
1033    write_cache(&cache_path, &pruned)?;
1034    if !quiet {
1035        eprintln!(
1036            "removed {} missing entries ({} → {})",
1037            before.saturating_sub(after),
1038            before,
1039            after
1040        );
1041    }
1042    Ok(())
1043}
1044
1045fn run_cache_info(args: CacheInfoArgs) -> Result<()> {
1046    #[cfg(feature = "hpindex")]
1047    if args.use_index {
1048        return run_cache_info_index(args);
1049    }
1050
1051    #[cfg(not(feature = "hpindex"))]
1052    if args.use_index {
1053        return Err(anyhow!(
1054            "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1055        ));
1056    }
1057
1058    let cache_path = resolve_cache_path(&args.cache_path)?;
1059
1060    if !cache_path.exists() {
1061        if args.json {
1062            println!(r#"{{"exists":false,"path":"{}"}}"#, cache_path.display());
1063        } else {
1064            println!("Cache does not exist at {}", cache_path.display());
1065        }
1066        return Ok(());
1067    }
1068
1069    let entries = load_cache(&cache_path)?;
1070    let file_meta = fs::metadata(&cache_path)?;
1071    let size_bytes = file_meta.len();
1072
1073    if args.json {
1074        let info = serde_json::json!({
1075            "exists": true,
1076            "path": cache_path.display().to_string(),
1077            "type": "json",
1078            "entries": entries.len(),
1079            "size_bytes": size_bytes,
1080        });
1081        println!("{}", serde_json::to_string_pretty(&info)?);
1082    } else {
1083        println!("Cache: {}", cache_path.display());
1084        println!("Type:  JSON");
1085        println!("Fonts: {}", entries.len());
1086        println!("Size:  {} bytes", size_bytes);
1087    }
1088
1089    Ok(())
1090}
1091
1092fn resolve_cache_path(custom: &Option<PathBuf>) -> Result<PathBuf> {
1093    if let Some(path) = custom {
1094        return Ok(path.clone());
1095    }
1096
1097    if let Ok(env_override) = env::var("TYPOG_CACHE_PATH") {
1098        return Ok(PathBuf::from(env_override));
1099    }
1100
1101    #[cfg(target_os = "windows")]
1102    {
1103        if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1104            return Ok(PathBuf::from(local_appdata).join("typg").join("cache.json"));
1105        }
1106        if let Some(home) = env::var_os("HOME") {
1107            return Ok(PathBuf::from(home).join("AppData/Local/typg/cache.json"));
1108        }
1109    }
1110
1111    #[cfg(not(target_os = "windows"))]
1112    {
1113        if let Some(xdg) = env::var_os("XDG_CACHE_HOME") {
1114            return Ok(PathBuf::from(xdg).join("typg").join("cache.json"));
1115        }
1116        if let Some(home) = env::var_os("HOME") {
1117            return Ok(PathBuf::from(home)
1118                .join(".cache")
1119                .join("typg")
1120                .join("cache.json"));
1121        }
1122    }
1123
1124    Err(anyhow!(
1125        "--cache-path is required because no cache directory could be detected"
1126    ))
1127}
1128
1129/// Resolve the index directory path.
1130#[cfg_attr(not(feature = "hpindex"), allow(dead_code))]
1131fn resolve_index_path(custom: &Option<PathBuf>) -> Result<PathBuf> {
1132    if let Some(path) = custom {
1133        return Ok(path.clone());
1134    }
1135
1136    if let Ok(env_override) = env::var("TYPOG_INDEX_PATH") {
1137        return Ok(PathBuf::from(env_override));
1138    }
1139
1140    #[cfg(target_os = "windows")]
1141    {
1142        if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1143            return Ok(PathBuf::from(local_appdata).join("typg").join("index"));
1144        }
1145        if let Some(home) = env::var_os("HOME") {
1146            return Ok(PathBuf::from(home).join("AppData/Local/typg/index"));
1147        }
1148    }
1149
1150    #[cfg(not(target_os = "windows"))]
1151    {
1152        if let Some(xdg) = env::var_os("XDG_CACHE_HOME") {
1153            return Ok(PathBuf::from(xdg).join("typg").join("index"));
1154        }
1155        if let Some(home) = env::var_os("HOME") {
1156            return Ok(PathBuf::from(home)
1157                .join(".cache")
1158                .join("typg")
1159                .join("index"));
1160        }
1161    }
1162
1163    Err(anyhow!(
1164        "--index-path is required because no cache directory could be detected"
1165    ))
1166}
1167
1168/// Load cached font entries from disk. Tries JSON array first, falls back to NDJSON.
1169fn load_cache(path: &Path) -> Result<Vec<TypgFontFaceMatch>> {
1170    let file = File::open(path).with_context(|| format!("opening cache {}", path.display()))?;
1171    let reader = BufReader::new(file);
1172
1173    match serde_json::from_reader(reader) {
1174        Ok(entries) => Ok(entries),
1175        Err(_) => {
1176            // Fall back to NDJSON parsing for forward compatibility
1177            let file =
1178                File::open(path).with_context(|| format!("re-opening cache {}", path.display()))?;
1179            let reader = BufReader::new(file);
1180            let stream = Deserializer::from_reader(reader).into_iter::<TypgFontFaceMatch>();
1181            let mut entries = Vec::new();
1182            for item in stream {
1183                entries.push(item?);
1184            }
1185            Ok(entries)
1186        }
1187    }
1188}
1189
1190/// Write font entries to the cache file as pretty-printed JSON.
1191fn write_cache(path: &Path, entries: &[TypgFontFaceMatch]) -> Result<()> {
1192    if let Some(parent) = path.parent() {
1193        fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
1194    }
1195
1196    let file = File::create(path).with_context(|| format!("creating cache {}", path.display()))?;
1197    let mut writer = BufWriter::new(file);
1198    serde_json::to_writer_pretty(&mut writer, entries)
1199        .with_context(|| format!("writing cache {}", path.display()))?;
1200    writer.flush()?;
1201    Ok(())
1202}
1203
1204fn merge_entries(
1205    existing: Vec<TypgFontFaceMatch>,
1206    additions: Vec<TypgFontFaceMatch>,
1207) -> Vec<TypgFontFaceMatch> {
1208    let mut map: HashMap<(PathBuf, Option<u32>), TypgFontFaceMatch> = HashMap::new();
1209
1210    for entry in existing.into_iter().chain(additions.into_iter()) {
1211        map.insert(cache_key(&entry), entry);
1212    }
1213
1214    let mut merged: Vec<TypgFontFaceMatch> = map.into_values().collect();
1215    sort_entries(&mut merged);
1216    merged
1217}
1218
1219fn prune_missing(entries: Vec<TypgFontFaceMatch>) -> Vec<TypgFontFaceMatch> {
1220    let mut pruned: Vec<TypgFontFaceMatch> = entries
1221        .into_iter()
1222        .filter(|entry| entry.source.path.exists())
1223        .collect();
1224    sort_entries(&mut pruned);
1225    pruned
1226}
1227
1228fn sort_entries(entries: &mut [TypgFontFaceMatch]) {
1229    entries.sort_by(|a, b| {
1230        a.source
1231            .path
1232            .cmp(&b.source.path)
1233            .then_with(|| a.source.ttc_index.cmp(&b.source.ttc_index))
1234    });
1235}
1236
1237fn cache_key(entry: &TypgFontFaceMatch) -> (PathBuf, Option<u32>) {
1238    (entry.source.path.clone(), entry.source.ttc_index)
1239}
1240
1241// ============================================================================
1242// High-performance index implementations (LMDB + Roaring Bitmaps)
1243// ============================================================================
1244
1245#[cfg(feature = "hpindex")]
1246fn run_cache_add_index(args: CacheAddArgs, quiet: bool) -> Result<()> {
1247    use std::time::SystemTime;
1248
1249    let stdin = io::stdin();
1250    let paths = gather_paths(
1251        &args.paths,
1252        args.stdin_paths,
1253        args.system_fonts,
1254        stdin.lock(),
1255    )?;
1256
1257    let index_path = resolve_index_path(&args.index_path)?;
1258    let index = FontIndex::open(&index_path)?;
1259
1260    // Use the existing search pipeline to discover and extract metadata.
1261    let opts = SearchOptions {
1262        follow_symlinks: args.follow_symlinks,
1263        jobs: args.jobs,
1264    };
1265    let additions = search(&paths, &Query::new(), &opts)?;
1266
1267    // Write to index in a single transaction.
1268    let mut writer = index.writer()?;
1269    let mut added = 0usize;
1270    let mut skipped = 0usize;
1271
1272    for entry in additions {
1273        // Get file mtime for incremental update detection.
1274        let mtime = entry
1275            .source
1276            .path
1277            .metadata()
1278            .and_then(|m| m.modified())
1279            .unwrap_or(SystemTime::UNIX_EPOCH);
1280
1281        // Check if update is needed.
1282        if !writer.needs_update(&entry.source.path, mtime)? {
1283            skipped += 1;
1284            continue;
1285        }
1286
1287        writer.add_font(
1288            &entry.source.path,
1289            entry.source.ttc_index,
1290            mtime,
1291            entry.metadata.names.clone(),
1292            &entry.metadata.axis_tags,
1293            &entry.metadata.feature_tags,
1294            &entry.metadata.script_tags,
1295            &entry.metadata.table_tags,
1296            &entry.metadata.codepoints,
1297            entry.metadata.is_variable,
1298            entry.metadata.weight_class,
1299            entry.metadata.width_class,
1300            entry.metadata.family_class,
1301        )?;
1302        added += 1;
1303    }
1304
1305    writer.commit()?;
1306
1307    if !quiet {
1308        let total = index.count()?;
1309        eprintln!(
1310            "indexed {} font faces at {} (added: {}, skipped: {})",
1311            total,
1312            index_path.display(),
1313            added,
1314            skipped
1315        );
1316    }
1317
1318    Ok(())
1319}
1320
1321#[cfg(feature = "hpindex")]
1322fn run_cache_list_index(args: CacheListArgs) -> Result<()> {
1323    let index_path = resolve_index_path(&args.index_path)?;
1324    let index = FontIndex::open(&index_path)?;
1325    let reader = index.reader()?;
1326    let entries = reader.list_all()?;
1327    let output = OutputFormat::from_output(&args.output);
1328    write_matches(&entries, &output)
1329}
1330
1331#[cfg(feature = "hpindex")]
1332fn run_cache_find_index(args: CacheFindArgs) -> Result<()> {
1333    let index_path = resolve_index_path(&args.index_path)?;
1334    let index = FontIndex::open(&index_path)?;
1335
1336    let query = build_query_from_parts(
1337        &args.axes,
1338        &args.features,
1339        &args.scripts,
1340        &args.tables,
1341        &args.name_patterns,
1342        &args.creator_patterns,
1343        &args.license_patterns,
1344        &args.codepoints,
1345        &args.text,
1346        args.variable,
1347        &args.weight,
1348        &args.width,
1349        &args.family_class,
1350    )?;
1351
1352    let reader = index.reader()?;
1353    let matches = reader.find(&query)?;
1354
1355    if args.count_only {
1356        println!("{}", matches.len());
1357        return Ok(());
1358    }
1359
1360    let output = OutputFormat::from_output(&args.output);
1361    write_matches(&matches, &output)
1362}
1363
1364#[cfg(feature = "hpindex")]
1365fn run_cache_clean_index(args: CacheCleanArgs, quiet: bool) -> Result<()> {
1366    let index_path = resolve_index_path(&args.index_path)?;
1367    let index = FontIndex::open(&index_path)?;
1368
1369    let mut writer = index.writer()?;
1370    let (before, after) = writer.prune_missing()?;
1371    writer.commit()?;
1372
1373    if !quiet {
1374        eprintln!(
1375            "removed {} missing entries ({} → {})",
1376            before.saturating_sub(after),
1377            before,
1378            after
1379        );
1380    }
1381    Ok(())
1382}
1383
1384#[cfg(feature = "hpindex")]
1385fn run_cache_info_index(args: CacheInfoArgs) -> Result<()> {
1386    let index_path = resolve_index_path(&args.index_path)?;
1387
1388    if !index_path.exists() {
1389        if args.json {
1390            println!(r#"{{"exists":false,"path":"{}"}}"#, index_path.display());
1391        } else {
1392            println!("Index does not exist at {}", index_path.display());
1393        }
1394        return Ok(());
1395    }
1396
1397    let index = FontIndex::open(&index_path)?;
1398    let count = index.count()?;
1399
1400    // Calculate total directory size (non-recursive, LMDB is flat).
1401    let size_bytes: u64 = fs::read_dir(&index_path)?
1402        .filter_map(|e| e.ok())
1403        .filter_map(|e| e.metadata().ok())
1404        .filter(|m| m.is_file())
1405        .map(|m| m.len())
1406        .sum();
1407
1408    if args.json {
1409        let info = serde_json::json!({
1410            "exists": true,
1411            "path": index_path.display().to_string(),
1412            "type": "lmdb",
1413            "entries": count,
1414            "size_bytes": size_bytes,
1415        });
1416        println!("{}", serde_json::to_string_pretty(&info)?);
1417    } else {
1418        println!("Index: {}", index_path.display());
1419        println!("Type:  LMDB");
1420        println!("Fonts: {}", count);
1421        println!("Size:  {} bytes", size_bytes);
1422    }
1423
1424    Ok(())
1425}
1426
1427#[cfg(test)]
1428mod tests;