1mod server;
15
16use std::collections::HashMap;
17use std::env;
18use std::fs::{self, File};
19use std::io::{self, BufRead, BufReader, BufWriter, IsTerminal, Write};
20use std::ops::RangeInclusive;
21use std::path::{Path, PathBuf};
22
23use anyhow::{anyhow, Context, Result};
24use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum, ValueHint};
25use regex::Regex;
26use serde_json::Deserializer;
27use tokio::runtime::Builder;
28
29use typg_core::query::{
31 parse_codepoint_list, parse_family_class, parse_tag_list, parse_u16_range, FamilyClassFilter,
32 Query,
33};
34use typg_core::search::{
35 filter_cached, search, search_streaming, SearchOptions, TypgFontFaceMatch,
36};
37use typg_core::tags::tag_to_string;
38
39#[cfg(feature = "hpindex")]
40use typg_core::index::FontIndex;
41
42#[derive(Debug, Parser)]
44#[command(
45 name = "typg",
46 version,
47 about = "Fast font search (made by FontLab https://www.fontlab.com/)"
48)]
49pub struct Cli {
50 #[arg(short = 'q', long = "quiet", global = true, action = ArgAction::SetTrue)]
52 quiet: bool,
53
54 #[command(subcommand)]
55 command: Command,
56}
57
58#[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_all = ["ndjson", "csv"])]
139 json: bool,
140
141 #[arg(long = "ndjson", action = ArgAction::SetTrue, conflicts_with_all = ["json", "csv"])]
143 ndjson: bool,
144
145 #[arg(
147 long = "csv",
148 action = ArgAction::SetTrue,
149 conflicts_with_all = ["json", "ndjson", "paths", "columns"]
150 )]
151 csv: bool,
152
153 #[arg(
155 long = "paths",
156 action = ArgAction::SetTrue,
157 conflicts_with_all = ["json", "ndjson", "csv", "columns"]
158 )]
159 paths: bool,
160
161 #[arg(long = "columns", action = ArgAction::SetTrue, conflicts_with_all = ["json", "ndjson", "csv", "paths"])]
163 columns: bool,
164
165 #[arg(long = "collections", action = ArgAction::SetTrue)]
167 collections: bool,
168
169 #[arg(long = "color", default_value_t = ColorChoice::Auto, value_enum)]
171 color: ColorChoice,
172
173 #[arg(short = 'd', long = "details")]
175 details: Option<String>,
176}
177
178#[derive(Debug, Args)]
179struct CacheListArgs {
180 #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
182 cache_path: Option<PathBuf>,
183
184 #[arg(long = "index", action = ArgAction::SetTrue)]
186 use_index: bool,
187
188 #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
190 index_path: Option<PathBuf>,
191
192 #[command(flatten)]
193 output: OutputArgs,
194}
195
196#[derive(Debug, Args)]
197struct CacheFindArgs {
198 #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
200 cache_path: Option<PathBuf>,
201
202 #[arg(long = "index", action = ArgAction::SetTrue)]
204 use_index: bool,
205
206 #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
208 index_path: Option<PathBuf>,
209
210 #[arg(short = 'a', long = "axes", value_delimiter = ',', value_hint = ValueHint::Other)]
212 axes: Vec<String>,
213
214 #[arg(short = 'f', long = "features", value_delimiter = ',', value_hint = ValueHint::Other)]
216 features: Vec<String>,
217
218 #[arg(short = 's', long = "scripts", value_delimiter = ',', value_hint = ValueHint::Other)]
220 scripts: Vec<String>,
221
222 #[arg(short = 'T', long = "tables", value_delimiter = ',', value_hint = ValueHint::Other)]
224 tables: Vec<String>,
225
226 #[arg(short = 'n', long = "name", value_hint = ValueHint::Other)]
228 name_patterns: Vec<String>,
229
230 #[arg(short = 'c', long = "creator", value_hint = ValueHint::Other)]
232 creator_patterns: Vec<String>,
233
234 #[arg(short = 'l', long = "license", value_hint = ValueHint::Other)]
236 license_patterns: Vec<String>,
237
238 #[arg(short = 'u', long = "codepoints", value_delimiter = ',', value_hint = ValueHint::Other)]
240 codepoints: Vec<String>,
241
242 #[arg(short = 't', long = "text")]
244 text: Option<String>,
245
246 #[arg(short = 'v', long = "variable", action = ArgAction::SetTrue)]
248 variable: bool,
249
250 #[arg(short = 'w', long = "weight", value_hint = ValueHint::Other)]
252 weight: Option<String>,
253
254 #[arg(short = 'W', long = "width", value_hint = ValueHint::Other)]
256 width: Option<String>,
257
258 #[arg(long = "family-class", value_hint = ValueHint::Other)]
260 family_class: Option<String>,
261
262 #[arg(long = "count", action = ArgAction::SetTrue, conflicts_with_all = ["json", "ndjson", "paths", "columns"])]
264 count_only: bool,
265
266 #[command(flatten)]
267 output: OutputArgs,
268}
269
270#[derive(Debug, Args)]
271struct CacheCleanArgs {
272 #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
274 cache_path: Option<PathBuf>,
275
276 #[arg(long = "index", action = ArgAction::SetTrue)]
278 use_index: bool,
279
280 #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
282 index_path: Option<PathBuf>,
283}
284
285#[derive(Debug, Args)]
286struct CacheInfoArgs {
287 #[arg(long = "cache-path", value_hint = ValueHint::FilePath)]
289 cache_path: Option<PathBuf>,
290
291 #[arg(long = "index", action = ArgAction::SetTrue)]
293 use_index: bool,
294
295 #[arg(long = "index-path", value_hint = ValueHint::DirPath)]
297 index_path: Option<PathBuf>,
298
299 #[arg(long = "json", action = ArgAction::SetTrue)]
301 json: bool,
302}
303
304#[derive(Debug, Args)]
305struct FindArgs {
306 #[arg(
308 value_hint = ValueHint::DirPath,
309 required_unless_present_any = ["system_fonts", "stdin_paths"]
310 )]
311 paths: Vec<PathBuf>,
312
313 #[arg(long = "stdin-paths", action = ArgAction::SetTrue)]
315 stdin_paths: bool,
316
317 #[arg(long = "system-fonts", action = ArgAction::SetTrue)]
319 system_fonts: bool,
320
321 #[arg(short = 'a', long = "axes", value_delimiter = ',', value_hint = ValueHint::Other)]
323 axes: Vec<String>,
324
325 #[arg(short = 'f', long = "features", value_delimiter = ',', value_hint = ValueHint::Other)]
327 features: Vec<String>,
328
329 #[arg(short = 's', long = "scripts", value_delimiter = ',', value_hint = ValueHint::Other)]
331 scripts: Vec<String>,
332
333 #[arg(short = 'T', long = "tables", value_delimiter = ',', value_hint = ValueHint::Other)]
335 tables: Vec<String>,
336
337 #[arg(short = 'n', long = "name", value_hint = ValueHint::Other)]
339 name_patterns: Vec<String>,
340
341 #[arg(short = 'c', long = "creator", value_hint = ValueHint::Other)]
343 creator_patterns: Vec<String>,
344
345 #[arg(short = 'l', long = "license", value_hint = ValueHint::Other)]
347 license_patterns: Vec<String>,
348
349 #[arg(short = 'u', long = "codepoints", value_delimiter = ',', value_hint = ValueHint::Other)]
351 codepoints: Vec<String>,
352
353 #[arg(short = 't', long = "text")]
355 text: Option<String>,
356
357 #[arg(short = 'v', long = "variable", action = ArgAction::SetTrue)]
359 variable: bool,
360
361 #[arg(short = 'w', long = "weight", value_hint = ValueHint::Other)]
363 weight: Option<String>,
364
365 #[arg(short = 'W', long = "width", value_hint = ValueHint::Other)]
367 width: Option<String>,
368
369 #[arg(long = "family-class", value_hint = ValueHint::Other)]
371 family_class: Option<String>,
372
373 #[arg(long = "follow-symlinks", action = ArgAction::SetTrue)]
375 follow_symlinks: bool,
376
377 #[arg(short = 'J', long = "jobs", value_hint = ValueHint::Other)]
379 jobs: Option<usize>,
380
381 #[arg(long = "json", action = ArgAction::SetTrue, conflicts_with_all = ["ndjson", "csv"])]
383 json: bool,
384
385 #[arg(long = "ndjson", action = ArgAction::SetTrue, conflicts_with_all = ["json", "csv"])]
387 ndjson: bool,
388
389 #[arg(
391 long = "csv",
392 action = ArgAction::SetTrue,
393 conflicts_with_all = ["json", "ndjson", "paths_only", "columns"]
394 )]
395 csv: bool,
396
397 #[arg(
399 long = "paths",
400 action = ArgAction::SetTrue,
401 conflicts_with_all = ["json", "ndjson", "csv", "columns"]
402 )]
403 paths_only: bool,
404
405 #[arg(long = "columns", action = ArgAction::SetTrue, conflicts_with_all = ["json", "ndjson", "csv", "paths_only"])]
407 columns: bool,
408
409 #[arg(long = "collections", action = ArgAction::SetTrue)]
411 collections: bool,
412
413 #[arg(long = "count", action = ArgAction::SetTrue, conflicts_with_all = ["json", "ndjson", "csv", "paths_only", "columns"])]
415 count_only: bool,
416
417 #[arg(long = "color", default_value_t = ColorChoice::Auto, value_enum)]
419 color: ColorChoice,
420
421 #[arg(short = 'd', long = "details")]
423 details: Option<String>,
424}
425
426#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
427enum ColorChoice {
428 Auto,
429 Always,
430 Never,
431}
432
433pub fn run() -> Result<()> {
439 let cli = Cli::parse();
440 let quiet = cli.quiet;
441
442 match cli.command {
443 Command::Find(args) => run_find(*args),
444 Command::Cache(cmd) => match cmd {
445 CacheCommand::Add(args) => run_cache_add(args, quiet),
446 CacheCommand::List(args) => run_cache_list(args),
447 CacheCommand::Find(args) => run_cache_find(*args),
448 CacheCommand::Clean(args) => run_cache_clean(args, quiet),
449 CacheCommand::Info(args) => run_cache_info(args),
450 },
451 Command::Serve(args) => run_serve(args),
452 }
453}
454
455fn run_find(args: FindArgs) -> Result<()> {
470 if matches!(args.jobs, Some(0)) {
471 return Err(anyhow!("--jobs must be at least 1"));
472 }
473
474 let stdin = io::stdin();
475 let paths = gather_paths(
476 &args.paths,
477 args.stdin_paths,
478 args.system_fonts,
479 stdin.lock(),
480 )?;
481 let query = build_query(&args)?;
482 let opts = SearchOptions {
483 follow_symlinks: args.follow_symlinks,
484 jobs: args.jobs,
485 };
486
487 let output = OutputFormat::from_find(&args);
488
489 let _ = get_effective_properties(&output)?;
491
492 if args.count_only || output.json || output.columns {
496 let matches = search(&paths, &query, &opts)?;
497 if args.count_only {
498 println!("{}", matches.len());
499 return Ok(());
500 }
501 return write_matches(&matches, &output, &paths);
502 }
503
504 let (tx, rx) = std::sync::mpsc::channel();
506
507 std::thread::scope(|s| -> Result<()> {
508 let handle = s.spawn(|| search_streaming(&paths, &query, &opts, tx));
509
510 let stdout = io::stdout();
511 let mut w = stdout.lock();
512 let use_color = match output.color {
513 ColorChoice::Always => true,
514 ColorChoice::Never => false,
515 ColorChoice::Auto => w.is_terminal(),
516 };
517
518 if output.csv {
520 if let Ok(props) = get_effective_properties(&output) {
521 let header_cols: Vec<String> = props.iter().map(|p| escape_csv_field(p)).collect();
522 let _ = writeln!(w, "{}", header_cols.join(","));
523 }
524 }
525
526 let mut seen = std::collections::HashSet::new();
527 for m in rx {
528 if output.paths {
529 if output.collections {
530 let _ = writeln!(w, "{}", m.source.path_with_index());
531 } else if seen.insert(m.source.path.clone()) {
532 let _ = writeln!(w, "{}", m.source.path.display());
533 }
534 } else if output.csv {
535 if output.collections || seen.insert(m.source.path.clone()) {
536 if let Ok(props) = get_effective_properties(&output) {
537 let mut row_cols = Vec::new();
538 for prop in &props {
539 let val = extract_property_value(&m, prop, &paths, output.collections);
540 let fmt_val = format_value_csv(&val);
541 row_cols.push(escape_csv_field(&fmt_val));
542 }
543 let _ = writeln!(w, "{}", row_cols.join(","));
544 }
545 }
546 } else if output.ndjson {
547 if output.collections || seen.insert(m.source.path.clone()) {
548 if let Ok(props) = get_effective_properties(&output) {
549 if !props.is_empty() {
550 if props.len() == 1 && props[0] == "path" {
551 let path_str = if output.collections {
552 m.source.path_with_index()
553 } else {
554 m.source.path.to_string_lossy().to_string()
555 };
556 if let Ok(line) = serde_json::to_string(&path_str) {
557 let _ = w.write_all(line.as_bytes());
558 let _ = w.write_all(b"\n");
559 }
560 } else {
561 let mut map = serde_json::Map::new();
562 for prop in &props {
563 let val = extract_property_value(
564 &m,
565 prop,
566 &paths,
567 output.collections,
568 );
569 map.insert(prop.clone(), val);
570 }
571 if let Ok(line) =
572 serde_json::to_string(&serde_json::Value::Object(map))
573 {
574 let _ = w.write_all(line.as_bytes());
575 let _ = w.write_all(b"\n");
576 }
577 }
578 } else if let Ok(line) = serde_json::to_string(&m) {
579 let _ = w.write_all(line.as_bytes());
580 let _ = w.write_all(b"\n");
581 }
582 }
583 }
584 } else {
585 if output.collections || seen.insert(m.source.path.clone()) {
587 if let Ok(props) = get_effective_properties(&output) {
588 if !props.is_empty() {
589 let mut row_vals = Vec::new();
590 for prop in &props {
591 let val =
592 extract_property_value(&m, prop, &paths, output.collections);
593 let fmt_val = match val {
594 serde_json::Value::String(s) => s,
595 other => other.to_string(),
596 };
597 row_vals.push(fmt_val);
598 }
599 let _ = writeln!(w, "{}", row_vals.join(" "));
600 } else if output.collections {
601 let rendered = render_path(&m, use_color, true);
602 let _ = writeln!(w, "{rendered}");
603 } else {
604 let rendered = render_path(&m, use_color, false);
605 let _ = writeln!(w, "{rendered}");
606 }
607 }
608 }
609 }
610 }
611
612 match handle.join() {
613 Ok(result) => result,
614 Err(_) => Err(anyhow!("search thread panicked")),
615 }
616 })
617}
618
619fn run_serve(args: ServeArgs) -> Result<()> {
620 let runtime = Builder::new_multi_thread().enable_all().build()?;
621 runtime.block_on(server::serve(&args.bind))
622}
623
624#[derive(Clone, Debug)]
625struct OutputFormat {
626 json: bool,
627 ndjson: bool,
628 csv: bool,
629 paths: bool,
630 columns: bool,
631 collections: bool,
632 color: ColorChoice,
633 details: Option<String>,
634}
635
636impl OutputFormat {
637 fn from_find(args: &FindArgs) -> Self {
638 Self {
639 json: args.json,
640 ndjson: args.ndjson,
641 csv: args.csv,
642 paths: args.paths_only,
643 columns: args.columns,
644 collections: args.collections,
645 color: args.color,
646 details: args.details.clone(),
647 }
648 }
649
650 fn from_output(args: &OutputArgs) -> Self {
651 Self {
652 json: args.json,
653 ndjson: args.ndjson,
654 csv: args.csv,
655 paths: args.paths,
656 columns: args.columns,
657 collections: args.collections,
658 color: args.color,
659 details: args.details.clone(),
660 }
661 }
662}
663
664fn write_matches(
665 matches: &[TypgFontFaceMatch],
666 format: &OutputFormat,
667 roots: &[PathBuf],
668) -> Result<()> {
669 let stdout = io::stdout();
670 let mut handle = stdout.lock();
671 let use_color = match format.color {
672 ColorChoice::Always => true,
673 ColorChoice::Never => false,
674 ColorChoice::Auto => handle.is_terminal(),
675 };
676
677 if format.paths {
678 write_paths(matches, &mut handle, format.collections)?;
679 } else if format.ndjson {
680 write_ndjson_filtered(matches, &mut handle, format, roots)?;
681 } else if format.json {
682 write_json_pretty_filtered(matches, &mut handle, format, roots)?;
683 } else if format.csv {
684 write_csv(matches, &mut handle, format, roots)?;
685 } else if format.columns {
686 write_columns_filtered(matches, &mut handle, format, use_color, roots)?;
687 } else {
688 write_plain_filtered(matches, &mut handle, format, use_color, roots)?;
689 }
690
691 Ok(())
692}
693
694fn get_relative_path(font_path: &Path, roots: &[PathBuf]) -> String {
695 for root in roots {
696 if let Ok(rel) = font_path.strip_prefix(root) {
697 return rel.to_string_lossy().to_string();
698 }
699 if let (Ok(abs_font), Ok(abs_root)) = (
700 std::fs::canonicalize(font_path),
701 std::fs::canonicalize(root),
702 ) {
703 if let Ok(rel) = abs_font.strip_prefix(&abs_root) {
704 return rel.to_string_lossy().to_string();
705 }
706 }
707 }
708 if let Ok(cwd) = std::env::current_dir() {
709 if let Ok(rel) = font_path.strip_prefix(&cwd) {
710 return rel.to_string_lossy().to_string();
711 }
712 if let (Ok(abs_font), Ok(abs_cwd)) = (
713 std::fs::canonicalize(font_path),
714 std::fs::canonicalize(&cwd),
715 ) {
716 if let Ok(rel) = abs_font.strip_prefix(&abs_cwd) {
717 return rel.to_string_lossy().to_string();
718 }
719 }
720 }
721 font_path
722 .file_name()
723 .map(|n| n.to_string_lossy().to_string())
724 .unwrap_or_else(|| font_path.to_string_lossy().to_string())
725}
726
727fn parse_details_list(details: &str) -> Result<Vec<String>> {
728 let presets = match details.trim() {
729 "0" => vec!["path".to_string()],
730 "1" => vec![
731 "path".to_string(),
732 "fname".to_string(),
733 "sname".to_string(),
734 "fmt".to_string(),
735 ],
736 "2" => vec![
737 "path".to_string(),
738 "fname".to_string(),
739 "sname".to_string(),
740 "fmt".to_string(),
741 "psname".to_string(),
742 "is_var".to_string(),
743 ],
744 "5" => vec![
745 "path".to_string(),
746 "fname".to_string(),
747 "sname".to_string(),
748 "fmt".to_string(),
749 "psname".to_string(),
750 "is_var".to_string(),
751 "weight".to_string(),
752 "width".to_string(),
753 "family_class".to_string(),
754 ],
755 "8" => vec![
756 "path".to_string(),
757 "fname".to_string(),
758 "sname".to_string(),
759 "fmt".to_string(),
760 "psname".to_string(),
761 "is_var".to_string(),
762 "weight".to_string(),
763 "width".to_string(),
764 "family_class".to_string(),
765 "axes".to_string(),
766 "fea_n".to_string(),
767 "scripts".to_string(),
768 ],
769 "9" => vec![
770 "path".to_string(),
771 "path_r".to_string(),
772 "fmt".to_string(),
773 "fname".to_string(),
774 "sname".to_string(),
775 "psname".to_string(),
776 "tfname".to_string(),
777 "lfname".to_string(),
778 "tsname".to_string(),
779 "lsname".to_string(),
780 "is_var".to_string(),
781 "weight".to_string(),
782 "width".to_string(),
783 "family_class".to_string(),
784 "axes".to_string(),
785 "axes_n".to_string(),
786 "fea".to_string(),
787 "fea_n".to_string(),
788 "scripts".to_string(),
789 "scripts_n".to_string(),
790 "tables".to_string(),
791 "tables_n".to_string(),
792 "creator_names".to_string(),
793 "license_names".to_string(),
794 ],
795 other => {
796 let mut parts = Vec::new();
797 for part in other.split(',') {
798 let trimmed = part.trim();
799 if !trimmed.is_empty() {
800 let canon = match trimmed {
801 "variable" => "is_var",
802 other => other,
803 };
804 let valid_keywords = [
805 "path",
806 "path_r",
807 "fmt",
808 "fname",
809 "sname",
810 "psname",
811 "tfname",
812 "lfname",
813 "tsname",
814 "lsname",
815 "is_var",
816 "weight",
817 "width",
818 "family_class",
819 "axes",
820 "axes_n",
821 "fea",
822 "fea_n",
823 "scripts",
824 "scripts_n",
825 "tables",
826 "tables_n",
827 "creator_names",
828 "license_names",
829 ];
830 if !valid_keywords.contains(&canon) {
831 return Err(anyhow!("invalid details keyword: {}", trimmed));
832 }
833 parts.push(canon.to_string());
834 }
835 }
836 if parts.is_empty() {
837 return Err(anyhow!("empty details option"));
838 }
839 parts
840 }
841 };
842 Ok(presets)
843}
844
845fn get_effective_properties(format: &OutputFormat) -> Result<Vec<String>> {
846 if let Some(ref d) = format.details {
847 parse_details_list(d)
848 } else if format.csv {
849 Ok(vec![
850 "path".to_string(),
851 "fname".to_string(),
852 "sname".to_string(),
853 "fmt".to_string(),
854 "is_var".to_string(),
855 "weight".to_string(),
856 "width".to_string(),
857 ])
858 } else {
859 Ok(Vec::new())
860 }
861}
862
863fn extract_property_value(
864 m: &TypgFontFaceMatch,
865 prop: &str,
866 roots: &[PathBuf],
867 collections: bool,
868) -> serde_json::Value {
869 match prop {
870 "path" => {
871 if collections {
872 serde_json::Value::String(m.source.path_with_index())
873 } else {
874 serde_json::Value::String(m.source.path.to_string_lossy().to_string())
875 }
876 }
877 "path_r" => {
878 let rel = get_relative_path(&m.source.path, roots);
879 if collections {
880 if let Some(idx) = m.source.ttc_index {
881 serde_json::Value::String(format!("{}#{}", rel, idx))
882 } else {
883 serde_json::Value::String(rel)
884 }
885 } else {
886 serde_json::Value::String(rel)
887 }
888 }
889 "fmt" => {
890 let ext = m
891 .source
892 .path
893 .extension()
894 .and_then(|e| e.to_str())
895 .map(|e| e.to_ascii_lowercase())
896 .unwrap_or_else(|| "unknown".to_string());
897 serde_json::Value::String(ext)
898 }
899 "fname" => {
900 let name = m
901 .metadata
902 .tfname
903 .clone()
904 .or_else(|| m.metadata.lfname.clone())
905 .unwrap_or_else(|| m.metadata.names.first().cloned().unwrap_or_default());
906 serde_json::Value::String(name)
907 }
908 "sname" => {
909 let style = m
910 .metadata
911 .tsname
912 .clone()
913 .or_else(|| m.metadata.lsname.clone())
914 .unwrap_or_default();
915 serde_json::Value::String(style)
916 }
917 "psname" => serde_json::Value::String(m.metadata.psname.clone().unwrap_or_default()),
918 "tfname" => serde_json::Value::String(m.metadata.tfname.clone().unwrap_or_default()),
919 "lfname" => serde_json::Value::String(m.metadata.lfname.clone().unwrap_or_default()),
920 "tsname" => serde_json::Value::String(m.metadata.tsname.clone().unwrap_or_default()),
921 "lsname" => serde_json::Value::String(m.metadata.lsname.clone().unwrap_or_default()),
922 "is_var" => serde_json::Value::Bool(m.metadata.is_variable),
923 "weight" => serde_json::Value::Number(m.metadata.weight_class.unwrap_or_default().into()),
924 "width" => serde_json::Value::Number(m.metadata.width_class.unwrap_or_default().into()),
925 "family_class" => {
926 let fc = m
927 .metadata
928 .family_class
929 .map(|(maj, sub)| format!("{}.{}", maj, sub))
930 .unwrap_or_default();
931 serde_json::Value::String(fc)
932 }
933 "axes" => {
934 let list: Vec<String> = m
935 .metadata
936 .axis_tags
937 .iter()
938 .map(|t| tag_to_string(*t))
939 .collect();
940 serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
941 }
942 "axes_n" => serde_json::Value::Number(m.metadata.axis_tags.len().into()),
943 "fea" => {
944 let list: Vec<String> = m
945 .metadata
946 .feature_tags
947 .iter()
948 .map(|t| tag_to_string(*t))
949 .collect();
950 serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
951 }
952 "fea_n" => serde_json::Value::Number(m.metadata.feature_tags.len().into()),
953 "scripts" => {
954 let list: Vec<String> = m
955 .metadata
956 .script_tags
957 .iter()
958 .map(|t| tag_to_string(*t))
959 .collect();
960 serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
961 }
962 "scripts_n" => serde_json::Value::Number(m.metadata.script_tags.len().into()),
963 "tables" => {
964 let list: Vec<String> = m
965 .metadata
966 .table_tags
967 .iter()
968 .map(|t| tag_to_string(*t))
969 .collect();
970 serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
971 }
972 "tables_n" => serde_json::Value::Number(m.metadata.table_tags.len().into()),
973 "creator_names" => serde_json::Value::Array(
974 m.metadata
975 .creator_names
976 .iter()
977 .map(|s| serde_json::Value::String(s.clone()))
978 .collect(),
979 ),
980 "license_names" => serde_json::Value::Array(
981 m.metadata
982 .license_names
983 .iter()
984 .map(|s| serde_json::Value::String(s.clone()))
985 .collect(),
986 ),
987 _ => serde_json::Value::Null,
988 }
989}
990
991fn format_value_csv(val: &serde_json::Value) -> String {
992 match val {
993 serde_json::Value::Null => String::new(),
994 serde_json::Value::Bool(b) => b.to_string(),
995 serde_json::Value::Number(n) => n.to_string(),
996 serde_json::Value::String(s) => s.clone(),
997 serde_json::Value::Array(arr) => arr
998 .iter()
999 .map(|item| match item {
1000 serde_json::Value::String(s) => s.clone(),
1001 other => other.to_string(),
1002 })
1003 .collect::<Vec<_>>()
1004 .join(";"),
1005 serde_json::Value::Object(obj) => serde_json::to_string(obj).unwrap_or_default(),
1006 }
1007}
1008
1009fn escape_csv_field(field: &str) -> String {
1010 if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
1011 let escaped = field.replace('"', "\"\"");
1012 format!("\"{}\"", escaped)
1013 } else {
1014 field.to_string()
1015 }
1016}
1017
1018fn write_csv(
1019 matches: &[TypgFontFaceMatch],
1020 mut w: impl Write,
1021 format: &OutputFormat,
1022 roots: &[PathBuf],
1023) -> Result<()> {
1024 let props = get_effective_properties(format)?;
1025 let header_cols: Vec<String> = props.iter().map(|p| escape_csv_field(p)).collect();
1026 writeln!(w, "{}", header_cols.join(","))?;
1027
1028 let mut seen = std::collections::HashSet::new();
1029 for m in matches {
1030 if format.collections || seen.insert(m.source.path.clone()) {
1031 let mut row_cols = Vec::new();
1032 for prop in &props {
1033 let val = extract_property_value(m, prop, roots, format.collections);
1034 let fmt_val = format_value_csv(&val);
1035 row_cols.push(escape_csv_field(&fmt_val));
1036 }
1037 writeln!(w, "{}", row_cols.join(","))?;
1038 }
1039 }
1040 Ok(())
1041}
1042
1043fn write_json_pretty_filtered(
1044 matches: &[TypgFontFaceMatch],
1045 mut w: impl Write,
1046 format: &OutputFormat,
1047 roots: &[PathBuf],
1048) -> Result<()> {
1049 let props = get_effective_properties(format)?;
1050 if props.is_empty() {
1051 let json = serde_json::to_string_pretty(matches)?;
1052 w.write_all(json.as_bytes())?;
1053 return Ok(());
1054 }
1055
1056 if props.len() == 1 && props[0] == "path" {
1057 let mut paths = Vec::new();
1058 let mut seen = std::collections::HashSet::new();
1059 for m in matches {
1060 if format.collections || seen.insert(m.source.path.clone()) {
1061 let path_str = if format.collections {
1062 m.source.path_with_index()
1063 } else {
1064 m.source.path.to_string_lossy().to_string()
1065 };
1066 paths.push(path_str);
1067 }
1068 }
1069 let json = serde_json::to_string_pretty(&paths)?;
1070 w.write_all(json.as_bytes())?;
1071 } else {
1072 let mut objects = Vec::new();
1073 let mut seen = std::collections::HashSet::new();
1074 for m in matches {
1075 if format.collections || seen.insert(m.source.path.clone()) {
1076 let mut map = serde_json::Map::new();
1077 for prop in &props {
1078 let val = extract_property_value(m, prop, roots, format.collections);
1079 map.insert(prop.clone(), val);
1080 }
1081 objects.push(serde_json::Value::Object(map));
1082 }
1083 }
1084 let json = serde_json::to_string_pretty(&objects)?;
1085 w.write_all(json.as_bytes())?;
1086 }
1087 Ok(())
1088}
1089
1090fn write_ndjson_filtered(
1091 matches: &[TypgFontFaceMatch],
1092 mut w: impl Write,
1093 format: &OutputFormat,
1094 roots: &[PathBuf],
1095) -> Result<()> {
1096 let props = get_effective_properties(format)?;
1097 if props.is_empty() {
1098 for m in matches {
1099 let line = serde_json::to_string(m)?;
1100 w.write_all(line.as_bytes())?;
1101 w.write_all(b"\n")?;
1102 }
1103 return Ok(());
1104 }
1105
1106 let mut seen = std::collections::HashSet::new();
1107 for m in matches {
1108 if format.collections || seen.insert(m.source.path.clone()) {
1109 if props.len() == 1 && props[0] == "path" {
1110 let path_str = if format.collections {
1111 m.source.path_with_index()
1112 } else {
1113 m.source.path.to_string_lossy().to_string()
1114 };
1115 let line = serde_json::to_string(&path_str)?;
1116 w.write_all(line.as_bytes())?;
1117 w.write_all(b"\n")?;
1118 } else {
1119 let mut map = serde_json::Map::new();
1120 for prop in &props {
1121 let val = extract_property_value(m, prop, roots, format.collections);
1122 map.insert(prop.clone(), val);
1123 }
1124 let line = serde_json::to_string(&serde_json::Value::Object(map))?;
1125 w.write_all(line.as_bytes())?;
1126 w.write_all(b"\n")?;
1127 }
1128 }
1129 }
1130 Ok(())
1131}
1132
1133fn write_columns_filtered(
1134 matches: &[TypgFontFaceMatch],
1135 mut w: impl Write,
1136 format: &OutputFormat,
1137 color: bool,
1138 roots: &[PathBuf],
1139) -> Result<()> {
1140 let props = get_effective_properties(format)?;
1141 if props.is_empty() {
1142 return write_columns(matches, w, color, format.collections);
1143 }
1144
1145 let mut seen = std::collections::HashSet::new();
1146 let mut rows = Vec::new();
1147 for m in matches {
1148 if format.collections || seen.insert(m.source.path.clone()) {
1149 let mut row = Vec::new();
1150 for prop in &props {
1151 let val = extract_property_value(m, prop, roots, format.collections);
1152 let str_val = match val {
1153 serde_json::Value::Null => String::new(),
1154 serde_json::Value::String(s) => s,
1155 serde_json::Value::Array(arr) => arr
1156 .iter()
1157 .map(|item| match item {
1158 serde_json::Value::String(s) => s.clone(),
1159 other => other.to_string(),
1160 })
1161 .collect::<Vec<_>>()
1162 .join(","),
1163 other => other.to_string(),
1164 };
1165 row.push(str_val);
1166 }
1167 rows.push(row);
1168 }
1169 }
1170
1171 if rows.is_empty() {
1172 return Ok(());
1173 }
1174
1175 let num_cols = props.len();
1176 let mut col_widths = vec![0; num_cols];
1177 for row in &rows {
1178 for (i, col) in row.iter().enumerate() {
1179 col_widths[i] = col_widths[i].max(col.len());
1180 }
1181 }
1182
1183 for row in rows {
1184 let mut cols = Vec::new();
1185 for (i, col) in row.iter().enumerate() {
1186 let width = col_widths[i];
1187 let padded = format!("{:<width$}", col);
1188 let rendered_col = if i == 0 {
1189 apply_color(&padded, color, AnsiColor::Cyan)
1190 } else if i == 1 {
1191 apply_color(&padded, color, AnsiColor::Yellow)
1192 } else {
1193 apply_color(&padded, color, AnsiColor::Green)
1194 };
1195 cols.push(rendered_col);
1196 }
1197 writeln!(w, "{}", cols.join(" "))?;
1198 }
1199
1200 Ok(())
1201}
1202
1203fn write_plain_filtered(
1204 matches: &[TypgFontFaceMatch],
1205 mut w: impl Write,
1206 format: &OutputFormat,
1207 color: bool,
1208 roots: &[PathBuf],
1209) -> Result<()> {
1210 let props = get_effective_properties(format)?;
1211 if props.is_empty() {
1212 return write_plain(matches, w, color, format.collections);
1213 }
1214
1215 let mut seen = std::collections::HashSet::new();
1216 for m in matches {
1217 if format.collections || seen.insert(m.source.path.clone()) {
1218 let mut row = Vec::new();
1219 for prop in &props {
1220 let val = extract_property_value(m, prop, roots, format.collections);
1221 let str_val = match val {
1222 serde_json::Value::Null => String::new(),
1223 serde_json::Value::String(s) => s,
1224 serde_json::Value::Array(arr) => arr
1225 .iter()
1226 .map(|item| match item {
1227 serde_json::Value::String(s) => s.clone(),
1228 other => other.to_string(),
1229 })
1230 .collect::<Vec<_>>()
1231 .join(","),
1232 other => other.to_string(),
1233 };
1234 row.push(str_val);
1235 }
1236 writeln!(w, "{}", row.join(" "))?;
1237 }
1238 }
1239 Ok(())
1240}
1241
1242fn build_query(args: &FindArgs) -> Result<Query> {
1247 build_query_from_parts(
1248 &args.axes,
1249 &args.features,
1250 &args.scripts,
1251 &args.tables,
1252 &args.name_patterns,
1253 &args.creator_patterns,
1254 &args.license_patterns,
1255 &args.codepoints,
1256 &args.text,
1257 args.variable,
1258 &args.weight,
1259 &args.width,
1260 &args.family_class,
1261 )
1262}
1263
1264#[allow(clippy::too_many_arguments)]
1273fn build_query_from_parts(
1274 axes: &[String],
1275 features: &[String],
1276 scripts: &[String],
1277 tables: &[String],
1278 name_patterns: &[String],
1279 creator_patterns: &[String],
1280 license_patterns: &[String],
1281 codepoints: &[String],
1282 text: &Option<String>,
1283 variable: bool,
1284 weight: &Option<String>,
1285 width: &Option<String>,
1286 family_class: &Option<String>,
1287) -> Result<Query> {
1288 let axes = parse_tag_list(axes)?;
1289 let features = parse_tag_list(features)?;
1290 let scripts = parse_tag_list(scripts)?;
1291 let tables = parse_tag_list(tables)?;
1292 let name_patterns = compile_patterns(name_patterns)?;
1293 let creator_patterns = compile_patterns(creator_patterns)?;
1294 let license_patterns = compile_patterns(license_patterns)?;
1295 let mut codepoints = parse_codepoints(codepoints)?;
1296 let weight_range = parse_optional_range(weight)?;
1297 let width_range = parse_optional_range(width)?;
1298 let family_class = parse_optional_family_class(family_class)?;
1299
1300 if let Some(text) = text {
1301 codepoints.extend(text.chars());
1302 }
1303
1304 dedup_chars(&mut codepoints);
1305
1306 Ok(Query::new()
1307 .with_axes(axes)
1308 .with_features(features)
1309 .with_scripts(scripts)
1310 .with_tables(tables)
1311 .with_name_patterns(name_patterns)
1312 .with_creator_patterns(creator_patterns)
1313 .with_license_patterns(license_patterns)
1314 .with_codepoints(codepoints)
1315 .require_variable(variable)
1316 .with_weight_range(weight_range)
1317 .with_width_range(width_range)
1318 .with_family_class(family_class))
1319}
1320
1321fn dedup_chars(cps: &mut Vec<char>) {
1322 cps.sort();
1323 cps.dedup();
1324}
1325
1326fn compile_patterns(patterns: &[String]) -> Result<Vec<Regex>> {
1327 patterns
1328 .iter()
1329 .map(|p| Regex::new(p).with_context(|| format!("invalid regex: {p}")))
1330 .collect()
1331}
1332
1333fn parse_codepoints(raw: &[String]) -> Result<Vec<char>> {
1334 let mut cps = Vec::new();
1335 for chunk in raw {
1336 cps.extend(parse_codepoint_list(chunk)?);
1337 }
1338 Ok(cps)
1339}
1340
1341fn parse_optional_range(raw: &Option<String>) -> Result<Option<RangeInclusive<u16>>> {
1342 match raw {
1343 Some(value) => Ok(Some(parse_u16_range(value)?)),
1344 None => Ok(None),
1345 }
1346}
1347
1348fn parse_optional_family_class(raw: &Option<String>) -> Result<Option<FamilyClassFilter>> {
1349 match raw {
1350 Some(value) => Ok(Some(parse_family_class(value)?)),
1351 None => Ok(None),
1352 }
1353}
1354
1355fn gather_paths(
1368 raw_paths: &[PathBuf],
1369 read_stdin: bool,
1370 include_system: bool,
1371 mut stdin: impl BufRead,
1372) -> Result<Vec<PathBuf>> {
1373 let mut paths = Vec::new();
1374
1375 if read_stdin {
1376 paths.extend(read_paths_from(&mut stdin)?);
1377 }
1378
1379 for path in raw_paths {
1380 if path == Path::new("-") {
1381 paths.extend(read_paths_from(&mut stdin)?);
1382 } else {
1383 paths.push(path.clone());
1384 }
1385 }
1386
1387 if include_system {
1388 paths.extend(system_font_roots()?);
1389 }
1390
1391 if paths.is_empty() {
1392 return Err(anyhow!("no search paths provided"));
1393 }
1394
1395 Ok(paths)
1396}
1397
1398fn read_paths_from(reader: &mut impl BufRead) -> Result<Vec<PathBuf>> {
1399 let mut buf = String::new();
1400 let mut paths = Vec::new();
1401
1402 loop {
1403 buf.clear();
1404 let read = reader.read_line(&mut buf)?;
1405 if read == 0 {
1406 break;
1407 }
1408
1409 let trimmed = buf.trim();
1410 if !trimmed.is_empty() {
1411 paths.push(PathBuf::from(trimmed));
1412 }
1413 }
1414
1415 Ok(paths)
1416}
1417
1418fn system_font_roots() -> Result<Vec<PathBuf>> {
1430 if let Ok(raw) = env::var("TYPOG_SYSTEM_FONT_DIRS") {
1431 let mut overrides: Vec<PathBuf> = raw
1432 .split([':', ';'])
1433 .filter(|s| !s.is_empty())
1434 .map(PathBuf::from)
1435 .filter(|p| p.exists())
1436 .collect();
1437
1438 overrides.sort();
1439 overrides.dedup();
1440
1441 return if overrides.is_empty() {
1442 Err(anyhow!("TYPOG_SYSTEM_FONT_DIRS is set but no paths exist"))
1443 } else {
1444 Ok(overrides)
1445 };
1446 }
1447
1448 let mut candidates: Vec<PathBuf> = Vec::new();
1449
1450 #[cfg(target_os = "macos")]
1451 {
1452 candidates.push(PathBuf::from("/System/Library/Fonts"));
1453 candidates.push(PathBuf::from("/Library/Fonts"));
1454 if let Some(home) = env::var_os("HOME") {
1455 candidates.push(PathBuf::from(home).join("Library/Fonts"));
1456 }
1457 }
1458
1459 #[cfg(target_os = "linux")]
1460 {
1461 candidates.push(PathBuf::from("/usr/share/fonts"));
1462 candidates.push(PathBuf::from("/usr/local/share/fonts"));
1463 if let Some(home) = env::var_os("HOME") {
1464 candidates.push(PathBuf::from(home).join(".local/share/fonts"));
1465 }
1466 }
1467
1468 #[cfg(target_os = "windows")]
1469 {
1470 if let Some(system_root) = env::var_os("SYSTEMROOT") {
1471 candidates.push(PathBuf::from(system_root).join("Fonts"));
1472 }
1473 if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1474 candidates.push(PathBuf::from(local_appdata).join("Microsoft/Windows/Fonts"));
1475 }
1476 }
1477
1478 candidates.retain(|p| p.exists());
1479 candidates.sort();
1480 candidates.dedup();
1481
1482 if candidates.is_empty() {
1483 return Err(anyhow!(
1484 "no system font directories found for this platform"
1485 ));
1486 }
1487
1488 Ok(candidates)
1489}
1490
1491fn write_plain(
1498 matches: &[TypgFontFaceMatch],
1499 mut w: impl Write,
1500 color: bool,
1501 collections: bool,
1502) -> Result<()> {
1503 if collections {
1504 for item in matches {
1505 let rendered = render_path(item, color, true);
1506 writeln!(w, "{rendered}")?;
1507 }
1508 } else {
1509 let mut seen = std::collections::HashSet::new();
1510 for item in matches {
1511 if seen.insert(item.source.path.clone()) {
1512 let rendered = render_path(item, color, false);
1513 writeln!(w, "{rendered}")?;
1514 }
1515 }
1516 }
1517 Ok(())
1518}
1519
1520fn write_paths(matches: &[TypgFontFaceMatch], mut w: impl Write, collections: bool) -> Result<()> {
1528 if collections {
1529 for item in matches {
1530 writeln!(w, "{}", item.source.path_with_index())?;
1531 }
1532 } else {
1533 let mut seen = std::collections::HashSet::new();
1534 for item in matches {
1535 if seen.insert(item.source.path.clone()) {
1536 writeln!(w, "{}", item.source.path.display())?;
1537 }
1538 }
1539 }
1540 Ok(())
1541}
1542
1543fn write_columns(
1554 matches: &[TypgFontFaceMatch],
1555 mut w: impl Write,
1556 color: bool,
1557 collections: bool,
1558) -> Result<()> {
1559 let mut rows: Vec<(String, String, String)> = matches
1560 .iter()
1561 .map(|m| {
1562 let path = if collections {
1563 m.source.path_with_index()
1564 } else {
1565 m.source.path.display().to_string()
1566 };
1567 let name = m
1568 .metadata
1569 .names
1570 .first()
1571 .cloned()
1572 .unwrap_or_else(|| "(unnamed)".to_string());
1573
1574 let tags = format!(
1575 "axes:{:<2} feats:{:<2} scripts:{:<2} tables:{:<2}{}",
1576 m.metadata.axis_tags.len(),
1577 m.metadata.feature_tags.len(),
1578 m.metadata.script_tags.len(),
1579 m.metadata.table_tags.len(),
1580 if m.metadata.is_variable { " var" } else { "" },
1581 );
1582
1583 (path, name, tags)
1584 })
1585 .collect();
1586
1587 let path_width = rows
1588 .iter()
1589 .map(|r| r.0.len())
1590 .max()
1591 .unwrap_or(0)
1592 .clamp(0, 120);
1593 let name_width = rows
1594 .iter()
1595 .map(|r| r.1.len())
1596 .max()
1597 .unwrap_or(0)
1598 .clamp(0, 80);
1599
1600 for (path, name, tags) in rows.drain(..) {
1601 let padded_path = format!("{:<path_width$}", path);
1602 let padded_name = format!("{:<name_width$}", name);
1603 let rendered_path = apply_color(&padded_path, color, AnsiColor::Cyan);
1604 let rendered_name = apply_color(&padded_name, color, AnsiColor::Yellow);
1605 let rendered_tags = apply_color(&tags, color, AnsiColor::Green);
1606
1607 writeln!(w, "{rendered_path} {rendered_name} {rendered_tags}")?;
1608 }
1609
1610 Ok(())
1611}
1612
1613#[derive(Copy, Clone)]
1614enum AnsiColor {
1615 Cyan,
1616 Yellow,
1617 Green,
1618}
1619
1620fn apply_color(text: &str, color: bool, code: AnsiColor) -> String {
1621 if !color {
1622 return text.to_string();
1623 }
1624
1625 let code_str = match code {
1626 AnsiColor::Cyan => "36",
1627 AnsiColor::Yellow => "33",
1628 AnsiColor::Green => "32",
1629 };
1630
1631 format!("\u{1b}[{}m{}\u{1b}[0m", code_str, text)
1632}
1633
1634fn render_path(item: &TypgFontFaceMatch, color: bool, collections: bool) -> String {
1635 let rendered = if collections {
1636 item.source.path_with_index()
1637 } else {
1638 item.source.path.display().to_string()
1639 };
1640 apply_color(&rendered, color, AnsiColor::Cyan)
1641}
1642
1643fn run_cache_add(args: CacheAddArgs, quiet: bool) -> Result<()> {
1644 if matches!(args.jobs, Some(0)) {
1645 return Err(anyhow!("--jobs must be at least 1"));
1646 }
1647
1648 #[cfg(feature = "hpindex")]
1649 if args.use_index {
1650 return run_cache_add_index(args, quiet);
1651 }
1652
1653 #[cfg(not(feature = "hpindex"))]
1654 if args.use_index {
1655 return Err(anyhow!(
1656 "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1657 ));
1658 }
1659
1660 let stdin = io::stdin();
1661 let paths = gather_paths(
1662 &args.paths,
1663 args.stdin_paths,
1664 args.system_fonts,
1665 stdin.lock(),
1666 )?;
1667
1668 let opts = SearchOptions {
1669 follow_symlinks: args.follow_symlinks,
1670 jobs: args.jobs,
1671 };
1672 let additions = search(&paths, &Query::new(), &opts)?;
1673
1674 let cache_path = resolve_cache_path(&args.cache_path)?;
1675 let existing = if cache_path.exists() {
1676 load_cache(&cache_path)?
1677 } else {
1678 Vec::new()
1679 };
1680
1681 let merged = merge_entries(existing, additions);
1682 write_cache(&cache_path, &merged)?;
1683
1684 if !quiet {
1685 eprintln!(
1686 "cached {} font faces at {}",
1687 merged.len(),
1688 cache_path.display()
1689 );
1690 }
1691 Ok(())
1692}
1693
1694fn run_cache_list(args: CacheListArgs) -> Result<()> {
1695 #[cfg(feature = "hpindex")]
1696 if args.use_index {
1697 return run_cache_list_index(args);
1698 }
1699
1700 #[cfg(not(feature = "hpindex"))]
1701 if args.use_index {
1702 return Err(anyhow!(
1703 "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1704 ));
1705 }
1706
1707 let cache_path = resolve_cache_path(&args.cache_path)?;
1708 let entries = load_cache(&cache_path)?;
1709 let output = OutputFormat::from_output(&args.output);
1710 write_matches(&entries, &output, &[])
1711}
1712
1713fn run_cache_find(args: CacheFindArgs) -> Result<()> {
1714 #[cfg(feature = "hpindex")]
1715 if args.use_index {
1716 return run_cache_find_index(args);
1717 }
1718
1719 #[cfg(not(feature = "hpindex"))]
1720 if args.use_index {
1721 return Err(anyhow!(
1722 "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1723 ));
1724 }
1725
1726 let cache_path = resolve_cache_path(&args.cache_path)?;
1727 let entries = load_cache(&cache_path)?;
1728 let query = build_query_from_parts(
1729 &args.axes,
1730 &args.features,
1731 &args.scripts,
1732 &args.tables,
1733 &args.name_patterns,
1734 &args.creator_patterns,
1735 &args.license_patterns,
1736 &args.codepoints,
1737 &args.text,
1738 args.variable,
1739 &args.weight,
1740 &args.width,
1741 &args.family_class,
1742 )?;
1743
1744 let matches = filter_cached(&entries, &query);
1745
1746 if args.count_only {
1747 println!("{}", matches.len());
1748 return Ok(());
1749 }
1750
1751 let output = OutputFormat::from_output(&args.output);
1752 write_matches(&matches, &output, &[])
1753}
1754
1755fn run_cache_clean(args: CacheCleanArgs, quiet: bool) -> Result<()> {
1756 #[cfg(feature = "hpindex")]
1757 if args.use_index {
1758 return run_cache_clean_index(args, quiet);
1759 }
1760
1761 #[cfg(not(feature = "hpindex"))]
1762 if args.use_index {
1763 return Err(anyhow!(
1764 "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1765 ));
1766 }
1767
1768 let cache_path = resolve_cache_path(&args.cache_path)?;
1769 let entries = load_cache(&cache_path)?;
1770 let before = entries.len();
1771 let pruned = prune_missing(entries);
1772 let after = pruned.len();
1773
1774 write_cache(&cache_path, &pruned)?;
1775 if !quiet {
1776 eprintln!(
1777 "removed {} missing entries ({} → {})",
1778 before.saturating_sub(after),
1779 before,
1780 after
1781 );
1782 }
1783 Ok(())
1784}
1785
1786fn run_cache_info(args: CacheInfoArgs) -> Result<()> {
1787 #[cfg(feature = "hpindex")]
1788 if args.use_index {
1789 return run_cache_info_index(args);
1790 }
1791
1792 #[cfg(not(feature = "hpindex"))]
1793 if args.use_index {
1794 return Err(anyhow!(
1795 "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1796 ));
1797 }
1798
1799 let cache_path = resolve_cache_path(&args.cache_path)?;
1800
1801 if !cache_path.exists() {
1802 if args.json {
1803 println!(r#"{{"exists":false,"path":"{}"}}"#, cache_path.display());
1804 } else {
1805 println!("Cache does not exist at {}", cache_path.display());
1806 }
1807 return Ok(());
1808 }
1809
1810 let entries = load_cache(&cache_path)?;
1811 let file_meta = fs::metadata(&cache_path)?;
1812 let size_bytes = file_meta.len();
1813
1814 if args.json {
1815 let info = serde_json::json!({
1816 "exists": true,
1817 "path": cache_path.display().to_string(),
1818 "type": "json",
1819 "entries": entries.len(),
1820 "size_bytes": size_bytes,
1821 });
1822 println!("{}", serde_json::to_string_pretty(&info)?);
1823 } else {
1824 println!("Cache: {}", cache_path.display());
1825 println!("Type: JSON");
1826 println!("Fonts: {}", entries.len());
1827 println!("Size: {} bytes", size_bytes);
1828 }
1829
1830 Ok(())
1831}
1832
1833fn resolve_cache_path(custom: &Option<PathBuf>) -> Result<PathBuf> {
1834 if let Some(path) = custom {
1835 return Ok(path.clone());
1836 }
1837
1838 if let Ok(env_override) = env::var("TYPOG_CACHE_PATH") {
1839 return Ok(PathBuf::from(env_override));
1840 }
1841
1842 #[cfg(target_os = "windows")]
1843 {
1844 if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1845 return Ok(PathBuf::from(local_appdata).join("typg").join("cache.json"));
1846 }
1847 if let Some(home) = env::var_os("HOME") {
1848 return Ok(PathBuf::from(home).join("AppData/Local/typg/cache.json"));
1849 }
1850 }
1851
1852 #[cfg(not(target_os = "windows"))]
1853 {
1854 if let Some(xdg) = env::var_os("XDG_CACHE_HOME") {
1855 return Ok(PathBuf::from(xdg).join("typg").join("cache.json"));
1856 }
1857 if let Some(home) = env::var_os("HOME") {
1858 return Ok(PathBuf::from(home)
1859 .join(".cache")
1860 .join("typg")
1861 .join("cache.json"));
1862 }
1863 }
1864
1865 Err(anyhow!(
1866 "--cache-path is required because no cache directory could be detected"
1867 ))
1868}
1869
1870#[cfg_attr(not(feature = "hpindex"), allow(dead_code))]
1872fn resolve_index_path(custom: &Option<PathBuf>) -> Result<PathBuf> {
1873 if let Some(path) = custom {
1874 return Ok(path.clone());
1875 }
1876
1877 if let Ok(env_override) = env::var("TYPOG_INDEX_PATH") {
1878 return Ok(PathBuf::from(env_override));
1879 }
1880
1881 #[cfg(target_os = "windows")]
1882 {
1883 if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1884 return Ok(PathBuf::from(local_appdata).join("typg").join("index"));
1885 }
1886 if let Some(home) = env::var_os("HOME") {
1887 return Ok(PathBuf::from(home).join("AppData/Local/typg/index"));
1888 }
1889 }
1890
1891 #[cfg(not(target_os = "windows"))]
1892 {
1893 if let Some(xdg) = env::var_os("XDG_CACHE_HOME") {
1894 return Ok(PathBuf::from(xdg).join("typg").join("index"));
1895 }
1896 if let Some(home) = env::var_os("HOME") {
1897 return Ok(PathBuf::from(home)
1898 .join(".cache")
1899 .join("typg")
1900 .join("index"));
1901 }
1902 }
1903
1904 Err(anyhow!(
1905 "--index-path is required because no cache directory could be detected"
1906 ))
1907}
1908
1909fn load_cache(path: &Path) -> Result<Vec<TypgFontFaceMatch>> {
1911 let file = File::open(path).with_context(|| format!("opening cache {}", path.display()))?;
1912 let reader = BufReader::new(file);
1913
1914 match serde_json::from_reader(reader) {
1915 Ok(entries) => Ok(entries),
1916 Err(_) => {
1917 let file =
1919 File::open(path).with_context(|| format!("re-opening cache {}", path.display()))?;
1920 let reader = BufReader::new(file);
1921 let stream = Deserializer::from_reader(reader).into_iter::<TypgFontFaceMatch>();
1922 let mut entries = Vec::new();
1923 for item in stream {
1924 entries.push(item?);
1925 }
1926 Ok(entries)
1927 }
1928 }
1929}
1930
1931fn write_cache(path: &Path, entries: &[TypgFontFaceMatch]) -> Result<()> {
1933 if let Some(parent) = path.parent() {
1934 fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
1935 }
1936
1937 let file = File::create(path).with_context(|| format!("creating cache {}", path.display()))?;
1938 let mut writer = BufWriter::new(file);
1939 serde_json::to_writer_pretty(&mut writer, entries)
1940 .with_context(|| format!("writing cache {}", path.display()))?;
1941 writer.flush()?;
1942 Ok(())
1943}
1944
1945fn merge_entries(
1946 existing: Vec<TypgFontFaceMatch>,
1947 additions: Vec<TypgFontFaceMatch>,
1948) -> Vec<TypgFontFaceMatch> {
1949 let mut map: HashMap<(PathBuf, Option<u32>), TypgFontFaceMatch> = HashMap::new();
1950
1951 for entry in existing.into_iter().chain(additions.into_iter()) {
1952 map.insert(cache_key(&entry), entry);
1953 }
1954
1955 let mut merged: Vec<TypgFontFaceMatch> = map.into_values().collect();
1956 sort_entries(&mut merged);
1957 merged
1958}
1959
1960fn prune_missing(entries: Vec<TypgFontFaceMatch>) -> Vec<TypgFontFaceMatch> {
1961 let mut pruned: Vec<TypgFontFaceMatch> = entries
1962 .into_iter()
1963 .filter(|entry| entry.source.path.exists())
1964 .collect();
1965 sort_entries(&mut pruned);
1966 pruned
1967}
1968
1969fn sort_entries(entries: &mut [TypgFontFaceMatch]) {
1970 entries.sort_by(|a, b| {
1971 a.source
1972 .path
1973 .cmp(&b.source.path)
1974 .then_with(|| a.source.ttc_index.cmp(&b.source.ttc_index))
1975 });
1976}
1977
1978fn cache_key(entry: &TypgFontFaceMatch) -> (PathBuf, Option<u32>) {
1979 (entry.source.path.clone(), entry.source.ttc_index)
1980}
1981
1982#[cfg(feature = "hpindex")]
1987fn run_cache_add_index(args: CacheAddArgs, quiet: bool) -> Result<()> {
1988 use std::time::SystemTime;
1989
1990 let stdin = io::stdin();
1991 let paths = gather_paths(
1992 &args.paths,
1993 args.stdin_paths,
1994 args.system_fonts,
1995 stdin.lock(),
1996 )?;
1997
1998 let index_path = resolve_index_path(&args.index_path)?;
1999 let index = FontIndex::open(&index_path)?;
2000
2001 let opts = SearchOptions {
2003 follow_symlinks: args.follow_symlinks,
2004 jobs: args.jobs,
2005 };
2006 let additions = search(&paths, &Query::new(), &opts)?;
2007
2008 let mut writer = index.writer()?;
2010 let mut added = 0usize;
2011 let mut skipped = 0usize;
2012
2013 for entry in additions {
2014 let mtime = entry
2016 .source
2017 .path
2018 .metadata()
2019 .and_then(|m| m.modified())
2020 .unwrap_or(SystemTime::UNIX_EPOCH);
2021
2022 if !writer.needs_update(&entry.source.path, mtime)? {
2024 skipped += 1;
2025 continue;
2026 }
2027
2028 writer.add_font(
2029 &entry.source.path,
2030 entry.source.ttc_index,
2031 mtime,
2032 &entry.metadata,
2033 )?;
2034 added += 1;
2035 }
2036
2037 writer.commit()?;
2038
2039 if !quiet {
2040 let total = index.count()?;
2041 eprintln!(
2042 "indexed {} font faces at {} (added: {}, skipped: {})",
2043 total,
2044 index_path.display(),
2045 added,
2046 skipped
2047 );
2048 }
2049
2050 Ok(())
2051}
2052
2053#[cfg(feature = "hpindex")]
2054fn run_cache_list_index(args: CacheListArgs) -> Result<()> {
2055 let index_path = resolve_index_path(&args.index_path)?;
2056 let index = FontIndex::open(&index_path)?;
2057 let reader = index.reader()?;
2058 let entries = reader.list_all()?;
2059 let output = OutputFormat::from_output(&args.output);
2060 write_matches(&entries, &output, &[])
2061}
2062
2063#[cfg(feature = "hpindex")]
2064fn run_cache_find_index(args: CacheFindArgs) -> Result<()> {
2065 let index_path = resolve_index_path(&args.index_path)?;
2066 let index = FontIndex::open(&index_path)?;
2067
2068 let query = build_query_from_parts(
2069 &args.axes,
2070 &args.features,
2071 &args.scripts,
2072 &args.tables,
2073 &args.name_patterns,
2074 &args.creator_patterns,
2075 &args.license_patterns,
2076 &args.codepoints,
2077 &args.text,
2078 args.variable,
2079 &args.weight,
2080 &args.width,
2081 &args.family_class,
2082 )?;
2083
2084 let reader = index.reader()?;
2085 let matches = reader.find(&query)?;
2086
2087 if args.count_only {
2088 println!("{}", matches.len());
2089 return Ok(());
2090 }
2091
2092 let output = OutputFormat::from_output(&args.output);
2093 write_matches(&matches, &output, &[])
2094}
2095
2096#[cfg(feature = "hpindex")]
2097fn run_cache_clean_index(args: CacheCleanArgs, quiet: bool) -> Result<()> {
2098 let index_path = resolve_index_path(&args.index_path)?;
2099 let index = FontIndex::open(&index_path)?;
2100
2101 let mut writer = index.writer()?;
2102 let (before, after) = writer.prune_missing()?;
2103 writer.commit()?;
2104
2105 if !quiet {
2106 eprintln!(
2107 "removed {} missing entries ({} → {})",
2108 before.saturating_sub(after),
2109 before,
2110 after
2111 );
2112 }
2113 Ok(())
2114}
2115
2116#[cfg(feature = "hpindex")]
2117fn run_cache_info_index(args: CacheInfoArgs) -> Result<()> {
2118 let index_path = resolve_index_path(&args.index_path)?;
2119
2120 if !index_path.exists() {
2121 if args.json {
2122 println!(r#"{{"exists":false,"path":"{}"}}"#, index_path.display());
2123 } else {
2124 println!("Index does not exist at {}", index_path.display());
2125 }
2126 return Ok(());
2127 }
2128
2129 let index = FontIndex::open(&index_path)?;
2130 let count = index.count()?;
2131
2132 let size_bytes: u64 = fs::read_dir(&index_path)?
2134 .filter_map(|e| e.ok())
2135 .filter_map(|e| e.metadata().ok())
2136 .filter(|m| m.is_file())
2137 .map(|m| m.len())
2138 .sum();
2139
2140 if args.json {
2141 let info = serde_json::json!({
2142 "exists": true,
2143 "path": index_path.display().to_string(),
2144 "type": "lmdb",
2145 "entries": count,
2146 "size_bytes": size_bytes,
2147 });
2148 println!("{}", serde_json::to_string_pretty(&info)?);
2149 } else {
2150 println!("Index: {}", index_path.display());
2151 println!("Type: LMDB");
2152 println!("Fonts: {}", count);
2153 println!("Size: {} bytes", size_bytes);
2154 }
2155
2156 Ok(())
2157}
2158
2159#[cfg(test)]
2160mod tests;