use clap::Args;
use topcoat_font::fontsource::Family;
use super::{
choice::{Database, DatabaseBackend, Font, Icons, Interaction, Routing},
options::{DatabaseSetup, FontSetup, IconSetup, ProjectOptions, UI_ICON_SET},
};
pub const DEFAULT_ICON_SET: &str = "lucide";
const DEFAULT_FONT_FAMILY: &str = "inter";
const UI_FONT_FAMILY: &str = "geist";
#[derive(Args)]
#[expect(
clippy::struct_excessive_bools,
reason = "each bool is a command-line switch"
)]
pub struct ChoiceArgs {
#[arg(long, conflicts_with = "minimal")]
recommended: bool,
#[arg(long)]
minimal: bool,
#[arg(long, value_name = "STYLE")]
routing: Option<Routing>,
#[arg(long, value_name = "INTEGRATION")]
database: Option<Database>,
#[arg(long, value_name = "BACKEND")]
database_backend: Option<DatabaseBackend>,
#[arg(long, value_name = "APPROACH")]
interaction: Option<Interaction>,
#[arg(long, conflicts_with = "no_tailwind")]
tailwind: bool,
#[arg(long)]
no_tailwind: bool,
#[arg(long, value_name = "SOURCE")]
icons: Option<Icons>,
#[arg(long, value_name = "SET")]
icon_set: Option<String>,
#[arg(long, value_name = "SOURCE")]
font: Option<Font>,
#[arg(long, value_name = "FAMILY")]
font_family: Option<String>,
#[arg(long, conflicts_with = "no_ui")]
ui: bool,
#[arg(long)]
no_ui: bool,
#[arg(long, conflicts_with = "no_example")]
example: bool,
#[arg(long)]
no_example: bool,
}
impl ChoiceArgs {
pub fn preset(&self) -> Option<Preset> {
if self.recommended {
Some(Preset::Recommended)
} else if self.minimal {
Some(Preset::Minimal)
} else {
None
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Preset {
Recommended,
Minimal,
}
impl Preset {
pub fn choices(self) -> PresetChoices {
match self {
Self::Recommended => PresetChoices {
routing: Routing::Module,
database: Database::Toasty,
interaction: Interaction::Topcoat,
tailwind: true,
icons: Icons::Iconify,
font: Font::Fontsource,
ui: true,
example: true,
},
Self::Minimal => PresetChoices {
routing: Routing::Module,
database: Database::None,
interaction: Interaction::None,
tailwind: false,
icons: Icons::None,
font: Font::None,
ui: false,
example: false,
},
}
}
}
pub struct PresetChoices {
pub routing: Routing,
pub database: Database,
pub interaction: Interaction,
pub tailwind: bool,
pub icons: Icons,
pub font: Font,
pub ui: bool,
pub example: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Origin {
Flag,
Preset(Preset),
Inferred,
Prompt,
}
#[derive(Clone, Copy, Debug)]
pub struct Sourced<T> {
pub value: T,
pub origin: Origin,
}
impl<T> Sourced<T> {
pub fn new(value: T, origin: Origin) -> Self {
Self { value, origin }
}
}
pub struct Input {
pub routing: Option<Sourced<Routing>>,
pub database: Option<Sourced<Database>>,
pub database_backend: Option<DatabaseBackend>,
pub interaction: Option<Sourced<Interaction>>,
pub tailwind: Option<Sourced<bool>>,
pub icons: Option<Sourced<Icons>>,
pub icon_set: Option<String>,
pub font: Option<Sourced<Font>>,
pub font_family: Option<String>,
pub ui: Option<Sourced<bool>>,
pub example: Option<Sourced<bool>>,
}
impl Input {
pub fn new(args: ChoiceArgs) -> Self {
fn flag<T>(value: T) -> Sourced<T> {
Sourced::new(value, Origin::Flag)
}
let preset = args.preset();
let mut input = Self {
routing: args.routing.map(flag),
database: args.database.map(flag),
database_backend: args.database_backend,
interaction: args.interaction.map(flag),
tailwind: switch(args.tailwind, args.no_tailwind).map(flag),
icons: args.icons.map(flag),
icon_set: args.icon_set,
font: args.font.map(flag),
font_family: args.font_family,
ui: switch(args.ui, args.no_ui).map(flag),
example: switch(args.example, args.no_example).map(flag),
};
if let Some(preset) = preset {
input.fill(preset);
}
input.infer();
input
}
fn fill(&mut self, preset: Preset) {
let origin = Origin::Preset(preset);
let choices = preset.choices();
self.routing
.get_or_insert(Sourced::new(choices.routing, origin));
self.database
.get_or_insert(Sourced::new(choices.database, origin));
self.interaction
.get_or_insert(Sourced::new(choices.interaction, origin));
self.tailwind
.get_or_insert(Sourced::new(choices.tailwind, origin));
self.icons
.get_or_insert(Sourced::new(choices.icons, origin));
self.font.get_or_insert(Sourced::new(choices.font, origin));
self.ui.get_or_insert(Sourced::new(choices.ui, origin));
self.example
.get_or_insert(Sourced::new(choices.example, origin));
}
pub fn infer(&mut self) {
if self.ui.is_some_and(|ui| ui.value) {
self.tailwind
.get_or_insert(Sourced::new(true, Origin::Inferred));
self.icons
.get_or_insert(Sourced::new(Icons::Iconify, Origin::Inferred));
self.font
.get_or_insert(Sourced::new(Font::Fontsource, Origin::Inferred));
}
let ui_unavailable = self.tailwind.is_some_and(|tailwind| !tailwind.value)
|| self
.icons
.is_some_and(|icons| icons.value != Icons::Iconify);
if ui_unavailable {
self.ui.get_or_insert(Sourced::new(false, Origin::Inferred));
}
}
pub fn answer(&mut self, answer: Answer) {
fn prompt<T>(value: T) -> Sourced<T> {
Sourced::new(value, Origin::Prompt)
}
match answer {
Answer::Routing(value) => self.routing = Some(prompt(value)),
Answer::Database(value) => self.database = Some(prompt(value)),
Answer::Interaction(value) => self.interaction = Some(prompt(value)),
Answer::Tailwind(value) => self.tailwind = Some(prompt(value)),
Answer::Icons(value) => self.icons = Some(prompt(value)),
Answer::Font(value) => self.font = Some(prompt(value)),
Answer::Ui(value) => self.ui = Some(prompt(value)),
Answer::Example(value) => self.example = Some(prompt(value)),
}
self.infer();
}
pub fn default_font_family(&self) -> &'static str {
if self.ui.is_some_and(|ui| ui.value) {
UI_FONT_FAMILY
} else {
DEFAULT_FONT_FAMILY
}
}
pub fn questions(&self) -> Vec<Question> {
[
(self.routing.is_none(), Question::Routing),
(self.database.is_none(), Question::Database),
(self.interaction.is_none(), Question::Interaction),
(self.ui.is_none(), Question::Ui),
(self.tailwind.is_none(), Question::Tailwind),
(self.icons.is_none(), Question::Icons),
(self.font.is_none(), Question::Font),
(self.example.is_none(), Question::Example),
]
.into_iter()
.filter_map(|(open, question)| open.then_some(question))
.collect()
}
pub fn resolve(&self) -> Result<Resolution, String> {
let (
Some(routing),
Some(database),
Some(interaction),
Some(tailwind),
Some(icons),
Some(font),
Some(ui),
Some(example),
) = (
self.routing,
self.database,
self.interaction,
self.tailwind,
self.icons,
self.font,
self.ui,
self.example,
)
else {
let flags: Vec<&str> = self.questions().into_iter().map(Question::flag).collect();
return Err(format!(
"missing choices: pass {}, or use --recommended or --minimal",
flags.join(", ")
));
};
if ui.value && !tailwind.value {
return Err(conflict(
"Topcoat UI requires Tailwind",
[(ui.origin, "--no-ui"), (tailwind.origin, "--tailwind")],
));
}
if ui.value && icons.value != Icons::Iconify {
return Err(conflict(
"Topcoat UI requires Iconify icons",
[(ui.origin, "--no-ui"), (icons.origin, "--icons iconify")],
));
}
let database_setup = match (database.value, self.database_backend) {
(Database::Toasty, backend) => DatabaseSetup::Toasty {
backend: backend.unwrap_or_default(),
},
(Database::None, Some(_)) => {
return Err("--database-backend requires --database toasty".to_string());
}
(Database::None, None) => DatabaseSetup::None,
};
let icon_setup = match (icons.value, self.icon_set.as_deref()) {
(Icons::Iconify, set) => IconSetup::Iconify {
set: icon_set_id(set.unwrap_or(DEFAULT_ICON_SET))?,
},
(Icons::Custom | Icons::None, Some(_)) => {
return Err("--icon-set requires --icons iconify".to_string());
}
(Icons::Custom, None) => IconSetup::Custom,
(Icons::None, None) => IconSetup::None,
};
let font_setup = match (font.value, self.font_family.as_deref()) {
(Font::Fontsource, family) => {
let family = family.unwrap_or(self.default_font_family());
FontSetup::Fontsource {
family: font_family_id(family)?,
}
}
(Font::None, Some(_)) => {
return Err("--font-family requires --font fontsource".to_string());
}
(Font::None, None) => FontSetup::None,
};
let options = ProjectOptions {
routing: routing.value,
database: database_setup,
interaction: interaction.value,
tailwind: tailwind.value,
icons: icon_setup,
font: font_setup,
ui: ui.value,
example: example.value,
};
let mut notes = Vec::new();
let ui_prompted = ui.origin == Origin::Prompt;
if tailwind.origin == Origin::Inferred && !ui_prompted {
notes.push("Tailwind is enabled because Topcoat UI requires it".to_string());
}
if icons.origin == Origin::Inferred && !ui_prompted {
notes.push("Iconify icons are enabled because Topcoat UI requires them".to_string());
}
if font.origin == Origin::Inferred && !ui_prompted {
notes.push(
"The Geist font is loaded from Fontsource for the Topcoat UI theme".to_string(),
);
}
if options.ui && options.font == FontSetup::None {
notes.push(
"The Topcoat UI theme expects the Geist font and falls back to the system font"
.to_string(),
);
}
if options.ui && !matches!(&options.icons, IconSetup::Iconify { set } if set == UI_ICON_SET)
{
notes.push(format!(
"The `{UI_ICON_SET}` icon set is also staged because Topcoat UI components use it"
));
}
Ok(Resolution { options, notes })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Question {
Routing,
Database,
Interaction,
Ui,
Tailwind,
Icons,
Font,
Example,
}
impl Question {
pub fn flag(self) -> &'static str {
match self {
Self::Routing => "--routing",
Self::Database => "--database",
Self::Interaction => "--interaction",
Self::Tailwind => "--tailwind or --no-tailwind",
Self::Icons => "--icons",
Self::Font => "--font",
Self::Ui => "--ui or --no-ui",
Self::Example => "--example or --no-example",
}
}
}
#[derive(Clone, Copy, Debug)]
pub enum Answer {
Routing(Routing),
Database(Database),
Interaction(Interaction),
Tailwind(bool),
Icons(Icons),
Font(Font),
Ui(bool),
Example(bool),
}
pub struct Resolution {
pub options: ProjectOptions,
pub notes: Vec<String>,
}
fn switch(enabled: bool, disabled: bool) -> Option<bool> {
match (enabled, disabled) {
(true, _) => Some(true),
(_, true) => Some(false),
_ => None,
}
}
fn conflict(message: &str, overrides: [(Origin, &str); 2]) -> String {
let suggestions: Vec<&str> = overrides
.iter()
.filter(|(origin, _)| *origin != Origin::Flag)
.map(|(_, flag)| *flag)
.collect();
if suggestions.is_empty() {
format!("{message}, so these flags cannot be combined")
} else {
format!("{message}; also pass {}", suggestions.join(" or "))
}
}
pub fn icon_set_id(value: &str) -> Result<String, String> {
let valid = !value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
if valid {
Ok(value.to_string())
} else {
Err(format!(
"invalid icon set `{value}`: use lowercase letters, digits, and hyphens"
))
}
}
pub fn font_family_id(value: &str) -> Result<String, String> {
if Family::by_id(value).is_some() {
Ok(value.to_string())
} else {
Err(format!(
"unknown Fontsource family `{value}`; use a family ID listed at \
https://fontsource.org, such as `{DEFAULT_FONT_FAMILY}`"
))
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[derive(Parser)]
struct Cli {
#[command(flatten)]
choices: ChoiceArgs,
}
fn input<S: AsRef<str>>(args: &[S]) -> Input {
let args = std::iter::once("topcoat-new").chain(args.iter().map(AsRef::as_ref));
Input::new(Cli::try_parse_from(args).unwrap().choices)
}
fn resolve<S: AsRef<str>>(args: &[S]) -> Result<ProjectOptions, String> {
input(args).resolve().map(|resolution| resolution.options)
}
#[test]
fn presets_answer_every_question() {
for preset in ["--recommended", "--minimal"] {
assert_eq!(input(&[preset]).questions(), []);
assert!(resolve(&[preset]).is_ok());
}
}
#[test]
fn flags_override_preset_choices() {
let options = resolve(&[
"--recommended",
"--database",
"none",
"--interaction",
"htmx",
])
.unwrap();
assert_eq!(options.database, DatabaseSetup::None);
assert_eq!(options.interaction, Interaction::Htmx);
}
#[test]
fn preset_conflicts_suggest_overriding_the_preset_choice() {
let error = resolve(&["--recommended", "--no-tailwind"]).unwrap_err();
assert!(error.contains("--no-ui"));
assert!(resolve(&["--recommended", "--no-tailwind", "--no-ui"]).is_ok());
let error = resolve(&["--minimal", "--ui"]).unwrap_err();
assert!(error.contains("--tailwind"));
let error = resolve(&["--minimal", "--ui", "--tailwind"]).unwrap_err();
assert!(error.contains("--icons iconify"));
assert!(resolve(&["--minimal", "--ui", "--tailwind", "--icons", "iconify"]).is_ok());
}
#[test]
fn explicitly_conflicting_flags_are_rejected() {
assert!(resolve(&["--recommended", "--ui", "--no-tailwind"]).is_err());
assert!(resolve(&["--recommended", "--ui", "--icons", "custom"]).is_err());
}
#[test]
fn ui_answers_its_prerequisites() {
let questions = input(&["--ui"]).questions();
assert!(!questions.contains(&Question::Tailwind));
assert!(!questions.contains(&Question::Icons));
assert!(!questions.contains(&Question::Font));
assert!(questions.contains(&Question::Database));
}
#[test]
fn ui_is_asked_before_its_prerequisites() {
let mut input = input::<&str>(&[]);
while let Some(&question) = input.questions().first() {
assert!(
!matches!(
question,
Question::Tailwind | Question::Icons | Question::Font
),
"{question:?} is asked before Topcoat UI"
);
if question == Question::Ui {
break;
}
input.answer(match question {
Question::Routing => Answer::Routing(Routing::Module),
Question::Database => Answer::Database(Database::None),
Question::Interaction => Answer::Interaction(Interaction::None),
_ => unreachable!("{question:?}"),
});
}
input.answer(Answer::Ui(true));
let questions = input.questions();
for prerequisite in [Question::Tailwind, Question::Icons, Question::Font] {
assert!(!questions.contains(&prerequisite), "{prerequisite:?}");
}
}
#[test]
fn ui_loads_geist_unless_a_font_is_chosen() {
let options = resolve(&[
"--routing",
"module",
"--database",
"none",
"--interaction",
"none",
"--ui",
"--no-example",
])
.unwrap();
assert_eq!(
options.font,
FontSetup::Fontsource {
family: UI_FONT_FAMILY.to_string()
}
);
assert_eq!(
options.icons,
IconSetup::Iconify {
set: UI_ICON_SET.to_string()
}
);
let options = resolve(&[
"--routing",
"module",
"--database",
"none",
"--interaction",
"none",
"--ui",
"--font",
"none",
"--no-example",
])
.unwrap();
assert_eq!(options.font, FontSetup::None);
}
#[test]
fn ui_is_not_asked_once_unavailable() {
assert!(
!input(&["--no-tailwind"])
.questions()
.contains(&Question::Ui)
);
assert!(
!input(&["--icons", "none"])
.questions()
.contains(&Question::Ui)
);
assert!(input(&["--tailwind"]).questions().contains(&Question::Ui));
}
#[test]
fn answers_resolve_like_the_equivalent_flags() {
let mut answered = input::<&str>(&[]);
for answer in [
Answer::Routing(Routing::Discover),
Answer::Database(Database::Toasty),
Answer::Interaction(Interaction::Htmx),
Answer::Tailwind(true),
Answer::Icons(Icons::Custom),
Answer::Font(Font::Fontsource),
Answer::Example(false),
] {
answered.answer(answer);
}
assert_eq!(answered.questions(), []);
let flags = resolve(&[
"--routing",
"discover",
"--database",
"toasty",
"--interaction",
"htmx",
"--tailwind",
"--icons",
"custom",
"--font",
"fontsource",
"--no-ui",
"--no-example",
])
.unwrap();
assert_eq!(answered.resolve().unwrap().options, flags);
}
#[test]
fn answering_no_tailwind_makes_ui_unavailable() {
let mut input = input(&["--icons", "iconify"]);
assert!(input.questions().contains(&Question::Ui));
input.answer(Answer::Tailwind(false));
assert!(!input.questions().contains(&Question::Ui));
}
#[test]
fn missing_choices_name_their_flags() {
let error = resolve(&["--database", "none", "--no-ui"]).unwrap_err();
assert!(error.contains("--interaction"));
assert!(!error.contains("--database"));
}
#[test]
fn settings_require_their_integration() {
for setting in [
["--database-backend", "sqlite"],
["--icon-set", "tabler"],
["--font-family", "inter"],
] {
let mut args = vec!["--minimal"];
args.extend(setting);
assert!(resolve(&args).is_err(), "{args:?}");
}
}
#[test]
fn settings_reject_ids_that_are_not_catalog_ids() {
assert!(resolve(&["--recommended", "--icon-set", "../lucide"]).is_err());
assert!(resolve(&["--recommended", "--font-family", "\"inter\""]).is_err());
}
#[test]
fn font_families_must_exist_in_the_fontsource_catalog() {
assert!(resolve(&["--recommended", "--font-family", "roboto"]).is_ok());
assert!(resolve(&["--recommended", "--font-family", "no-such-font"]).is_err());
}
#[test]
fn ui_stages_its_icon_set_alongside_the_chosen_one() {
let options = resolve(&["--recommended", "--icon-set", "tabler"]).unwrap();
let mut sets = options.icon_sets();
sets.sort_unstable();
assert_eq!(sets, [UI_ICON_SET, "tabler"]);
}
}