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