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