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