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