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