use clap::{Parser, ValueEnum};
use glyphweave::core::error::GlyphWeaveError;
use glyphweave::core::model::{AlgorithmKind, FontSizeSpec, WordEntry};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct CliArgs {
#[arg(long = "config", help = "Path to a TOML config file")]
pub config: Option<PathBuf>,
#[arg(long = "canvas-size", value_parser = parse_tuple)]
pub canvas_size: Option<(usize, usize)>,
#[arg(long = "canvas-margin")]
pub canvas_margin: Option<usize>,
#[arg(long = "words", value_delimiter = ',')]
pub words: Vec<String>,
#[arg(long = "word-file")]
pub word_file: Option<PathBuf>,
#[arg(long = "weights-file")]
pub weights_file: Option<PathBuf>,
#[arg(long = "word-size-range", value_parser = parse_tuple)]
pub word_size_range: Option<(usize, usize)>,
#[arg(long = "colors", value_delimiter = ',')]
pub word_colors: Option<Vec<String>>,
#[arg(
long = "palette",
value_enum,
help = "Auto palette strategy for generated colors"
)]
pub palette: Option<PaletteKind>,
#[arg(
long = "palette-base",
help = "Base color in #RRGGBB used by dynamic palettes"
)]
pub palette_base: Option<String>,
#[arg(
long = "palette-size",
help = "Number of colors generated by the palette"
)]
pub palette_size: Option<usize>,
#[arg(long = "rotations", value_delimiter = ',')]
pub rotations: Option<Vec<u16>>,
#[arg(short = 't', long = "text", required = true)]
pub shape_text: String,
#[arg(long = "text-size", value_parser = parse_shape_size)]
pub shape_size: Option<FontSizeSpec>,
#[arg(long = "algorithm", value_enum)]
pub algorithm: Option<CliAlgorithm>,
#[arg(long = "font")]
pub font_path: Option<PathBuf>,
#[arg(
long = "choose-system-font",
default_value_t = false,
help = "Prompt to choose a system font when embedded/default font is unavailable"
)]
pub choose_system_font: bool,
#[arg(long = "seed")]
pub seed: Option<u64>,
#[arg(long = "ratio")]
pub threshold: Option<f32>,
#[arg(long = "max-tries")]
pub max_tries: Option<usize>,
#[arg(long = "debug-mask-out")]
pub debug_mask_out: Option<PathBuf>,
#[arg(long = "no-progress", default_value_t = false)]
pub no_progress: bool,
#[arg(short = 'o', long = "output", required = true)]
pub output: PathBuf,
#[arg(short = 'v', long = "verbose", default_value_t = false)]
pub verbose: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum CliAlgorithm {
RandomBaseline,
FastGrid,
SpiralGreedy,
Mcts,
SimulatedAnnealing,
}
impl CliAlgorithm {
pub fn parse_text(text: &str) -> Option<Self> {
match text.trim().to_ascii_lowercase().as_str() {
"random-baseline" | "randombaseline" => Some(Self::RandomBaseline),
"fast-grid" | "fastgrid" => Some(Self::FastGrid),
"spiral-greedy" | "spiralgreedy" => Some(Self::SpiralGreedy),
"mcts" => Some(Self::Mcts),
"simulated-annealing" | "simulatedannealing" | "annealing" | "sa" => {
Some(Self::SimulatedAnnealing)
}
_ => None,
}
}
}
impl From<CliAlgorithm> for AlgorithmKind {
fn from(value: CliAlgorithm) -> Self {
match value {
CliAlgorithm::RandomBaseline => AlgorithmKind::RandomBaseline,
CliAlgorithm::FastGrid => AlgorithmKind::FastGrid,
CliAlgorithm::SpiralGreedy => AlgorithmKind::SpiralGreedy,
CliAlgorithm::Mcts => AlgorithmKind::Mcts,
CliAlgorithm::SimulatedAnnealing => AlgorithmKind::SimulatedAnnealing,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum PaletteKind {
Auto,
Complementary,
Triadic,
Analogous,
Monochrome,
Pastel,
Earth,
Vibrant,
}
impl PaletteKind {
pub fn parse_text(text: &str) -> Option<Self> {
match text.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Self::Auto),
"complementary" | "complement" => Some(Self::Complementary),
"triadic" | "triad" => Some(Self::Triadic),
"analogous" | "analog" => Some(Self::Analogous),
"monochrome" | "mono" => Some(Self::Monochrome),
"pastel" => Some(Self::Pastel),
"earth" | "earthy" => Some(Self::Earth),
"vibrant" | "vivid" => Some(Self::Vibrant),
_ => None,
}
}
}
pub fn collect_words(args: &CliArgs) -> Result<Vec<WordEntry>, GlyphWeaveError> {
let mut table: BTreeMap<String, f32> = BTreeMap::new();
for word in &args.words {
let normalized = word.trim();
if normalized.is_empty() {
continue;
}
*table.entry(normalized.to_string()).or_insert(0.0) += 1.0;
}
if let Some(path) = &args.word_file {
for (word, weight) in parse_word_file(path)? {
*table.entry(word).or_insert(0.0) += weight;
}
}
if let Some(path) = &args.weights_file {
for (word, weight) in parse_word_file(path)? {
if let Some(existing) = table.get_mut(&word) {
*existing = weight;
}
}
}
let words = table
.into_iter()
.map(|(word, weight)| WordEntry::new(word, weight.max(0.0)))
.collect::<Vec<_>>();
if words.is_empty() {
return Err(GlyphWeaveError::InvalidConfig(
"no words provided: use --words or --word-file".to_string(),
));
}
Ok(words)
}
pub fn parse_word_file(path: &Path) -> Result<Vec<(String, f32)>, GlyphWeaveError> {
let content = std::fs::read_to_string(path)?;
let mut out = Vec::new();
for (index, raw_line) in content.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((word, weight)) = parse_word_line(line) else {
return Err(GlyphWeaveError::InvalidConfig(format!(
"invalid word format in {} at line {}: '{line}'",
path.display(),
index + 1
)));
};
out.push((word, weight));
}
Ok(out)
}
pub fn parse_shape_size_text(input: &str) -> Result<FontSizeSpec, String> {
if input.eq_ignore_ascii_case("AutoFit") {
return Ok(FontSizeSpec::AutoFit);
}
let size = input
.trim()
.parse::<usize>()
.map_err(|_| "invalid font size".to_string())?;
Ok(FontSizeSpec::Fixed(size))
}
fn parse_word_line(line: &str) -> Option<(String, f32)> {
let mut parts = line.splitn(2, ',');
let word = parts.next()?.trim();
if word.is_empty() {
return None;
}
let weight = if let Some(weight_raw) = parts.next() {
weight_raw.trim().parse::<f32>().ok()?
} else {
1.0
};
Some((word.to_string(), weight))
}
fn parse_tuple(input: &str) -> Result<(usize, usize), String> {
let parts: Vec<&str> = input.split(',').collect();
if parts.len() != 2 {
return Err("value must use WIDTH,HEIGHT format".to_string());
}
let first = parts[0]
.trim()
.parse::<usize>()
.map_err(|_| "invalid first number".to_string())?;
let second = parts[1]
.trim()
.parse::<usize>()
.map_err(|_| "invalid second number".to_string())?;
Ok((first, second))
}
fn parse_shape_size(input: &str) -> Result<FontSizeSpec, String> {
parse_shape_size_text(input)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_word_line_supports_optional_weight() {
let entry = parse_word_line("hello,2.5").expect("line should parse");
assert_eq!(entry.0, "hello");
assert_eq!(entry.1, 2.5);
let entry = parse_word_line("world").expect("line should parse");
assert_eq!(entry.0, "world");
assert_eq!(entry.1, 1.0);
}
#[test]
fn parse_algorithm_name() {
assert!(matches!(
CliAlgorithm::parse_text("simulated-annealing"),
Some(CliAlgorithm::SimulatedAnnealing)
));
assert!(CliAlgorithm::parse_text("unknown").is_none());
}
}