use std::any::TypeId;
use std::ffi::OsStr;
use std::fmt;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use clap::builder::{MapValueParser, PathBufValueParser, StringValueParser, TypedValueParser};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Role {
Model,
Prompt,
InputFile,
Backend,
Sampling,
Mode,
Other,
Unknown,
}
impl Role {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Model => "model",
Self::Prompt => "prompt",
Self::InputFile => "input-file",
Self::Backend => "backend",
Self::Sampling => "sampling",
Self::Mode => "mode",
Self::Other => "other",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Carrier {
Path,
Text,
}
#[derive(Debug, Clone, Copy)]
pub struct Marker {
pub name: &'static str,
pub role: Role,
pub carrier: Carrier,
type_id: fn() -> TypeId,
}
impl Marker {
#[must_use]
pub fn type_id(&self) -> TypeId {
(self.type_id)()
}
}
macro_rules! path_role {
($(#[$doc:meta])* $name:ident) => {
$(#[$doc])*
#[derive(Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct $name(PathBuf);
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
impl $name {
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self(path.into())
}
#[must_use]
pub fn as_path(&self) -> &Path {
&self.0
}
#[must_use]
pub fn into_path_buf(self) -> PathBuf {
self.0
}
}
impl Deref for $name {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl AsRef<Path> for $name {
fn as_ref(&self) -> &Path {
&self.0
}
}
impl AsRef<OsStr> for $name {
fn as_ref(&self) -> &OsStr {
self.0.as_os_str()
}
}
impl From<PathBuf> for $name {
fn from(p: PathBuf) -> Self {
Self(p)
}
}
impl From<&Path> for $name {
fn from(p: &Path) -> Self {
Self(p.to_path_buf())
}
}
impl From<&str> for $name {
fn from(p: &str) -> Self {
Self(PathBuf::from(p))
}
}
impl From<String> for $name {
fn from(p: String) -> Self {
Self(PathBuf::from(p))
}
}
impl From<$name> for PathBuf {
fn from(p: $name) -> PathBuf {
p.0
}
}
impl PartialEq<PathBuf> for $name {
fn eq(&self, other: &PathBuf) -> bool {
&self.0 == other
}
}
impl PartialEq<Path> for $name {
fn eq(&self, other: &Path) -> bool {
self.0 == other
}
}
impl clap::builder::ValueParserFactory for $name {
type Parser = MapValueParser<PathBufValueParser, fn(PathBuf) -> $name>;
fn value_parser() -> Self::Parser {
PathBufValueParser::new().map($name as fn(PathBuf) -> $name)
}
}
};
}
macro_rules! text_role {
($(#[$doc:meta])* $name:ident) => {
$(#[$doc])*
#[derive(Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct $name(String);
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
impl $name {
#[must_use]
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn as_string(&self) -> &String {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
impl Deref for $name {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl AsRef<Path> for $name {
fn as_ref(&self) -> &Path {
Path::new(&self.0)
}
}
impl AsRef<OsStr> for $name {
fn as_ref(&self) -> &OsStr {
OsStr::new(&self.0)
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for $name {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for $name {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl From<$name> for String {
fn from(s: $name) -> String {
s.0
}
}
impl PartialEq<str> for $name {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for $name {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<String> for $name {
fn eq(&self, other: &String) -> bool {
&self.0 == other
}
}
impl clap::builder::ValueParserFactory for $name {
type Parser = MapValueParser<StringValueParser, fn(String) -> $name>;
fn value_parser() -> Self::Parser {
StringValueParser::new().map($name as fn(String) -> $name)
}
}
};
}
path_role!(
ModelPath
);
path_role!(
InputFile
);
path_role!(
OutputPath
);
path_role!(
DirPath
);
path_role!(
ConfigPath
);
text_role!(
ModelRef
);
text_role!(
PromptText
);
text_role!(
FreeText
);
text_role!(
EncodeText
);
#[derive(Debug, Clone, Copy)]
pub struct ServesGeneration;
impl ServesGeneration {
pub const ID: &'static str = "batuta_common::cli_roles::ServesGeneration";
#[must_use]
pub fn group() -> clap::ArgGroup {
clap::ArgGroup::new(Self::ID).multiple(true)
}
#[must_use]
pub fn is_on(cmd: &clap::Command) -> bool {
cmd.get_groups().any(|g| g.get_id() == Self::ID)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum SamplingKind {
Seed,
Temperature,
TopK,
TopP,
MinP,
RepeatPenalty,
RepeatLastN,
}
impl SamplingKind {
pub const ALL: [SamplingKind; 7] = [
Self::Seed,
Self::Temperature,
Self::TopK,
Self::TopP,
Self::MinP,
Self::RepeatPenalty,
Self::RepeatLastN,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Seed => "seed",
Self::Temperature => "temperature",
Self::TopK => "top_k",
Self::TopP => "top_p",
Self::MinP => "min_p",
Self::RepeatPenalty => "repeat_penalty",
Self::RepeatLastN => "repeat_last_n",
}
}
#[must_use]
pub const fn id(self) -> &'static str {
match self {
Self::Seed => "batuta_common::cli_roles::SamplingArg::seed",
Self::Temperature => "batuta_common::cli_roles::SamplingArg::temperature",
Self::TopK => "batuta_common::cli_roles::SamplingArg::top_k",
Self::TopP => "batuta_common::cli_roles::SamplingArg::top_p",
Self::MinP => "batuta_common::cli_roles::SamplingArg::min_p",
Self::RepeatPenalty => "batuta_common::cli_roles::SamplingArg::repeat_penalty",
Self::RepeatLastN => "batuta_common::cli_roles::SamplingArg::repeat_last_n",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct SamplingArg;
impl SamplingArg {
#[must_use]
pub fn groups() -> Vec<clap::ArgGroup> {
SamplingKind::ALL
.iter()
.map(|k| clap::ArgGroup::new(k.id()).multiple(true))
.collect()
}
#[must_use]
pub fn kind_of(built: &clap::Command, arg: &clap::Arg) -> Option<SamplingKind> {
SamplingKind::ALL.into_iter().find(|k| {
built
.get_groups()
.any(|g| g.get_id() == k.id() && g.get_args().any(|a| a == arg.get_id()))
})
}
}
#[must_use]
pub fn strings<T: AsRef<str>>(values: &[T]) -> Vec<String> {
values.iter().map(|v| v.as_ref().to_string()).collect()
}
#[must_use]
pub fn path_bufs<T: AsRef<Path>>(values: &[T]) -> Vec<PathBuf> {
values.iter().map(|v| v.as_ref().to_path_buf()).collect()
}
pub const MARKERS: &[Marker] = &[
Marker {
name: "ModelPath",
role: Role::Model,
carrier: Carrier::Path,
type_id: TypeId::of::<ModelPath>,
},
Marker {
name: "ModelRef",
role: Role::Model,
carrier: Carrier::Text,
type_id: TypeId::of::<ModelRef>,
},
Marker {
name: "PromptText",
role: Role::Prompt,
carrier: Carrier::Text,
type_id: TypeId::of::<PromptText>,
},
Marker {
name: "InputFile",
role: Role::InputFile,
carrier: Carrier::Path,
type_id: TypeId::of::<InputFile>,
},
Marker {
name: "OutputPath",
role: Role::Other,
carrier: Carrier::Path,
type_id: TypeId::of::<OutputPath>,
},
Marker {
name: "DirPath",
role: Role::Other,
carrier: Carrier::Path,
type_id: TypeId::of::<DirPath>,
},
Marker {
name: "ConfigPath",
role: Role::Other,
carrier: Carrier::Path,
type_id: TypeId::of::<ConfigPath>,
},
Marker {
name: "FreeText",
role: Role::Other,
carrier: Carrier::Text,
type_id: TypeId::of::<FreeText>,
},
Marker {
name: "EncodeText",
role: Role::Other,
carrier: Carrier::Text,
type_id: TypeId::of::<EncodeText>,
},
];
#[must_use]
pub fn marker_for(type_id: TypeId) -> Option<&'static Marker> {
MARKERS.iter().find(|m| m.type_id() == type_id)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{Arg, Command};
fn parser_type<T: clap::builder::ValueParserFactory>() -> TypeId
where
T::Parser: TypedValueParser + Send + Sync + 'static,
<T::Parser as TypedValueParser>::Value: Send + Sync + Clone + 'static,
{
let vp: clap::builder::ValueParser = T::value_parser().into();
let arg = Arg::new("x").value_parser(vp);
let id = arg.get_value_parser().type_id();
MARKERS
.iter()
.map(Marker::type_id)
.chain([TypeId::of::<PathBuf>(), TypeId::of::<String>()])
.find(|t| id == *t)
.expect("value parser yields a known type")
}
#[test]
fn every_marker_parser_reports_its_own_type_id() {
assert_eq!(parser_type::<ModelPath>(), TypeId::of::<ModelPath>());
assert_eq!(parser_type::<ModelRef>(), TypeId::of::<ModelRef>());
assert_eq!(parser_type::<PromptText>(), TypeId::of::<PromptText>());
assert_eq!(parser_type::<InputFile>(), TypeId::of::<InputFile>());
assert_eq!(parser_type::<OutputPath>(), TypeId::of::<OutputPath>());
assert_eq!(parser_type::<DirPath>(), TypeId::of::<DirPath>());
assert_eq!(parser_type::<ConfigPath>(), TypeId::of::<ConfigPath>());
assert_eq!(parser_type::<FreeText>(), TypeId::of::<FreeText>());
assert_eq!(parser_type::<EncodeText>(), TypeId::of::<EncodeText>());
}
#[test]
fn marker_table_is_one_to_one_with_types() {
let mut ids: Vec<TypeId> = MARKERS.iter().map(Marker::type_id).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), MARKERS.len(), "two markers share a TypeId");
assert!(
marker_for(TypeId::of::<PathBuf>()).is_none(),
"raw PathBuf must not be a marker"
);
assert!(
marker_for(TypeId::of::<String>()).is_none(),
"raw String must not be a marker"
);
assert_eq!(
marker_for(TypeId::of::<ModelPath>()).map(|m| m.role),
Some(Role::Model)
);
assert_eq!(
marker_for(TypeId::of::<PromptText>()).map(|m| m.role),
Some(Role::Prompt)
);
}
#[test]
fn marker_parsers_accept_what_the_raw_parsers_accept() {
let raw = Command::new("t").arg(Arg::new("p").value_parser(clap::value_parser!(PathBuf)));
let typed =
Command::new("t").arg(Arg::new("p").value_parser(clap::value_parser!(ModelPath)));
for argv in [vec!["t", "m.gguf"], vec!["t", ""], vec!["t", "-"]] {
let a = raw.clone().try_get_matches_from(&argv).is_ok();
let b = typed.clone().try_get_matches_from(&argv).is_ok();
assert_eq!(a, b, "PathBuf vs ModelPath disagree on {argv:?}");
}
let m = typed.try_get_matches_from(["t", "m.gguf"]).expect("parses");
assert_eq!(
m.get_one::<ModelPath>("p").map(ModelPath::as_path),
Some(Path::new("m.gguf"))
);
let raw = Command::new("t").arg(Arg::new("s").value_parser(clap::value_parser!(String)));
let typed =
Command::new("t").arg(Arg::new("s").value_parser(clap::value_parser!(PromptText)));
for argv in [vec!["t", "What is 2+2?"], vec!["t", ""]] {
let a = raw.clone().try_get_matches_from(&argv).is_ok();
let b = typed.clone().try_get_matches_from(&argv).is_ok();
assert_eq!(a, b, "String vs PromptText disagree on {argv:?}");
}
}
#[test]
fn serves_generation_marks_a_command_without_changing_it() {
let plain = Command::new("t").arg(Arg::new("port").long("port"));
let marked = plain.clone().group(ServesGeneration::group());
assert!(!ServesGeneration::is_on(&plain));
assert!(ServesGeneration::is_on(&marked));
for argv in [vec!["t"], vec!["t", "--port", "8080"], vec!["t", "--nope"]] {
let a = plain.clone().try_get_matches_from(&argv).is_ok();
let b = marked.clone().try_get_matches_from(&argv).is_ok();
assert_eq!(a, b, "the marker changed parsing of {argv:?}");
}
let help = |c: &Command| c.clone().render_long_help().to_string();
assert_eq!(help(&plain), help(&marked), "the marker changed --help");
let lookalike = plain.group(clap::ArgGroup::new("ServesGeneration").multiple(true));
assert!(!ServesGeneration::is_on(&lookalike));
}
#[test]
fn debug_output_is_the_raw_types() {
assert_eq!(
format!("{:?}", ModelPath::from("m.gguf")),
format!("{:?}", PathBuf::from("m.gguf"))
);
assert_eq!(
format!("{:?}", Some(OutputPath::from("o"))),
format!("{:?}", Some(PathBuf::from("o")))
);
assert_eq!(
format!("{:?}", PromptText::from("hi")),
format!("{:?}", String::from("hi"))
);
assert_eq!(
format!("{:?}", vec![FreeText::from("a")]),
format!("{:?}", vec![String::from("a")])
);
}
#[test]
fn derive_picks_the_marker_parser_for_a_marker_field() {
#[derive(clap::Parser)]
struct T {
model: ModelPath,
#[arg(long)]
prompt: Option<PromptText>,
#[arg(long)]
inputs: Vec<InputFile>,
}
use clap::{CommandFactory, Parser};
let cmd = T::command();
let id = |name: &str| {
cmd.get_arguments()
.find(|a| a.get_id() == name)
.map(|a| a.get_value_parser().type_id())
.expect("arg exists")
};
assert!(id("model") == TypeId::of::<ModelPath>());
assert!(id("prompt") == TypeId::of::<PromptText>());
assert!(id("inputs") == TypeId::of::<InputFile>());
let t = T::try_parse_from(["t", "a.apr", "--prompt", "hi", "--inputs", "x.wav"])
.expect("parses");
assert_eq!(t.model.as_path(), Path::new("a.apr"));
assert_eq!(t.prompt.as_deref(), Some("hi"));
assert_eq!(t.inputs.len(), 1);
}
#[test]
fn role_and_sampling_spellings_are_the_json_contract() {
let roles = [
(Role::Model, "model"),
(Role::Prompt, "prompt"),
(Role::InputFile, "input-file"),
(Role::Backend, "backend"),
(Role::Sampling, "sampling"),
(Role::Mode, "mode"),
(Role::Other, "other"),
(Role::Unknown, "unknown"),
];
for (role, s) in roles {
assert_eq!(role.as_str(), s);
}
let kinds = [
"seed",
"temperature",
"top_k",
"top_p",
"min_p",
"repeat_penalty",
"repeat_last_n",
];
for (k, s) in SamplingKind::ALL.iter().zip(kinds) {
assert_eq!(k.as_str(), s);
assert_eq!(
k.id(),
format!("batuta_common::cli_roles::SamplingArg::{s}")
);
}
}
#[test]
fn sampling_groups_are_one_per_kind() {
let groups = SamplingArg::groups();
let ids: Vec<&str> = groups.iter().map(|g| g.get_id().as_str()).collect();
let want: Vec<&str> = SamplingKind::ALL.iter().map(|k| k.id()).collect();
assert_eq!(ids, want);
assert!(groups.into_iter().all(|mut g| g.is_multiple()));
}
#[test]
fn kind_of_reads_the_group_an_argument_joined() {
let mut cmd = Command::new("t").groups(SamplingArg::groups());
for k in SamplingKind::ALL {
cmd = cmd.arg(Arg::new(k.as_str()).long(k.as_str()).group(k.id()));
}
cmd = cmd.arg(Arg::new("port").long("port"));
cmd.build();
for k in SamplingKind::ALL {
let arg = cmd
.get_arguments()
.find(|a| a.get_id() == k.as_str())
.expect("arg exists");
assert_eq!(SamplingArg::kind_of(&cmd, arg), Some(k), "{k:?}");
}
let port = cmd
.get_arguments()
.find(|a| a.get_id() == "port")
.expect("arg exists");
assert_eq!(SamplingArg::kind_of(&cmd, port), None);
}
#[test]
fn strings_and_path_bufs_keep_every_value_in_order() {
let texts = [FreeText::from("a"), FreeText::from("b")];
assert_eq!(strings(&texts), vec!["a".to_string(), "b".to_string()]);
assert!(strings::<FreeText>(&[]).is_empty());
let paths = [InputFile::from("x.wav"), InputFile::from("y.wav")];
assert_eq!(
path_bufs(&paths),
vec![PathBuf::from("x.wav"), PathBuf::from("y.wav")]
);
}
}