Skip to main content

typg_cli/
lib.rs

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