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