use clap::CommandFactory;
use clap_complete::{generate, Shell};
use fontlift_core::{
journal::{self, JournalAction, RecoveryPolicy},
protection, validation,
validation_ext::{self, ValidatorConfig},
FontError, FontManager, FontScope, FontliftFontFaceInfo, FontliftFontSource,
};
use serde_json::to_string_pretty;
use std::collections::BTreeSet;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::args::{Cli, ValidationStrictness};
#[derive(Debug, Clone, Copy)]
pub struct ListRenderOptions {
pub show_path: bool,
pub show_name: bool,
pub sorted: bool,
pub json: bool,
}
#[derive(Debug, PartialEq)]
pub enum ListRender {
Lines(Vec<String>),
Json(String),
}
#[derive(Debug, Clone, Copy)]
pub struct OutputOptions {
pub quiet: bool,
pub verbose: bool,
}
impl OutputOptions {
pub fn should_print(&self) -> bool {
!self.quiet
}
pub fn should_print_verbose(&self) -> bool {
self.verbose && !self.quiet
}
}
#[derive(Debug, Clone, Copy)]
pub struct OperationOptions {
pub dry_run: bool,
pub output: OutputOptions,
}
impl OperationOptions {
pub fn new(dry_run: bool, quiet: bool, verbose: bool) -> Self {
Self {
dry_run,
output: OutputOptions { quiet, verbose },
}
}
}
pub(crate) fn log_status(opts: &OperationOptions, message: &str) {
if opts.output.should_print() {
println!("{}", message);
}
}
pub(crate) fn log_verbose(opts: &OperationOptions, message: &str) {
if opts.output.should_print_verbose() {
eprintln!("{}", message);
}
}
fn scope_order(preferred: FontScope) -> [FontScope; 2] {
match preferred {
FontScope::User => [FontScope::User, FontScope::System],
FontScope::System => [FontScope::System, FontScope::User],
}
}
fn describe_scope_chain(preferred: FontScope) -> String {
scope_order(preferred)
.iter()
.map(|s| s.description())
.collect::<Vec<_>>()
.join(" then ")
}
fn uninstall_across_scopes(
manager: &Arc<dyn FontManager>,
path: &Path,
preferred_scope: FontScope,
) -> Result<FontScope, FontError> {
let mut last_error: Option<FontError> = None;
for scope in scope_order(preferred_scope) {
let source = FontliftFontSource::new(path.to_path_buf()).with_scope(Some(scope));
match manager.uninstall_font(&source) {
Ok(()) => return Ok(scope),
Err(err) => last_error = Some(err),
}
}
if let Some(err) = last_error {
Err(err)
} else {
Err(FontError::RegistrationFailed(format!(
"Failed to uninstall font {} in any scope",
path.display()
)))
}
}
pub fn render_list_output(
mut fonts: Vec<FontliftFontFaceInfo>,
opts: ListRenderOptions,
) -> Result<ListRender, FontError> {
let must_dedupe_fonts = opts.sorted || opts.json;
if must_dedupe_fonts {
fonts = protection::dedupe_fonts(fonts);
}
if opts.json {
let json = to_string_pretty(&fonts).map_err(|e| {
FontError::InvalidFormat(format!("Failed to serialize font list to JSON: {}", e))
})?;
return Ok(ListRender::Json(json));
}
let show_path = opts.show_path || !opts.show_name;
let show_name = opts.show_name;
let mut lines = Vec::new();
for font in fonts {
if show_path && show_name {
lines.push(format!(
"{}::{}",
font.source.path.display(),
font.postscript_name
));
} else if show_path {
lines.push(font.source.path.display().to_string());
} else {
lines.push(font.postscript_name);
}
}
lines.sort();
if (opts.show_path && !opts.show_name) || opts.sorted {
lines.dedup();
}
Ok(ListRender::Lines(lines))
}
pub fn collect_font_inputs(inputs: &[PathBuf]) -> Result<Vec<PathBuf>, FontError> {
if inputs.is_empty() {
return Err(FontError::InvalidFormat(
"At least one font path or directory is required".to_string(),
));
}
let mut found: BTreeSet<PathBuf> = BTreeSet::new();
for input in inputs {
if input.is_dir() {
for entry in fs::read_dir(input).map_err(FontError::IoError)? {
let entry = entry.map_err(FontError::IoError)?;
let path = entry.path();
if path.is_file() && validation::is_valid_font_extension(&path) {
found.insert(path);
}
}
} else if input.is_file() {
if validation::is_valid_font_extension(input) {
found.insert(input.clone());
} else {
return Err(FontError::InvalidFormat(format!(
"Invalid font extension: {}",
input.display()
)));
}
} else {
return Err(FontError::FontNotFound(input.clone()));
}
}
if found.is_empty() {
return Err(FontError::InvalidFormat(
"No font files found in provided paths".to_string(),
));
}
Ok(found.into_iter().collect())
}
pub fn create_font_manager() -> Arc<dyn FontManager> {
#[cfg(target_os = "macos")]
{
Arc::new(fontlift_platform_mac::MacFontManager::new())
}
#[cfg(target_os = "windows")]
{
Arc::new(fontlift_platform_win::WinFontManager::new())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
compile_error!("Linux support not yet implemented");
}
}
pub fn write_completions<W: Write>(shell: Shell, mut writer: W) -> Result<(), FontError> {
let mut command = Cli::command();
let bin_name = command.get_name().to_string();
generate(shell, &mut command, bin_name.as_str(), &mut writer);
Ok(())
}
pub async fn handle_list_command(
manager: Arc<dyn FontManager>,
path: bool,
name: bool,
sorted: bool,
json: bool,
) -> Result<(), FontError> {
let fonts = manager.list_installed_fonts()?;
let opts = ListRenderOptions {
show_path: path,
show_name: name,
sorted,
json,
};
match render_list_output(fonts, opts)? {
ListRender::Lines(lines) => {
for line in lines {
println!("{}", line);
}
}
ListRender::Json(json) => {
println!("{}", json);
}
}
Ok(())
}
fn to_core_strictness(s: ValidationStrictness) -> validation_ext::ValidationStrictness {
match s {
ValidationStrictness::Lenient => validation_ext::ValidationStrictness::Lenient,
ValidationStrictness::Normal => validation_ext::ValidationStrictness::Normal,
ValidationStrictness::Paranoid => validation_ext::ValidationStrictness::Paranoid,
}
}
pub async fn handle_install_command(
manager: Arc<dyn FontManager>,
font_inputs: Vec<PathBuf>,
admin: bool,
validate: bool,
strictness: ValidationStrictness,
inplace: bool,
opts: OperationOptions,
) -> Result<(), FontError> {
let scope = if admin {
FontScope::System
} else {
FontScope::User
};
let targets = collect_font_inputs(&font_inputs)?;
if validate {
log_verbose(&opts, "Running out-of-process font validation...");
let config = ValidatorConfig::from_strictness(to_core_strictness(strictness));
match validation_ext::validate_and_introspect(&targets, &config) {
Ok(results) => {
for (i, result) in results.iter().enumerate() {
if let Err(e) = result {
log_status(
&opts,
&format!("⚠️ Validation failed for {}: {}", targets[i].display(), e),
);
if !opts.dry_run {
return Err(FontError::InvalidFormat(format!(
"Font validation failed: {}",
targets[i].display()
)));
}
} else {
log_verbose(&opts, &format!("✓ Validated: {}", targets[i].display()));
}
}
}
Err(e) => {
log_verbose(
&opts,
&format!("⚠️ Validation skipped (validator unavailable): {}", e),
);
}
}
}
for path in targets {
log_verbose(&opts, &format!("Scope: {}", scope.description()));
if opts.dry_run {
log_status(
&opts,
&format!(
"DRY-RUN: would install font {} ({})",
path.display(),
scope.description()
),
);
continue;
}
let install_path = if inplace {
path.clone()
} else {
let fonts_dir = if admin {
PathBuf::from("/Library/Fonts")
} else {
dirs::home_dir()
.ok_or_else(|| {
FontError::UnsupportedOperation(
"Cannot determine home directory".to_string(),
)
})?
.join("Library/Fonts")
};
if !fonts_dir.exists() {
fs::create_dir_all(&fonts_dir).map_err(FontError::IoError)?;
}
let target = fonts_dir.join(path.file_name().unwrap_or_default());
if target != path {
log_verbose(
&opts,
&format!("Copying {} to {}", path.display(), target.display()),
);
fs::copy(&path, &target).map_err(FontError::IoError)?;
}
target
};
log_status(
&opts,
&format!("Installing font from: {}", install_path.display()),
);
let source = FontliftFontSource::new(install_path).with_scope(Some(scope));
manager.install_font(&source)?;
log_status(&opts, "✅ Successfully installed font");
}
Ok(())
}
pub async fn handle_uninstall_command(
manager: Arc<dyn FontManager>,
name: Option<String>,
font_inputs: Vec<PathBuf>,
admin: bool,
opts: OperationOptions,
) -> Result<(), FontError> {
let default_scope = if admin {
FontScope::System
} else {
FontScope::User
};
if let Some(font_name) = name {
log_status(&opts, &format!("Uninstalling font by name: {}", font_name));
let installed_fonts = manager.list_installed_fonts()?;
if let Some(font) = installed_fonts
.iter()
.find(|f| f.postscript_name == font_name || f.full_name == font_name)
{
let starting_scope = font.source.scope.unwrap_or(default_scope);
if opts.dry_run {
log_status(
&opts,
&format!(
"DRY-RUN: would uninstall '{}' at {} (checking {})",
font_name,
font.source.path.display(),
describe_scope_chain(starting_scope)
),
);
} else {
match uninstall_across_scopes(&manager, &font.source.path, starting_scope) {
Ok(used_scope) => {
log_status(
&opts,
&format!(
"✅ Successfully uninstalled font '{}' ({})",
font_name,
used_scope.description()
),
);
}
Err(e) => {
log_status(
&opts,
&format!(
"⚠️ Could not unregister font '{}': {}",
font_name, e
),
);
}
}
}
} else {
log_status(
&opts,
&format!("⚠️ Font '{}' is not installed, nothing to uninstall", font_name),
);
return Ok(());
}
} else {
let targets = collect_font_inputs(&font_inputs)?;
for path in targets {
if opts.dry_run {
log_status(
&opts,
&format!(
"DRY-RUN: would uninstall font at {} (checking {})",
path.display(),
describe_scope_chain(default_scope)
),
);
continue;
}
log_status(
&opts,
&format!("Uninstalling font from path: {}", path.display()),
);
match uninstall_across_scopes(&manager, &path, default_scope) {
Ok(used_scope) => {
log_status(
&opts,
&format!(
"✅ Successfully uninstalled font ({})",
used_scope.description()
),
);
}
Err(e) => {
log_status(
&opts,
&format!(
"⚠️ Font at {} may not be registered: {}",
path.display(),
e
),
);
}
}
}
}
Ok(())
}
pub async fn handle_remove_command(
manager: Arc<dyn FontManager>,
name: Option<String>,
font_inputs: Vec<PathBuf>,
admin: bool,
opts: OperationOptions,
) -> Result<(), FontError> {
let scope = if admin {
FontScope::System
} else {
FontScope::User
};
if let Some(font_name) = name {
log_status(&opts, &format!("Removing font by name: {}", font_name));
let installed_fonts = manager.list_installed_fonts()?;
if let Some(font) = installed_fonts
.iter()
.find(|f| f.postscript_name == font_name || f.full_name == font_name)
{
if opts.dry_run {
log_status(
&opts,
&format!(
"DRY-RUN: would remove '{}' at {}",
font_name,
font.source.path.display()
),
);
} else {
let path = font.source.path.clone();
let starting_scope = font.source.scope.unwrap_or(scope);
match uninstall_across_scopes(&manager, &path, starting_scope) {
Ok(used_scope) => {
log_verbose(
&opts,
&format!("Unregistered font ({})", used_scope.description()),
);
}
Err(e) => {
log_status(
&opts,
&format!(
"⚠️ Could not unregister font '{}': {} (will still delete file)",
font_name, e
),
);
}
}
if path.exists() {
fs::remove_file(&path).map_err(FontError::IoError)?;
log_status(
&opts,
&format!("✅ Successfully removed font file: {}", path.display()),
);
} else {
log_status(
&opts,
&format!("⚠️ Font file not found: {}", path.display()),
);
}
}
} else {
log_status(
&opts,
&format!("⚠️ Font '{}' is not installed, nothing to remove", font_name),
);
return Ok(());
}
} else {
let targets = collect_font_inputs(&font_inputs)?;
for path in targets {
if opts.dry_run {
log_status(
&opts,
&format!(
"DRY-RUN: would remove font at {} ({})",
path.display(),
scope.description()
),
);
continue;
}
log_status(
&opts,
&format!("Removing font from path: {}", path.display()),
);
match uninstall_across_scopes(&manager, &path, scope) {
Ok(used_scope) => {
log_verbose(
&opts,
&format!("Unregistered font ({})", used_scope.description()),
);
}
Err(e) => {
log_status(
&opts,
&format!(
"⚠️ Could not unregister font: {} (will still delete file)",
e
),
);
}
}
if path.exists() {
fs::remove_file(&path).map_err(FontError::IoError)?;
log_status(
&opts,
&format!("✅ Successfully removed font file: {}", path.display()),
);
} else {
log_status(
&opts,
&format!("⚠️ Font file not found: {}", path.display()),
);
}
}
}
Ok(())
}
pub async fn handle_cleanup_command(
manager: Arc<dyn FontManager>,
admin: bool,
prune_only: bool,
cache_only: bool,
opts: OperationOptions,
) -> Result<(), FontError> {
let scope = if admin {
FontScope::System
} else {
FontScope::User
};
let run_prune = !cache_only;
let run_cache_clear = !prune_only;
log_status(
&opts,
&format!(
"Starting {} cleanup...",
if admin { "system" } else { "user" }
),
);
if opts.dry_run {
let mut planned = Vec::new();
if run_prune {
planned.push("prune stale registrations");
}
if run_cache_clear {
planned.push("clear font caches");
}
log_status(
&opts,
&format!(
"DRY-RUN: would {} ({})",
planned.join(" and "),
scope.description()
),
);
return Ok(());
}
if run_prune {
let pruned = manager.prune_missing_fonts(scope)?;
log_verbose(
&opts,
&format!("Pruned {} stale font registration(s)", pruned),
);
}
if run_cache_clear {
match manager.clear_font_caches(scope) {
Ok(()) => log_status(&opts, "✅ Successfully cleared font caches"),
Err(FontError::PermissionDenied(msg)) if scope == FontScope::User => {
log_status(
&opts,
&format!("⚠️ Skipping cache clear (requires admin): {}", msg),
);
}
Err(err) => return Err(err),
}
}
Ok(())
}
pub async fn handle_doctor_command(preview: bool, opts: OperationOptions) -> Result<(), FontError> {
log_status(&opts, "Checking for interrupted operations...");
let journal = journal::load_journal()?;
let incomplete = journal.incomplete_entries();
if incomplete.is_empty() {
log_status(&opts, "✅ No interrupted operations found");
return Ok(());
}
log_status(
&opts,
&format!("Found {} interrupted operation(s)", incomplete.len()),
);
for entry in &incomplete {
log_status(
&opts,
&format!("\nOperation {} (started {:?}):", entry.id, entry.started_at),
);
if let Some(desc) = &entry.description {
log_status(&opts, &format!(" Description: {}", desc));
}
log_status(
&opts,
&format!(
" Progress: step {} of {}",
entry.current_step,
entry.actions.len()
),
);
for (i, action) in entry.remaining_actions().iter().enumerate() {
let step_num = entry.current_step + i + 1;
log_status(&opts, &format!(" [{}] {}", step_num, action.description()));
}
}
if preview || opts.dry_run {
log_status(
&opts,
"\nDRY-RUN: would attempt recovery of above operations",
);
return Ok(());
}
log_status(&opts, "\nAttempting recovery...");
let results = journal::recover_incomplete_operations(|action, policy| {
log_verbose(&opts, &format!(" {:?}: {}", policy, action.description()));
match (action, policy) {
(_, RecoveryPolicy::Skip) => Ok(true),
(JournalAction::CopyFile { from, to }, RecoveryPolicy::RollForward) => {
if to.exists() {
Ok(true)
} else if from.exists() {
std::fs::copy(from, to)
.map(|_| true)
.map_err(FontError::IoError)
} else {
Ok(false)
}
}
(JournalAction::DeleteFile { path }, RecoveryPolicy::RollForward) => {
if path.exists() {
std::fs::remove_file(path)
.map(|_| true)
.map_err(FontError::IoError)
} else {
Ok(true)
}
}
(JournalAction::RegisterFont { .. }, RecoveryPolicy::RollForward) => {
log_verbose(
&opts,
" (font registration recovery requires manual intervention)",
);
Ok(false)
}
(JournalAction::UnregisterFont { .. }, RecoveryPolicy::RollForward) => {
log_verbose(
&opts,
" (font unregistration recovery requires manual intervention)",
);
Ok(false)
}
(JournalAction::ClearCache { .. }, _) => Ok(true),
_ => Ok(false),
}
})?;
let succeeded = results.iter().filter(|r| r.success).count();
let failed = results.len() - succeeded;
if failed > 0 {
log_status(
&opts,
&format!(
"⚠️ Recovery completed with {} success, {} failure(s)",
succeeded, failed
),
);
} else if succeeded > 0 {
log_status(
&opts,
&format!("✅ Successfully recovered {} action(s)", succeeded),
);
} else {
log_status(&opts, "✅ No recovery actions needed");
}
Ok(())
}