1mod 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#[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 #[arg(short = 'q', long = "quiet", global = true, action = ArgAction::SetTrue)]
41 quiet: bool,
42
43 #[command(subcommand)]
44 command: Command,
45}
46
47#[derive(Debug, Subcommand)]
49enum Command {
50 Find(Box<FindArgs>),
52
53 #[command(subcommand)]
55 Cache(CacheCommand),
56
57 Serve(ServeArgs),
59}
60
61#[derive(Debug, Subcommand)]
63enum CacheCommand {
64 Add(CacheAddArgs),
66 List(CacheListArgs),
68 Find(Box<CacheFindArgs>),
70 Clean(CacheCleanArgs),
72 Info(CacheInfoArgs),
74}
75
76#[derive(Debug, Args)]
78struct ServeArgs {
79 #[arg(long = "bind", default_value = "127.0.0.1:8765")]
81 bind: String,
82}
83
84#[derive(Debug, Args)]
86struct CacheAddArgs {
87 #[arg(
89 value_hint = ValueHint::DirPath,
90 required_unless_present_any = ["system_fonts", "stdin_paths"]
91 )]
92 paths: Vec<PathBuf>,
93
94 #[arg(long = "stdin-paths", action = ArgAction::SetTrue)]
96 stdin_paths: bool,
97
98 #[arg(long = "system-fonts", action = ArgAction::SetTrue)]
100 system_fonts: bool,
101
102 #[arg(long = "follow-symlinks", action = ArgAction::SetTrue)]
104 follow_symlinks: bool,
105
106 #[arg(short = 'J', long = "jobs", value_hint = ValueHint::Other)]
108 jobs: Option<usize>,
109
110 #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
112 cache_path: Option<PathBuf>,
113
114 #[arg(long = "index", action = ArgAction::SetTrue)]
116 use_index: bool,
117
118 #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
120 index_path: Option<PathBuf>,
121}
122
123#[derive(Debug, Args, Clone)]
125struct OutputArgs {
126 #[arg(long = "json", action = ArgAction::SetTrue, conflicts_with = "ndjson")]
128 json: bool,
129
130 #[arg(long = "ndjson", action = ArgAction::SetTrue)]
132 ndjson: bool,
133
134 #[arg(
136 long = "paths",
137 action = ArgAction::SetTrue,
138 conflicts_with_all = ["json", "ndjson", "columns"]
139 )]
140 paths: bool,
141
142 #[arg(long = "columns", action = ArgAction::SetTrue)]
144 columns: bool,
145
146 #[arg(long = "color", default_value_t = ColorChoice::Auto, value_enum)]
148 color: ColorChoice,
149}
150
151#[derive(Debug, Args)]
152struct CacheListArgs {
153 #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
155 cache_path: Option<PathBuf>,
156
157 #[arg(long = "index", action = ArgAction::SetTrue)]
159 use_index: bool,
160
161 #[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 #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
173 cache_path: Option<PathBuf>,
174
175 #[arg(long = "index", action = ArgAction::SetTrue)]
177 use_index: bool,
178
179 #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
181 index_path: Option<PathBuf>,
182
183 #[arg(short = 'a', long = "axes", value_delimiter = ',', value_hint = ValueHint::Other)]
185 axes: Vec<String>,
186
187 #[arg(short = 'f', long = "features", value_delimiter = ',', value_hint = ValueHint::Other)]
189 features: Vec<String>,
190
191 #[arg(short = 's', long = "scripts", value_delimiter = ',', value_hint = ValueHint::Other)]
193 scripts: Vec<String>,
194
195 #[arg(short = 'T', long = "tables", value_delimiter = ',', value_hint = ValueHint::Other)]
197 tables: Vec<String>,
198
199 #[arg(short = 'n', long = "name", value_hint = ValueHint::Other)]
201 name_patterns: Vec<String>,
202
203 #[arg(short = 'c', long = "creator", value_hint = ValueHint::Other)]
205 creator_patterns: Vec<String>,
206
207 #[arg(short = 'l', long = "license", value_hint = ValueHint::Other)]
209 license_patterns: Vec<String>,
210
211 #[arg(short = 'u', long = "codepoints", value_delimiter = ',', value_hint = ValueHint::Other)]
213 codepoints: Vec<String>,
214
215 #[arg(short = 't', long = "text")]
217 text: Option<String>,
218
219 #[arg(short = 'v', long = "variable", action = ArgAction::SetTrue)]
221 variable: bool,
222
223 #[arg(short = 'w', long = "weight", value_hint = ValueHint::Other)]
225 weight: Option<String>,
226
227 #[arg(short = 'W', long = "width", value_hint = ValueHint::Other)]
229 width: Option<String>,
230
231 #[arg(long = "family-class", value_hint = ValueHint::Other)]
233 family_class: Option<String>,
234
235 #[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 #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
247 cache_path: Option<PathBuf>,
248
249 #[arg(long = "index", action = ArgAction::SetTrue)]
251 use_index: bool,
252
253 #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
255 index_path: Option<PathBuf>,
256}
257
258#[derive(Debug, Args)]
259struct CacheInfoArgs {
260 #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
262 cache_path: Option<PathBuf>,
263
264 #[arg(long = "index", action = ArgAction::SetTrue)]
266 use_index: bool,
267
268 #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
270 index_path: Option<PathBuf>,
271
272 #[arg(long = "json", action = ArgAction::SetTrue)]
274 json: bool,
275}
276
277#[derive(Debug, Args)]
278struct FindArgs {
279 #[arg(
281 value_hint = ValueHint::DirPath,
282 required_unless_present_any = ["system_fonts", "stdin_paths"]
283 )]
284 paths: Vec<PathBuf>,
285
286 #[arg(long = "stdin-paths", action = ArgAction::SetTrue)]
288 stdin_paths: bool,
289
290 #[arg(long = "system-fonts", action = ArgAction::SetTrue)]
292 system_fonts: bool,
293
294 #[arg(short = 'a', long = "axes", value_delimiter = ',', value_hint = ValueHint::Other)]
296 axes: Vec<String>,
297
298 #[arg(short = 'f', long = "features", value_delimiter = ',', value_hint = ValueHint::Other)]
300 features: Vec<String>,
301
302 #[arg(short = 's', long = "scripts", value_delimiter = ',', value_hint = ValueHint::Other)]
304 scripts: Vec<String>,
305
306 #[arg(short = 'T', long = "tables", value_delimiter = ',', value_hint = ValueHint::Other)]
308 tables: Vec<String>,
309
310 #[arg(short = 'n', long = "name", value_hint = ValueHint::Other)]
312 name_patterns: Vec<String>,
313
314 #[arg(short = 'c', long = "creator", value_hint = ValueHint::Other)]
316 creator_patterns: Vec<String>,
317
318 #[arg(short = 'l', long = "license", value_hint = ValueHint::Other)]
320 license_patterns: Vec<String>,
321
322 #[arg(short = 'u', long = "codepoints", value_delimiter = ',', value_hint = ValueHint::Other)]
324 codepoints: Vec<String>,
325
326 #[arg(short = 't', long = "text")]
328 text: Option<String>,
329
330 #[arg(short = 'v', long = "variable", action = ArgAction::SetTrue)]
332 variable: bool,
333
334 #[arg(short = 'w', long = "weight", value_hint = ValueHint::Other)]
336 weight: Option<String>,
337
338 #[arg(short = 'W', long = "width", value_hint = ValueHint::Other)]
340 width: Option<String>,
341
342 #[arg(long = "family-class", value_hint = ValueHint::Other)]
344 family_class: Option<String>,
345
346 #[arg(long = "follow-symlinks", action = ArgAction::SetTrue)]
348 follow_symlinks: bool,
349
350 #[arg(short = 'J', long = "jobs", value_hint = ValueHint::Other)]
352 jobs: Option<usize>,
353
354 #[arg(long = "json", action = ArgAction::SetTrue, conflicts_with = "ndjson")]
356 json: bool,
357
358 #[arg(long = "ndjson", action = ArgAction::SetTrue)]
360 ndjson: bool,
361
362 #[arg(
364 long = "paths",
365 action = ArgAction::SetTrue,
366 conflicts_with_all = ["json", "ndjson", "columns"]
367 )]
368 paths_only: bool,
369
370 #[arg(long = "columns", action = ArgAction::SetTrue)]
372 columns: bool,
373
374 #[arg(long = "count", action = ArgAction::SetTrue, conflicts_with_all = ["json", "ndjson", "paths_only", "columns"])]
376 count_only: bool,
377
378 #[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
390pub 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
408fn 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 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 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
534fn 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#[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
1107fn 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 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
1129fn 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#[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 let opts = SearchOptions {
1201 follow_symlinks: args.follow_symlinks,
1202 jobs: args.jobs,
1203 };
1204 let additions = search(&paths, &Query::new(), &opts)?;
1205
1206 let mut writer = index.writer()?;
1208 let mut added = 0usize;
1209 let mut skipped = 0usize;
1210
1211 for entry in additions {
1212 let mtime = entry
1214 .source
1215 .path
1216 .metadata()
1217 .and_then(|m| m.modified())
1218 .unwrap_or(SystemTime::UNIX_EPOCH);
1219
1220 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 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;