use super::*;
use clap::CommandFactory;
use std::fs;
use std::io::Cursor;
use tempfile::tempdir;
use typg_core::search::{TypgFontFaceMatch, TypgFontFaceMeta, TypgFontSource};
use typg_core::tags::tag4;
fn metadata_with(name: &str, axis: Option<&str>, ttc: Option<u32>) -> TypgFontFaceMatch {
let ext = if ttc.is_some() { "ttc" } else { "ttf" };
TypgFontFaceMatch {
source: TypgFontSource {
path: PathBuf::from(format!("/fonts/{}.{}", name, ext)),
ttc_index: ttc,
},
metadata: TypgFontFaceMeta {
names: vec![name.to_string()],
axis_tags: axis.into_iter().map(|t| tag4(t).expect("tag")).collect(),
feature_tags: Vec::new(),
script_tags: Vec::new(),
table_tags: Vec::new(),
codepoints: vec!['A'],
is_variable: axis.is_some(),
weight_class: None,
width_class: None,
family_class: None,
creator_names: Vec::new(),
license_names: Vec::new(),
psname: None,
tfname: None,
lfname: None,
tsname: None,
lsname: None,
},
}
}
#[test]
fn parses_find_args_into_query() {
let cli = Cli::try_parse_from([
"typg",
"find",
"-a",
"wght",
"-f",
"liga",
"-s",
"latn",
"-T",
"GPOS",
"-n",
"Mono",
"-u",
"U+0041",
"-w",
"400",
"--width",
"5",
"--family-class",
"sans",
"-v",
"--json",
"/fonts",
])
.expect("parse cli");
let Command::Find(args) = cli.command else {
panic!("expected find command");
};
let query = build_query(&args).expect("build query");
assert!(args.json);
assert!(!args.ndjson);
let mut matching = metadata_with("Mono", Some("wght"), None);
matching.metadata.feature_tags = vec![tag4("liga").unwrap()];
matching.metadata.script_tags = vec![tag4("latn").unwrap()];
matching.metadata.table_tags = vec![tag4("GPOS").unwrap()];
matching.metadata.weight_class = Some(400);
matching.metadata.width_class = Some(5);
matching.metadata.family_class = Some((8, 0));
assert!(query.matches(&matching.metadata));
let non_matching = metadata_with("Sans", None, None);
assert!(!query.matches(&non_matching.metadata));
}
#[test]
fn json_and_ndjson_conflict() {
let parse = Cli::try_parse_from(["typg", "find", "--json", "--ndjson", "/fonts"]);
assert!(parse.is_err());
}
#[test]
fn invalid_regex_returns_error() {
let args = FindArgs {
paths: vec![PathBuf::from("/fonts")],
axes: Vec::new(),
features: Vec::new(),
scripts: Vec::new(),
tables: Vec::new(),
name_patterns: vec!["(".to_string()],
creator_patterns: Vec::new(),
license_patterns: Vec::new(),
codepoints: Vec::new(),
text: None,
variable: false,
weight: None,
width: None,
family_class: None,
follow_symlinks: false,
stdin_paths: false,
system_fonts: false,
jobs: None,
json: false,
ndjson: false,
paths_only: false,
columns: false,
collections: false,
count_only: false,
color: ColorChoice::Auto,
csv: false,
details: None,
};
let built = build_query(&args);
assert!(built.is_err());
}
#[test]
fn writes_plain_with_ttc_suffix() {
let matches = vec![
metadata_with("A", None, None),
metadata_with("B", None, Some(2)),
];
let mut buf = Cursor::new(Vec::new());
write_plain(&matches, &mut buf, false, true).expect("write");
let output = String::from_utf8(buf.into_inner()).expect("utf8");
assert!(output.contains("/fonts/A.ttf"));
assert!(output.contains("/fonts/B.ttc#2"));
}
#[test]
fn writes_paths_output_without_color() {
let matches = vec![
metadata_with("A", None, None),
metadata_with("B", None, Some(3)),
];
let mut buf = Cursor::new(Vec::new());
write_paths(&matches, &mut buf, true).expect("write paths");
let output = String::from_utf8(buf.into_inner()).expect("utf8");
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines, vec!["/fonts/A.ttf", "/fonts/B.ttc#3"]);
assert!(
output.chars().all(|c| c != '\u{1b}'),
"paths output should not include ANSI"
);
}
#[test]
fn text_flag_merges_into_codepoints() {
let cli = Cli::try_parse_from(["typg", "find", "-u", "U+0041", "-t", "B", "/fonts"])
.expect("parse cli");
let Command::Find(args) = cli.command else {
panic!("expected find command");
};
let query = build_query(&args).expect("build");
let mut meta = metadata_with("AB", None, None);
meta.metadata.codepoints = vec!['A', 'B'];
assert!(query.matches(&meta.metadata));
}
#[test]
fn gathers_paths_from_stdin_when_flagged() {
let mut stdin = Cursor::new(b"/fonts/A\n/fonts/B\n".to_vec());
let paths = gather_paths(&[], true, false, &mut stdin).expect("paths");
assert_eq!(
paths,
vec![PathBuf::from("/fonts/A"), PathBuf::from("/fonts/B")]
);
}
#[test]
fn dash_placeholder_reads_stdin_and_merges_other_paths() {
let mut stdin = Cursor::new(b"/fonts/A\n".to_vec());
let paths = gather_paths(
&[PathBuf::from("-"), PathBuf::from("/fonts/B")],
false,
false,
&mut stdin,
)
.expect("paths");
assert_eq!(
paths,
vec![PathBuf::from("/fonts/A"), PathBuf::from("/fonts/B")]
);
}
#[test]
fn system_font_roots_uses_override_env() {
let tmp = tempdir().expect("tempdir");
let font_dir = tmp.path().join("fonts");
std::fs::create_dir_all(&font_dir).expect("mkdir");
env::set_var("TYPOG_SYSTEM_FONT_DIRS", font_dir.display().to_string());
let roots = system_font_roots().expect("roots");
env::remove_var("TYPOG_SYSTEM_FONT_DIRS");
assert_eq!(roots, vec![font_dir]);
}
#[test]
fn columns_align_names() {
let matches = vec![
metadata_with("Alpha", Some("wght"), None),
metadata_with("Beta", None, None),
];
let mut buf = Cursor::new(Vec::new());
write_columns(&matches, &mut buf, false, false).expect("write");
let output = String::from_utf8(buf.into_inner()).expect("utf8");
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 2);
let alpha_pos = lines[0].find("Alpha").expect("alpha");
let beta_pos = lines[1].find("Beta").expect("beta");
assert_eq!(alpha_pos, beta_pos);
}
#[test]
fn color_choice_is_applied() {
let matches = vec![metadata_with("Alpha", None, None)];
let mut buf = Cursor::new(Vec::new());
write_plain(&matches, &mut buf, true, false).expect("write");
let output = String::from_utf8(buf.into_inner()).expect("utf8");
assert!(output.contains("\u{1b}["));
}
#[test]
fn parses_color_and_columns_flags() {
let cli = Cli::try_parse_from(["typg", "find", "--columns", "--color", "always", "/fonts"])
.expect("parse cli");
let Command::Find(args) = cli.command else {
panic!("expected find command");
};
assert!(args.columns);
assert_eq!(args.color, ColorChoice::Always);
}
#[test]
fn parses_paths_flag() {
let cli = Cli::try_parse_from(["typg", "find", "--paths", "/fonts"]).expect("parse cli");
let Command::Find(args) = cli.command else {
panic!("expected find command");
};
assert!(args.paths_only);
assert!(!args.json);
assert!(!args.ndjson);
}
#[test]
fn parses_serve_bind_flag() {
let cli = Cli::try_parse_from(["typg", "serve", "--bind", "0.0.0.0:9999"]).expect("parse cli");
let Command::Serve(args) = cli.command else {
panic!("expected serve command");
};
assert_eq!(args.bind, "0.0.0.0:9999");
}
#[test]
fn help_output_includes_new_flags() {
let mut root = Cli::command();
let find = root
.find_subcommand_mut("find")
.expect("find command present");
let help = find.render_long_help().to_string();
assert!(help.contains("--columns"));
assert!(help.contains("--paths"));
assert!(help.contains("--color <COLOR>"));
assert!(help.contains("--jobs <JOBS>"));
assert!(help.contains("--weight <WEIGHT>"));
assert!(help.contains("--width <WIDTH>"));
}
#[test]
fn help_output_lists_details_keywords() {
let mut root = Cli::command();
let find = root
.find_subcommand_mut("find")
.expect("find command present");
let help = find.render_long_help().to_string();
assert!(help.contains("path"), "Help should contain 'path'");
assert!(help.contains("path_r"), "Help should contain 'path_r'");
assert!(help.contains("fmt"), "Help should contain 'fmt'");
assert!(help.contains("fname"), "Help should contain 'fname'");
assert!(help.contains("sname"), "Help should contain 'sname'");
assert!(help.contains("psname"), "Help should contain 'psname'");
assert!(help.contains("tfname"), "Help should contain 'tfname'");
assert!(help.contains("lfname"), "Help should contain 'lfname'");
assert!(help.contains("tsname"), "Help should contain 'tsname'");
assert!(help.contains("lsname"), "Help should contain 'lsname'");
assert!(help.contains("var"), "Help should contain 'var'");
assert!(help.contains("wt"), "Help should contain 'wt'");
assert!(help.contains("wd"), "Help should contain 'wd'");
assert!(help.contains("panf"), "Help should contain 'panf'");
assert!(help.contains("axes"), "Help should contain 'axes'");
assert!(help.contains("axes_n"), "Help should contain 'axes_n'");
assert!(help.contains("fea"), "Help should contain 'fea'");
assert!(help.contains("fea_n"), "Help should contain 'fea_n'");
assert!(help.contains("scr"), "Help should contain 'scr'");
assert!(help.contains("scr_n"), "Help should contain 'scr_n'");
assert!(help.contains("tab"), "Help should contain 'tab'");
assert!(help.contains("tab_n"), "Help should contain 'tab_n'");
assert!(help.contains("crea"), "Help should contain 'crea'");
assert!(help.contains("lic"), "Help should contain 'lic'");
}
#[test]
fn parses_jobs_flag() {
let cli = Cli::try_parse_from(["typg", "find", "--jobs", "3", "/fonts"]).expect("parse cli");
let Command::Find(args) = cli.command else {
panic!("expected find command");
};
assert_eq!(args.jobs, Some(3));
}
#[test]
fn rejects_zero_jobs() {
let args = FindArgs {
paths: vec![PathBuf::from("/fonts")],
axes: Vec::new(),
features: Vec::new(),
scripts: Vec::new(),
tables: Vec::new(),
name_patterns: Vec::new(),
creator_patterns: Vec::new(),
license_patterns: Vec::new(),
codepoints: Vec::new(),
text: None,
variable: false,
weight: None,
width: None,
family_class: None,
follow_symlinks: false,
stdin_paths: false,
system_fonts: false,
jobs: Some(0),
json: false,
ndjson: false,
paths_only: false,
columns: false,
collections: false,
count_only: false,
color: ColorChoice::Auto,
csv: false,
details: None,
};
let result = run_find(args);
assert!(result.is_err(), "jobs=0 should be rejected");
}
#[test]
fn merge_entries_deduplicates_by_path_and_ttc() {
let existing = vec![metadata_with("Alpha", None, None)];
let additions = vec![
metadata_with("Alpha", Some("wght"), None),
metadata_with("Alpha", None, Some(1)),
];
let merged = merge_entries(existing, additions);
assert_eq!(merged.len(), 2);
let variable = merged
.iter()
.find(|m| m.source.ttc_index.is_none())
.expect("variable entry present");
assert!(
variable.metadata.is_variable,
"newest metadata should replace old"
);
}
#[test]
fn resolve_cache_path_prefers_env_override() {
let tmp = tempdir().expect("tempdir");
let target = tmp.path().join("cache.json");
env::set_var("TYPOG_CACHE_PATH", &target);
let resolved = resolve_cache_path(&None).expect("resolve");
env::remove_var("TYPOG_CACHE_PATH");
assert_eq!(resolved, target);
}
#[test]
fn prune_missing_entries_drops_nonexistent_paths() {
let tmp = tempdir().expect("tempdir");
let cache_file = tmp.path().join("cache.json");
let keep_path = tmp.path().join("keep.ttf");
fs::write(&keep_path, b"font").expect("write keep font");
let mut entries = vec![TypgFontFaceMatch {
source: TypgFontSource {
path: keep_path.clone(),
ttc_index: None,
},
..metadata_with("KeepMe", None, None)
}];
let missing = tmp.path().join("missing.ttf");
fs::write(&missing, b"fake").expect("write stub font");
entries.push(TypgFontFaceMatch {
source: TypgFontSource {
path: missing.clone(),
ttc_index: None,
},
..metadata_with("Missing", None, None)
});
write_cache(&cache_file, &entries).expect("write cache");
fs::remove_file(&missing).expect("remove stub");
let pruned = prune_missing(entries);
assert_eq!(pruned.len(), 1, "missing entry should be dropped");
assert_eq!(pruned[0].source.path, keep_path);
}
#[test]
fn test_details_parsing_presets_and_custom() {
assert_eq!(parse_details_list("0").unwrap(), vec!["path"]);
assert_eq!(
parse_details_list("1").unwrap(),
vec!["path", "fname", "sname", "fmt"]
);
assert_eq!(
parse_details_list("2").unwrap(),
vec!["path", "fname", "sname", "fmt", "psname", "var"]
);
assert_eq!(
parse_details_list("path,fname,fmt,var").unwrap(),
vec!["path", "fname", "fmt", "var"]
);
assert_eq!(
parse_details_list("wt,wd,panf,scr,scr_n,tab,tab_n,crea,lic").unwrap(),
vec!["wt", "wd", "panf", "scr", "scr_n", "tab", "tab_n", "crea", "lic"]
);
assert!(parse_details_list("variable").is_err());
assert!(parse_details_list("is_var").is_err());
assert!(parse_details_list("weight").is_err());
assert!(parse_details_list("width").is_err());
assert!(parse_details_list("family_class").is_err());
assert!(parse_details_list("scripts").is_err());
assert!(parse_details_list("scripts_n").is_err());
assert!(parse_details_list("tables").is_err());
assert!(parse_details_list("tables_n").is_err());
assert!(parse_details_list("creator_names").is_err());
assert!(parse_details_list("license_names").is_err());
assert!(parse_details_list("invalid_kw").is_err());
assert!(parse_details_list("").is_err());
}
#[test]
fn test_csv_escaping() {
assert_eq!(escape_csv_field("simple"), "simple");
assert_eq!(escape_csv_field("one,two"), "\"one,two\"");
assert_eq!(escape_csv_field("quote\"here"), "\"quote\"\"here\"");
assert_eq!(escape_csv_field("line\nbreak"), "\"line\nbreak\"");
}
#[test]
fn test_relative_path_solver() {
let roots = vec![PathBuf::from("/fonts/sys"), PathBuf::from("/fonts/user")];
let p1 = PathBuf::from("/fonts/sys/sans/Arial.ttf");
assert_eq!(get_relative_path(&p1, &roots), "sans/Arial.ttf");
let p2 = PathBuf::from("/fonts/user/custom/Blob.ttf");
assert_eq!(get_relative_path(&p2, &roots), "custom/Blob.ttf");
let p3 = PathBuf::from("/other/path/Font.ttf");
assert_eq!(get_relative_path(&p3, &roots), "Font.ttf");
}
#[test]
fn test_extract_property_values() {
let mut m = metadata_with("TestFont", None, Some(1));
m.metadata.tfname = Some("TypoFamily".to_string());
m.metadata.tsname = Some("TypoSub".to_string());
m.metadata.weight_class = Some(500);
m.metadata.is_variable = true;
let roots = vec![PathBuf::from("/fonts")];
assert_eq!(
extract_property_value(&m, "fname", &roots, false),
serde_json::Value::String("TypoFamily".to_string())
);
assert_eq!(
extract_property_value(&m, "sname", &roots, false),
serde_json::Value::String("TypoSub".to_string())
);
assert_eq!(
extract_property_value(&m, "path", &roots, true),
serde_json::Value::String("/fonts/TestFont.ttc#1".to_string())
);
assert_eq!(
extract_property_value(&m, "path_r", &roots, true),
serde_json::Value::String("TestFont.ttc#1".to_string())
);
}