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