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 "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 "var".to_string(),
751 "wt".to_string(),
752 "wd".to_string(),
753 "panf".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 "var".to_string(),
762 "wt".to_string(),
763 "wd".to_string(),
764 "panf".to_string(),
765 "axes".to_string(),
766 "fea_n".to_string(),
767 "scr".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 "var".to_string(),
781 "wt".to_string(),
782 "wd".to_string(),
783 "panf".to_string(),
784 "axes".to_string(),
785 "axes_n".to_string(),
786 "fea".to_string(),
787 "fea_n".to_string(),
788 "scr".to_string(),
789 "scr_n".to_string(),
790 "tab".to_string(),
791 "tab_n".to_string(),
792 "crea".to_string(),
793 "lic".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 valid_keywords = [
801 "path", "path_r", "fmt", "fname", "sname", "psname", "tfname", "lfname",
802 "tsname", "lsname", "var", "wt", "wd", "panf", "axes", "axes_n", "fea",
803 "fea_n", "scr", "scr_n", "tab", "tab_n", "crea", "lic",
804 ];
805 if !valid_keywords.contains(&trimmed) {
806 return Err(anyhow!("invalid details keyword: {}", trimmed));
807 }
808 parts.push(trimmed.to_string());
809 }
810 }
811 if parts.is_empty() {
812 return Err(anyhow!("empty details option"));
813 }
814 parts
815 }
816 };
817 Ok(presets)
818}
819
820fn get_effective_properties(format: &OutputFormat) -> Result<Vec<String>> {
821 if let Some(ref d) = format.details {
822 parse_details_list(d)
823 } else if format.csv {
824 Ok(vec![
825 "path".to_string(),
826 "fname".to_string(),
827 "sname".to_string(),
828 "fmt".to_string(),
829 "var".to_string(),
830 "wt".to_string(),
831 "wd".to_string(),
832 ])
833 } else {
834 Ok(Vec::new())
835 }
836}
837
838fn extract_property_value(
839 m: &TypgFontFaceMatch,
840 prop: &str,
841 roots: &[PathBuf],
842 collections: bool,
843) -> serde_json::Value {
844 match prop {
845 "path" => {
846 if collections {
847 serde_json::Value::String(m.source.path_with_index())
848 } else {
849 serde_json::Value::String(m.source.path.to_string_lossy().to_string())
850 }
851 }
852 "path_r" => {
853 let rel = get_relative_path(&m.source.path, roots);
854 if collections {
855 if let Some(idx) = m.source.ttc_index {
856 serde_json::Value::String(format!("{}#{}", rel, idx))
857 } else {
858 serde_json::Value::String(rel)
859 }
860 } else {
861 serde_json::Value::String(rel)
862 }
863 }
864 "fmt" => {
865 let ext = m
866 .source
867 .path
868 .extension()
869 .and_then(|e| e.to_str())
870 .map(|e| e.to_ascii_lowercase())
871 .unwrap_or_else(|| "unknown".to_string());
872 serde_json::Value::String(ext)
873 }
874 "fname" => {
875 let name = m
876 .metadata
877 .tfname
878 .clone()
879 .or_else(|| m.metadata.lfname.clone())
880 .unwrap_or_else(|| m.metadata.names.first().cloned().unwrap_or_default());
881 serde_json::Value::String(name)
882 }
883 "sname" => {
884 let style = m
885 .metadata
886 .tsname
887 .clone()
888 .or_else(|| m.metadata.lsname.clone())
889 .unwrap_or_default();
890 serde_json::Value::String(style)
891 }
892 "psname" => serde_json::Value::String(m.metadata.psname.clone().unwrap_or_default()),
893 "tfname" => serde_json::Value::String(m.metadata.tfname.clone().unwrap_or_default()),
894 "lfname" => serde_json::Value::String(m.metadata.lfname.clone().unwrap_or_default()),
895 "tsname" => serde_json::Value::String(m.metadata.tsname.clone().unwrap_or_default()),
896 "lsname" => serde_json::Value::String(m.metadata.lsname.clone().unwrap_or_default()),
897 "var" => serde_json::Value::Bool(m.metadata.is_variable),
898 "wt" => serde_json::Value::Number(m.metadata.weight_class.unwrap_or_default().into()),
899 "wd" => serde_json::Value::Number(m.metadata.width_class.unwrap_or_default().into()),
900 "panf" => {
901 let fc = m
902 .metadata
903 .family_class
904 .map(|(maj, sub)| format!("{}.{}", maj, sub))
905 .unwrap_or_default();
906 serde_json::Value::String(fc)
907 }
908 "axes" => {
909 let list: Vec<String> = m
910 .metadata
911 .axis_tags
912 .iter()
913 .map(|t| tag_to_string(*t))
914 .collect();
915 serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
916 }
917 "axes_n" => serde_json::Value::Number(m.metadata.axis_tags.len().into()),
918 "fea" => {
919 let list: Vec<String> = m
920 .metadata
921 .feature_tags
922 .iter()
923 .map(|t| tag_to_string(*t))
924 .collect();
925 serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
926 }
927 "fea_n" => serde_json::Value::Number(m.metadata.feature_tags.len().into()),
928 "scr" => {
929 let list: Vec<String> = m
930 .metadata
931 .script_tags
932 .iter()
933 .map(|t| tag_to_string(*t))
934 .collect();
935 serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
936 }
937 "scr_n" => serde_json::Value::Number(m.metadata.script_tags.len().into()),
938 "tab" => {
939 let list: Vec<String> = m
940 .metadata
941 .table_tags
942 .iter()
943 .map(|t| tag_to_string(*t))
944 .collect();
945 serde_json::Value::Array(list.into_iter().map(serde_json::Value::String).collect())
946 }
947 "tab_n" => serde_json::Value::Number(m.metadata.table_tags.len().into()),
948 "crea" => serde_json::Value::Array(
949 m.metadata
950 .creator_names
951 .iter()
952 .map(|s| serde_json::Value::String(s.clone()))
953 .collect(),
954 ),
955 "lic" => serde_json::Value::Array(
956 m.metadata
957 .license_names
958 .iter()
959 .map(|s| serde_json::Value::String(s.clone()))
960 .collect(),
961 ),
962 _ => serde_json::Value::Null,
963 }
964}
965
966fn format_value_csv(val: &serde_json::Value) -> String {
967 match val {
968 serde_json::Value::Null => String::new(),
969 serde_json::Value::Bool(b) => b.to_string(),
970 serde_json::Value::Number(n) => n.to_string(),
971 serde_json::Value::String(s) => s.clone(),
972 serde_json::Value::Array(arr) => arr
973 .iter()
974 .map(|item| match item {
975 serde_json::Value::String(s) => s.clone(),
976 other => other.to_string(),
977 })
978 .collect::<Vec<_>>()
979 .join(";"),
980 serde_json::Value::Object(obj) => serde_json::to_string(obj).unwrap_or_default(),
981 }
982}
983
984fn escape_csv_field(field: &str) -> String {
985 if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
986 let escaped = field.replace('"', "\"\"");
987 format!("\"{}\"", escaped)
988 } else {
989 field.to_string()
990 }
991}
992
993fn write_csv(
994 matches: &[TypgFontFaceMatch],
995 mut w: impl Write,
996 format: &OutputFormat,
997 roots: &[PathBuf],
998) -> Result<()> {
999 let props = get_effective_properties(format)?;
1000 let header_cols: Vec<String> = props.iter().map(|p| escape_csv_field(p)).collect();
1001 writeln!(w, "{}", header_cols.join(","))?;
1002
1003 let mut seen = std::collections::HashSet::new();
1004 for m in matches {
1005 if format.collections || seen.insert(m.source.path.clone()) {
1006 let mut row_cols = Vec::new();
1007 for prop in &props {
1008 let val = extract_property_value(m, prop, roots, format.collections);
1009 let fmt_val = format_value_csv(&val);
1010 row_cols.push(escape_csv_field(&fmt_val));
1011 }
1012 writeln!(w, "{}", row_cols.join(","))?;
1013 }
1014 }
1015 Ok(())
1016}
1017
1018fn write_json_pretty_filtered(
1019 matches: &[TypgFontFaceMatch],
1020 mut w: impl Write,
1021 format: &OutputFormat,
1022 roots: &[PathBuf],
1023) -> Result<()> {
1024 let props = get_effective_properties(format)?;
1025 if props.is_empty() {
1026 let json = serde_json::to_string_pretty(matches)?;
1027 w.write_all(json.as_bytes())?;
1028 return Ok(());
1029 }
1030
1031 if props.len() == 1 && props[0] == "path" {
1032 let mut paths = Vec::new();
1033 let mut seen = std::collections::HashSet::new();
1034 for m in matches {
1035 if format.collections || seen.insert(m.source.path.clone()) {
1036 let path_str = if format.collections {
1037 m.source.path_with_index()
1038 } else {
1039 m.source.path.to_string_lossy().to_string()
1040 };
1041 paths.push(path_str);
1042 }
1043 }
1044 let json = serde_json::to_string_pretty(&paths)?;
1045 w.write_all(json.as_bytes())?;
1046 } else {
1047 let mut objects = Vec::new();
1048 let mut seen = std::collections::HashSet::new();
1049 for m in matches {
1050 if format.collections || seen.insert(m.source.path.clone()) {
1051 let mut map = serde_json::Map::new();
1052 for prop in &props {
1053 let val = extract_property_value(m, prop, roots, format.collections);
1054 map.insert(prop.clone(), val);
1055 }
1056 objects.push(serde_json::Value::Object(map));
1057 }
1058 }
1059 let json = serde_json::to_string_pretty(&objects)?;
1060 w.write_all(json.as_bytes())?;
1061 }
1062 Ok(())
1063}
1064
1065fn write_ndjson_filtered(
1066 matches: &[TypgFontFaceMatch],
1067 mut w: impl Write,
1068 format: &OutputFormat,
1069 roots: &[PathBuf],
1070) -> Result<()> {
1071 let props = get_effective_properties(format)?;
1072 if props.is_empty() {
1073 for m in matches {
1074 let line = serde_json::to_string(m)?;
1075 w.write_all(line.as_bytes())?;
1076 w.write_all(b"\n")?;
1077 }
1078 return Ok(());
1079 }
1080
1081 let mut seen = std::collections::HashSet::new();
1082 for m in matches {
1083 if format.collections || seen.insert(m.source.path.clone()) {
1084 if props.len() == 1 && props[0] == "path" {
1085 let path_str = if format.collections {
1086 m.source.path_with_index()
1087 } else {
1088 m.source.path.to_string_lossy().to_string()
1089 };
1090 let line = serde_json::to_string(&path_str)?;
1091 w.write_all(line.as_bytes())?;
1092 w.write_all(b"\n")?;
1093 } else {
1094 let mut map = serde_json::Map::new();
1095 for prop in &props {
1096 let val = extract_property_value(m, prop, roots, format.collections);
1097 map.insert(prop.clone(), val);
1098 }
1099 let line = serde_json::to_string(&serde_json::Value::Object(map))?;
1100 w.write_all(line.as_bytes())?;
1101 w.write_all(b"\n")?;
1102 }
1103 }
1104 }
1105 Ok(())
1106}
1107
1108fn write_columns_filtered(
1109 matches: &[TypgFontFaceMatch],
1110 mut w: impl Write,
1111 format: &OutputFormat,
1112 color: bool,
1113 roots: &[PathBuf],
1114) -> Result<()> {
1115 let props = get_effective_properties(format)?;
1116 if props.is_empty() {
1117 return write_columns(matches, w, color, format.collections);
1118 }
1119
1120 let mut seen = std::collections::HashSet::new();
1121 let mut rows = Vec::new();
1122 for m in matches {
1123 if format.collections || seen.insert(m.source.path.clone()) {
1124 let mut row = Vec::new();
1125 for prop in &props {
1126 let val = extract_property_value(m, prop, roots, format.collections);
1127 let str_val = match val {
1128 serde_json::Value::Null => String::new(),
1129 serde_json::Value::String(s) => s,
1130 serde_json::Value::Array(arr) => arr
1131 .iter()
1132 .map(|item| match item {
1133 serde_json::Value::String(s) => s.clone(),
1134 other => other.to_string(),
1135 })
1136 .collect::<Vec<_>>()
1137 .join(","),
1138 other => other.to_string(),
1139 };
1140 row.push(str_val);
1141 }
1142 rows.push(row);
1143 }
1144 }
1145
1146 if rows.is_empty() {
1147 return Ok(());
1148 }
1149
1150 let num_cols = props.len();
1151 let mut col_widths = vec![0; num_cols];
1152 for row in &rows {
1153 for (i, col) in row.iter().enumerate() {
1154 col_widths[i] = col_widths[i].max(col.len());
1155 }
1156 }
1157
1158 for row in rows {
1159 let mut cols = Vec::new();
1160 for (i, col) in row.iter().enumerate() {
1161 let width = col_widths[i];
1162 let padded = format!("{:<width$}", col);
1163 let rendered_col = if i == 0 {
1164 apply_color(&padded, color, AnsiColor::Cyan)
1165 } else if i == 1 {
1166 apply_color(&padded, color, AnsiColor::Yellow)
1167 } else {
1168 apply_color(&padded, color, AnsiColor::Green)
1169 };
1170 cols.push(rendered_col);
1171 }
1172 writeln!(w, "{}", cols.join(" "))?;
1173 }
1174
1175 Ok(())
1176}
1177
1178fn write_plain_filtered(
1179 matches: &[TypgFontFaceMatch],
1180 mut w: impl Write,
1181 format: &OutputFormat,
1182 color: bool,
1183 roots: &[PathBuf],
1184) -> Result<()> {
1185 let props = get_effective_properties(format)?;
1186 if props.is_empty() {
1187 return write_plain(matches, w, color, format.collections);
1188 }
1189
1190 let mut seen = std::collections::HashSet::new();
1191 for m in matches {
1192 if format.collections || seen.insert(m.source.path.clone()) {
1193 let mut row = Vec::new();
1194 for prop in &props {
1195 let val = extract_property_value(m, prop, roots, format.collections);
1196 let str_val = match val {
1197 serde_json::Value::Null => String::new(),
1198 serde_json::Value::String(s) => s,
1199 serde_json::Value::Array(arr) => arr
1200 .iter()
1201 .map(|item| match item {
1202 serde_json::Value::String(s) => s.clone(),
1203 other => other.to_string(),
1204 })
1205 .collect::<Vec<_>>()
1206 .join(","),
1207 other => other.to_string(),
1208 };
1209 row.push(str_val);
1210 }
1211 writeln!(w, "{}", row.join(" "))?;
1212 }
1213 }
1214 Ok(())
1215}
1216
1217fn build_query(args: &FindArgs) -> Result<Query> {
1222 build_query_from_parts(
1223 &args.axes,
1224 &args.features,
1225 &args.scripts,
1226 &args.tables,
1227 &args.name_patterns,
1228 &args.creator_patterns,
1229 &args.license_patterns,
1230 &args.codepoints,
1231 &args.text,
1232 args.variable,
1233 &args.weight,
1234 &args.width,
1235 &args.family_class,
1236 )
1237}
1238
1239#[allow(clippy::too_many_arguments)]
1248fn build_query_from_parts(
1249 axes: &[String],
1250 features: &[String],
1251 scripts: &[String],
1252 tables: &[String],
1253 name_patterns: &[String],
1254 creator_patterns: &[String],
1255 license_patterns: &[String],
1256 codepoints: &[String],
1257 text: &Option<String>,
1258 variable: bool,
1259 weight: &Option<String>,
1260 width: &Option<String>,
1261 family_class: &Option<String>,
1262) -> Result<Query> {
1263 let axes = parse_tag_list(axes)?;
1264 let features = parse_tag_list(features)?;
1265 let scripts = parse_tag_list(scripts)?;
1266 let tables = parse_tag_list(tables)?;
1267 let name_patterns = compile_patterns(name_patterns)?;
1268 let creator_patterns = compile_patterns(creator_patterns)?;
1269 let license_patterns = compile_patterns(license_patterns)?;
1270 let mut codepoints = parse_codepoints(codepoints)?;
1271 let weight_range = parse_optional_range(weight)?;
1272 let width_range = parse_optional_range(width)?;
1273 let family_class = parse_optional_family_class(family_class)?;
1274
1275 if let Some(text) = text {
1276 codepoints.extend(text.chars());
1277 }
1278
1279 dedup_chars(&mut codepoints);
1280
1281 Ok(Query::new()
1282 .with_axes(axes)
1283 .with_features(features)
1284 .with_scripts(scripts)
1285 .with_tables(tables)
1286 .with_name_patterns(name_patterns)
1287 .with_creator_patterns(creator_patterns)
1288 .with_license_patterns(license_patterns)
1289 .with_codepoints(codepoints)
1290 .require_variable(variable)
1291 .with_weight_range(weight_range)
1292 .with_width_range(width_range)
1293 .with_family_class(family_class))
1294}
1295
1296fn dedup_chars(cps: &mut Vec<char>) {
1297 cps.sort();
1298 cps.dedup();
1299}
1300
1301fn compile_patterns(patterns: &[String]) -> Result<Vec<Regex>> {
1302 patterns
1303 .iter()
1304 .map(|p| Regex::new(p).with_context(|| format!("invalid regex: {p}")))
1305 .collect()
1306}
1307
1308fn parse_codepoints(raw: &[String]) -> Result<Vec<char>> {
1309 let mut cps = Vec::new();
1310 for chunk in raw {
1311 cps.extend(parse_codepoint_list(chunk)?);
1312 }
1313 Ok(cps)
1314}
1315
1316fn parse_optional_range(raw: &Option<String>) -> Result<Option<RangeInclusive<u16>>> {
1317 match raw {
1318 Some(value) => Ok(Some(parse_u16_range(value)?)),
1319 None => Ok(None),
1320 }
1321}
1322
1323fn parse_optional_family_class(raw: &Option<String>) -> Result<Option<FamilyClassFilter>> {
1324 match raw {
1325 Some(value) => Ok(Some(parse_family_class(value)?)),
1326 None => Ok(None),
1327 }
1328}
1329
1330fn gather_paths(
1343 raw_paths: &[PathBuf],
1344 read_stdin: bool,
1345 include_system: bool,
1346 mut stdin: impl BufRead,
1347) -> Result<Vec<PathBuf>> {
1348 let mut paths = Vec::new();
1349
1350 if read_stdin {
1351 paths.extend(read_paths_from(&mut stdin)?);
1352 }
1353
1354 for path in raw_paths {
1355 if path == Path::new("-") {
1356 paths.extend(read_paths_from(&mut stdin)?);
1357 } else {
1358 paths.push(path.clone());
1359 }
1360 }
1361
1362 if include_system {
1363 paths.extend(system_font_roots()?);
1364 }
1365
1366 if paths.is_empty() {
1367 return Err(anyhow!("no search paths provided"));
1368 }
1369
1370 Ok(paths)
1371}
1372
1373fn read_paths_from(reader: &mut impl BufRead) -> Result<Vec<PathBuf>> {
1374 let mut buf = String::new();
1375 let mut paths = Vec::new();
1376
1377 loop {
1378 buf.clear();
1379 let read = reader.read_line(&mut buf)?;
1380 if read == 0 {
1381 break;
1382 }
1383
1384 let trimmed = buf.trim();
1385 if !trimmed.is_empty() {
1386 paths.push(PathBuf::from(trimmed));
1387 }
1388 }
1389
1390 Ok(paths)
1391}
1392
1393fn system_font_roots() -> Result<Vec<PathBuf>> {
1405 if let Ok(raw) = env::var("TYPOG_SYSTEM_FONT_DIRS") {
1406 let mut overrides: Vec<PathBuf> = raw
1407 .split([':', ';'])
1408 .filter(|s| !s.is_empty())
1409 .map(PathBuf::from)
1410 .filter(|p| p.exists())
1411 .collect();
1412
1413 overrides.sort();
1414 overrides.dedup();
1415
1416 return if overrides.is_empty() {
1417 Err(anyhow!("TYPOG_SYSTEM_FONT_DIRS is set but no paths exist"))
1418 } else {
1419 Ok(overrides)
1420 };
1421 }
1422
1423 let mut candidates: Vec<PathBuf> = Vec::new();
1424
1425 #[cfg(target_os = "macos")]
1426 {
1427 candidates.push(PathBuf::from("/System/Library/Fonts"));
1428 candidates.push(PathBuf::from("/Library/Fonts"));
1429 if let Some(home) = env::var_os("HOME") {
1430 candidates.push(PathBuf::from(home).join("Library/Fonts"));
1431 }
1432 }
1433
1434 #[cfg(target_os = "linux")]
1435 {
1436 candidates.push(PathBuf::from("/usr/share/fonts"));
1437 candidates.push(PathBuf::from("/usr/local/share/fonts"));
1438 if let Some(home) = env::var_os("HOME") {
1439 candidates.push(PathBuf::from(home).join(".local/share/fonts"));
1440 }
1441 }
1442
1443 #[cfg(target_os = "windows")]
1444 {
1445 if let Some(system_root) = env::var_os("SYSTEMROOT") {
1446 candidates.push(PathBuf::from(system_root).join("Fonts"));
1447 }
1448 if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1449 candidates.push(PathBuf::from(local_appdata).join("Microsoft/Windows/Fonts"));
1450 }
1451 }
1452
1453 candidates.retain(|p| p.exists());
1454 candidates.sort();
1455 candidates.dedup();
1456
1457 if candidates.is_empty() {
1458 return Err(anyhow!(
1459 "no system font directories found for this platform"
1460 ));
1461 }
1462
1463 Ok(candidates)
1464}
1465
1466fn write_plain(
1473 matches: &[TypgFontFaceMatch],
1474 mut w: impl Write,
1475 color: bool,
1476 collections: bool,
1477) -> Result<()> {
1478 if collections {
1479 for item in matches {
1480 let rendered = render_path(item, color, true);
1481 writeln!(w, "{rendered}")?;
1482 }
1483 } else {
1484 let mut seen = std::collections::HashSet::new();
1485 for item in matches {
1486 if seen.insert(item.source.path.clone()) {
1487 let rendered = render_path(item, color, false);
1488 writeln!(w, "{rendered}")?;
1489 }
1490 }
1491 }
1492 Ok(())
1493}
1494
1495fn write_paths(matches: &[TypgFontFaceMatch], mut w: impl Write, collections: bool) -> Result<()> {
1503 if collections {
1504 for item in matches {
1505 writeln!(w, "{}", item.source.path_with_index())?;
1506 }
1507 } else {
1508 let mut seen = std::collections::HashSet::new();
1509 for item in matches {
1510 if seen.insert(item.source.path.clone()) {
1511 writeln!(w, "{}", item.source.path.display())?;
1512 }
1513 }
1514 }
1515 Ok(())
1516}
1517
1518fn write_columns(
1529 matches: &[TypgFontFaceMatch],
1530 mut w: impl Write,
1531 color: bool,
1532 collections: bool,
1533) -> Result<()> {
1534 let mut rows: Vec<(String, String, String)> = matches
1535 .iter()
1536 .map(|m| {
1537 let path = if collections {
1538 m.source.path_with_index()
1539 } else {
1540 m.source.path.display().to_string()
1541 };
1542 let name = m
1543 .metadata
1544 .names
1545 .first()
1546 .cloned()
1547 .unwrap_or_else(|| "(unnamed)".to_string());
1548
1549 let tags = format!(
1550 "axes:{:<2} feats:{:<2} scripts:{:<2} tables:{:<2}{}",
1551 m.metadata.axis_tags.len(),
1552 m.metadata.feature_tags.len(),
1553 m.metadata.script_tags.len(),
1554 m.metadata.table_tags.len(),
1555 if m.metadata.is_variable { " var" } else { "" },
1556 );
1557
1558 (path, name, tags)
1559 })
1560 .collect();
1561
1562 let path_width = rows
1563 .iter()
1564 .map(|r| r.0.len())
1565 .max()
1566 .unwrap_or(0)
1567 .clamp(0, 120);
1568 let name_width = rows
1569 .iter()
1570 .map(|r| r.1.len())
1571 .max()
1572 .unwrap_or(0)
1573 .clamp(0, 80);
1574
1575 for (path, name, tags) in rows.drain(..) {
1576 let padded_path = format!("{:<path_width$}", path);
1577 let padded_name = format!("{:<name_width$}", name);
1578 let rendered_path = apply_color(&padded_path, color, AnsiColor::Cyan);
1579 let rendered_name = apply_color(&padded_name, color, AnsiColor::Yellow);
1580 let rendered_tags = apply_color(&tags, color, AnsiColor::Green);
1581
1582 writeln!(w, "{rendered_path} {rendered_name} {rendered_tags}")?;
1583 }
1584
1585 Ok(())
1586}
1587
1588#[derive(Copy, Clone)]
1589enum AnsiColor {
1590 Cyan,
1591 Yellow,
1592 Green,
1593}
1594
1595fn apply_color(text: &str, color: bool, code: AnsiColor) -> String {
1596 if !color {
1597 return text.to_string();
1598 }
1599
1600 let code_str = match code {
1601 AnsiColor::Cyan => "36",
1602 AnsiColor::Yellow => "33",
1603 AnsiColor::Green => "32",
1604 };
1605
1606 format!("\u{1b}[{}m{}\u{1b}[0m", code_str, text)
1607}
1608
1609fn render_path(item: &TypgFontFaceMatch, color: bool, collections: bool) -> String {
1610 let rendered = if collections {
1611 item.source.path_with_index()
1612 } else {
1613 item.source.path.display().to_string()
1614 };
1615 apply_color(&rendered, color, AnsiColor::Cyan)
1616}
1617
1618fn run_cache_add(args: CacheAddArgs, quiet: bool) -> Result<()> {
1619 if matches!(args.jobs, Some(0)) {
1620 return Err(anyhow!("--jobs must be at least 1"));
1621 }
1622
1623 #[cfg(feature = "hpindex")]
1624 if args.use_index {
1625 return run_cache_add_index(args, quiet);
1626 }
1627
1628 #[cfg(not(feature = "hpindex"))]
1629 if args.use_index {
1630 return Err(anyhow!(
1631 "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1632 ));
1633 }
1634
1635 let stdin = io::stdin();
1636 let paths = gather_paths(
1637 &args.paths,
1638 args.stdin_paths,
1639 args.system_fonts,
1640 stdin.lock(),
1641 )?;
1642
1643 let opts = SearchOptions {
1644 follow_symlinks: args.follow_symlinks,
1645 jobs: args.jobs,
1646 };
1647 let additions = search(&paths, &Query::new(), &opts)?;
1648
1649 let cache_path = resolve_cache_path(&args.cache_path)?;
1650 let existing = if cache_path.exists() {
1651 load_cache(&cache_path)?
1652 } else {
1653 Vec::new()
1654 };
1655
1656 let merged = merge_entries(existing, additions);
1657 write_cache(&cache_path, &merged)?;
1658
1659 if !quiet {
1660 eprintln!(
1661 "cached {} font faces at {}",
1662 merged.len(),
1663 cache_path.display()
1664 );
1665 }
1666 Ok(())
1667}
1668
1669fn run_cache_list(args: CacheListArgs) -> Result<()> {
1670 #[cfg(feature = "hpindex")]
1671 if args.use_index {
1672 return run_cache_list_index(args);
1673 }
1674
1675 #[cfg(not(feature = "hpindex"))]
1676 if args.use_index {
1677 return Err(anyhow!(
1678 "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1679 ));
1680 }
1681
1682 let cache_path = resolve_cache_path(&args.cache_path)?;
1683 let entries = load_cache(&cache_path)?;
1684 let output = OutputFormat::from_output(&args.output);
1685 write_matches(&entries, &output, &[])
1686}
1687
1688fn run_cache_find(args: CacheFindArgs) -> Result<()> {
1689 #[cfg(feature = "hpindex")]
1690 if args.use_index {
1691 return run_cache_find_index(args);
1692 }
1693
1694 #[cfg(not(feature = "hpindex"))]
1695 if args.use_index {
1696 return Err(anyhow!(
1697 "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1698 ));
1699 }
1700
1701 let cache_path = resolve_cache_path(&args.cache_path)?;
1702 let entries = load_cache(&cache_path)?;
1703 let query = build_query_from_parts(
1704 &args.axes,
1705 &args.features,
1706 &args.scripts,
1707 &args.tables,
1708 &args.name_patterns,
1709 &args.creator_patterns,
1710 &args.license_patterns,
1711 &args.codepoints,
1712 &args.text,
1713 args.variable,
1714 &args.weight,
1715 &args.width,
1716 &args.family_class,
1717 )?;
1718
1719 let matches = filter_cached(&entries, &query);
1720
1721 if args.count_only {
1722 println!("{}", matches.len());
1723 return Ok(());
1724 }
1725
1726 let output = OutputFormat::from_output(&args.output);
1727 write_matches(&matches, &output, &[])
1728}
1729
1730fn run_cache_clean(args: CacheCleanArgs, quiet: bool) -> Result<()> {
1731 #[cfg(feature = "hpindex")]
1732 if args.use_index {
1733 return run_cache_clean_index(args, quiet);
1734 }
1735
1736 #[cfg(not(feature = "hpindex"))]
1737 if args.use_index {
1738 return Err(anyhow!(
1739 "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1740 ));
1741 }
1742
1743 let cache_path = resolve_cache_path(&args.cache_path)?;
1744 let entries = load_cache(&cache_path)?;
1745 let before = entries.len();
1746 let pruned = prune_missing(entries);
1747 let after = pruned.len();
1748
1749 write_cache(&cache_path, &pruned)?;
1750 if !quiet {
1751 eprintln!(
1752 "removed {} missing entries ({} → {})",
1753 before.saturating_sub(after),
1754 before,
1755 after
1756 );
1757 }
1758 Ok(())
1759}
1760
1761fn run_cache_info(args: CacheInfoArgs) -> Result<()> {
1762 #[cfg(feature = "hpindex")]
1763 if args.use_index {
1764 return run_cache_info_index(args);
1765 }
1766
1767 #[cfg(not(feature = "hpindex"))]
1768 if args.use_index {
1769 return Err(anyhow!(
1770 "--index requires the hpindex feature; rebuild with: cargo build --features hpindex"
1771 ));
1772 }
1773
1774 let cache_path = resolve_cache_path(&args.cache_path)?;
1775
1776 if !cache_path.exists() {
1777 if args.json {
1778 println!(r#"{{"exists":false,"path":"{}"}}"#, cache_path.display());
1779 } else {
1780 println!("Cache does not exist at {}", cache_path.display());
1781 }
1782 return Ok(());
1783 }
1784
1785 let entries = load_cache(&cache_path)?;
1786 let file_meta = fs::metadata(&cache_path)?;
1787 let size_bytes = file_meta.len();
1788
1789 if args.json {
1790 let info = serde_json::json!({
1791 "exists": true,
1792 "path": cache_path.display().to_string(),
1793 "type": "json",
1794 "entries": entries.len(),
1795 "size_bytes": size_bytes,
1796 });
1797 println!("{}", serde_json::to_string_pretty(&info)?);
1798 } else {
1799 println!("Cache: {}", cache_path.display());
1800 println!("Type: JSON");
1801 println!("Fonts: {}", entries.len());
1802 println!("Size: {} bytes", size_bytes);
1803 }
1804
1805 Ok(())
1806}
1807
1808fn resolve_cache_path(custom: &Option<PathBuf>) -> Result<PathBuf> {
1809 if let Some(path) = custom {
1810 return Ok(path.clone());
1811 }
1812
1813 if let Ok(env_override) = env::var("TYPOG_CACHE_PATH") {
1814 return Ok(PathBuf::from(env_override));
1815 }
1816
1817 #[cfg(target_os = "windows")]
1818 {
1819 if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1820 return Ok(PathBuf::from(local_appdata).join("typg").join("cache.json"));
1821 }
1822 if let Some(home) = env::var_os("HOME") {
1823 return Ok(PathBuf::from(home).join("AppData/Local/typg/cache.json"));
1824 }
1825 }
1826
1827 #[cfg(not(target_os = "windows"))]
1828 {
1829 if let Some(xdg) = env::var_os("XDG_CACHE_HOME") {
1830 return Ok(PathBuf::from(xdg).join("typg").join("cache.json"));
1831 }
1832 if let Some(home) = env::var_os("HOME") {
1833 return Ok(PathBuf::from(home)
1834 .join(".cache")
1835 .join("typg")
1836 .join("cache.json"));
1837 }
1838 }
1839
1840 Err(anyhow!(
1841 "--cache-path is required because no cache directory could be detected"
1842 ))
1843}
1844
1845#[cfg_attr(not(feature = "hpindex"), allow(dead_code))]
1847fn resolve_index_path(custom: &Option<PathBuf>) -> Result<PathBuf> {
1848 if let Some(path) = custom {
1849 return Ok(path.clone());
1850 }
1851
1852 if let Ok(env_override) = env::var("TYPOG_INDEX_PATH") {
1853 return Ok(PathBuf::from(env_override));
1854 }
1855
1856 #[cfg(target_os = "windows")]
1857 {
1858 if let Some(local_appdata) = env::var_os("LOCALAPPDATA") {
1859 return Ok(PathBuf::from(local_appdata).join("typg").join("index"));
1860 }
1861 if let Some(home) = env::var_os("HOME") {
1862 return Ok(PathBuf::from(home).join("AppData/Local/typg/index"));
1863 }
1864 }
1865
1866 #[cfg(not(target_os = "windows"))]
1867 {
1868 if let Some(xdg) = env::var_os("XDG_CACHE_HOME") {
1869 return Ok(PathBuf::from(xdg).join("typg").join("index"));
1870 }
1871 if let Some(home) = env::var_os("HOME") {
1872 return Ok(PathBuf::from(home)
1873 .join(".cache")
1874 .join("typg")
1875 .join("index"));
1876 }
1877 }
1878
1879 Err(anyhow!(
1880 "--index-path is required because no cache directory could be detected"
1881 ))
1882}
1883
1884fn load_cache(path: &Path) -> Result<Vec<TypgFontFaceMatch>> {
1886 let file = File::open(path).with_context(|| format!("opening cache {}", path.display()))?;
1887 let reader = BufReader::new(file);
1888
1889 match serde_json::from_reader(reader) {
1890 Ok(entries) => Ok(entries),
1891 Err(_) => {
1892 let file =
1894 File::open(path).with_context(|| format!("re-opening cache {}", path.display()))?;
1895 let reader = BufReader::new(file);
1896 let stream = Deserializer::from_reader(reader).into_iter::<TypgFontFaceMatch>();
1897 let mut entries = Vec::new();
1898 for item in stream {
1899 entries.push(item?);
1900 }
1901 Ok(entries)
1902 }
1903 }
1904}
1905
1906fn write_cache(path: &Path, entries: &[TypgFontFaceMatch]) -> Result<()> {
1908 if let Some(parent) = path.parent() {
1909 fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
1910 }
1911
1912 let file = File::create(path).with_context(|| format!("creating cache {}", path.display()))?;
1913 let mut writer = BufWriter::new(file);
1914 serde_json::to_writer_pretty(&mut writer, entries)
1915 .with_context(|| format!("writing cache {}", path.display()))?;
1916 writer.flush()?;
1917 Ok(())
1918}
1919
1920fn merge_entries(
1921 existing: Vec<TypgFontFaceMatch>,
1922 additions: Vec<TypgFontFaceMatch>,
1923) -> Vec<TypgFontFaceMatch> {
1924 let mut map: HashMap<(PathBuf, Option<u32>), TypgFontFaceMatch> = HashMap::new();
1925
1926 for entry in existing.into_iter().chain(additions.into_iter()) {
1927 map.insert(cache_key(&entry), entry);
1928 }
1929
1930 let mut merged: Vec<TypgFontFaceMatch> = map.into_values().collect();
1931 sort_entries(&mut merged);
1932 merged
1933}
1934
1935fn prune_missing(entries: Vec<TypgFontFaceMatch>) -> Vec<TypgFontFaceMatch> {
1936 let mut pruned: Vec<TypgFontFaceMatch> = entries
1937 .into_iter()
1938 .filter(|entry| entry.source.path.exists())
1939 .collect();
1940 sort_entries(&mut pruned);
1941 pruned
1942}
1943
1944fn sort_entries(entries: &mut [TypgFontFaceMatch]) {
1945 entries.sort_by(|a, b| {
1946 a.source
1947 .path
1948 .cmp(&b.source.path)
1949 .then_with(|| a.source.ttc_index.cmp(&b.source.ttc_index))
1950 });
1951}
1952
1953fn cache_key(entry: &TypgFontFaceMatch) -> (PathBuf, Option<u32>) {
1954 (entry.source.path.clone(), entry.source.ttc_index)
1955}
1956
1957#[cfg(feature = "hpindex")]
1962fn run_cache_add_index(args: CacheAddArgs, quiet: bool) -> Result<()> {
1963 use std::time::SystemTime;
1964
1965 let stdin = io::stdin();
1966 let paths = gather_paths(
1967 &args.paths,
1968 args.stdin_paths,
1969 args.system_fonts,
1970 stdin.lock(),
1971 )?;
1972
1973 let index_path = resolve_index_path(&args.index_path)?;
1974 let index = FontIndex::open(&index_path)?;
1975
1976 let opts = SearchOptions {
1978 follow_symlinks: args.follow_symlinks,
1979 jobs: args.jobs,
1980 };
1981 let additions = search(&paths, &Query::new(), &opts)?;
1982
1983 let mut writer = index.writer()?;
1985 let mut added = 0usize;
1986 let mut skipped = 0usize;
1987
1988 for entry in additions {
1989 let mtime = entry
1991 .source
1992 .path
1993 .metadata()
1994 .and_then(|m| m.modified())
1995 .unwrap_or(SystemTime::UNIX_EPOCH);
1996
1997 if !writer.needs_update(&entry.source.path, mtime)? {
1999 skipped += 1;
2000 continue;
2001 }
2002
2003 writer.add_font(
2004 &entry.source.path,
2005 entry.source.ttc_index,
2006 mtime,
2007 &entry.metadata,
2008 )?;
2009 added += 1;
2010 }
2011
2012 writer.commit()?;
2013
2014 if !quiet {
2015 let total = index.count()?;
2016 eprintln!(
2017 "indexed {} font faces at {} (added: {}, skipped: {})",
2018 total,
2019 index_path.display(),
2020 added,
2021 skipped
2022 );
2023 }
2024
2025 Ok(())
2026}
2027
2028#[cfg(feature = "hpindex")]
2029fn run_cache_list_index(args: CacheListArgs) -> Result<()> {
2030 let index_path = resolve_index_path(&args.index_path)?;
2031 let index = FontIndex::open(&index_path)?;
2032 let reader = index.reader()?;
2033 let entries = reader.list_all()?;
2034 let output = OutputFormat::from_output(&args.output);
2035 write_matches(&entries, &output, &[])
2036}
2037
2038#[cfg(feature = "hpindex")]
2039fn run_cache_find_index(args: CacheFindArgs) -> Result<()> {
2040 let index_path = resolve_index_path(&args.index_path)?;
2041 let index = FontIndex::open(&index_path)?;
2042
2043 let query = build_query_from_parts(
2044 &args.axes,
2045 &args.features,
2046 &args.scripts,
2047 &args.tables,
2048 &args.name_patterns,
2049 &args.creator_patterns,
2050 &args.license_patterns,
2051 &args.codepoints,
2052 &args.text,
2053 args.variable,
2054 &args.weight,
2055 &args.width,
2056 &args.family_class,
2057 )?;
2058
2059 let reader = index.reader()?;
2060 let matches = reader.find(&query)?;
2061
2062 if args.count_only {
2063 println!("{}", matches.len());
2064 return Ok(());
2065 }
2066
2067 let output = OutputFormat::from_output(&args.output);
2068 write_matches(&matches, &output, &[])
2069}
2070
2071#[cfg(feature = "hpindex")]
2072fn run_cache_clean_index(args: CacheCleanArgs, quiet: bool) -> Result<()> {
2073 let index_path = resolve_index_path(&args.index_path)?;
2074 let index = FontIndex::open(&index_path)?;
2075
2076 let mut writer = index.writer()?;
2077 let (before, after) = writer.prune_missing()?;
2078 writer.commit()?;
2079
2080 if !quiet {
2081 eprintln!(
2082 "removed {} missing entries ({} → {})",
2083 before.saturating_sub(after),
2084 before,
2085 after
2086 );
2087 }
2088 Ok(())
2089}
2090
2091#[cfg(feature = "hpindex")]
2092fn run_cache_info_index(args: CacheInfoArgs) -> Result<()> {
2093 let index_path = resolve_index_path(&args.index_path)?;
2094
2095 if !index_path.exists() {
2096 if args.json {
2097 println!(r#"{{"exists":false,"path":"{}"}}"#, index_path.display());
2098 } else {
2099 println!("Index does not exist at {}", index_path.display());
2100 }
2101 return Ok(());
2102 }
2103
2104 let index = FontIndex::open(&index_path)?;
2105 let count = index.count()?;
2106
2107 let size_bytes: u64 = fs::read_dir(&index_path)?
2109 .filter_map(|e| e.ok())
2110 .filter_map(|e| e.metadata().ok())
2111 .filter(|m| m.is_file())
2112 .map(|m| m.len())
2113 .sum();
2114
2115 if args.json {
2116 let info = serde_json::json!({
2117 "exists": true,
2118 "path": index_path.display().to_string(),
2119 "type": "lmdb",
2120 "entries": count,
2121 "size_bytes": size_bytes,
2122 });
2123 println!("{}", serde_json::to_string_pretty(&info)?);
2124 } else {
2125 println!("Index: {}", index_path.display());
2126 println!("Type: LMDB");
2127 println!("Fonts: {}", count);
2128 println!("Size: {} bytes", size_bytes);
2129 }
2130
2131 Ok(())
2132}
2133
2134#[cfg(test)]
2135mod tests;