use clap::Subcommand;
use uuid::Uuid;
use crate::api::ControlPlaneClient;
use crate::config::SessionConfig;
#[derive(Debug, Subcommand)]
#[command(
about = "List or create characters (requires login session)",
long_about = "Characters are created only through this command (or POST /v1/characters).\n\
Play never auto-creates — use an existing name or create one first."
)]
pub enum CharacterCommand {
List,
Create {
name: String,
},
}
#[derive(serde::Deserialize)]
struct CharacterList {
characters: Vec<CharacterRow>,
}
#[derive(serde::Deserialize, serde::Serialize, Clone)]
struct CharacterRow {
id: Uuid,
name: String,
}
#[derive(serde::Deserialize)]
struct CreateCharacter {
character: CharacterRow,
}
pub async fn run(command: CharacterCommand, json: bool) -> anyhow::Result<()> {
match command {
CharacterCommand::List => list(json).await,
CharacterCommand::Create { name } => create(&name, json).await,
}
}
async fn list(json: bool) -> anyhow::Result<()> {
let session = SessionConfig::load()?;
let client = ControlPlaneClient::new(&session.api_base);
let data: CharacterList = client.get("/v1/characters", &session.session_token).await?;
if json {
println!("{}", serde_json::to_string_pretty(&data.characters)?);
return Ok(());
}
if data.characters.is_empty() {
println!("No characters yet. Create one:");
println!(" flatland3 character create <Name>");
return Ok(());
}
println!("Characters ({}):", data.characters.len());
for c in &data.characters {
let marker = if session.last_character_id == Some(c.id) {
" *"
} else {
""
};
println!(" {} {}{}", c.name, c.id, marker);
}
if session.last_character_id.is_some() {
println!(" (* last used for play)");
}
Ok(())
}
async fn create(name: &str, json: bool) -> anyhow::Result<()> {
let name = name.trim();
validate_character_name(name)?;
let mut session = SessionConfig::load()?;
let client = ControlPlaneClient::new(&session.api_base);
let created: CreateCharacter = client
.post(
"/v1/characters",
&serde_json::json!({ "name": name }),
Some(&session.session_token),
)
.await?;
session.remember_character(created.character.id)?;
if json {
println!("{}", serde_json::to_string_pretty(&created.character)?);
return Ok(());
}
println!(
"Created character \"{}\" ({})",
created.character.name, created.character.id
);
println!("Play with: flatland3 --name {}", created.character.name);
Ok(())
}
pub fn validate_character_name(name: &str) -> anyhow::Result<()> {
let name = name.trim();
if name.is_empty() || name.len() > 32 {
anyhow::bail!("character name must be 1–32 characters");
}
let mut chars = name.chars();
let Some(first) = chars.next() else {
anyhow::bail!("character name must be 1–32 characters");
};
if !first.is_ascii_alphabetic() {
anyhow::bail!("character name must start with a letter (A–Z)");
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
anyhow::bail!(
"character name may only contain letters, digits, '_' and '-' (no spaces or ':')"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::validate_character_name;
#[test]
fn accepts_simple_names() {
assert!(validate_character_name("madsin").is_ok());
assert!(validate_character_name("Mad_Sin-2").is_ok());
}
#[test]
fn rejects_colon_junk() {
assert!(validate_character_name("madsin:3A").is_err());
assert!(validate_character_name("mad sin").is_err());
assert!(validate_character_name("1mad").is_err());
}
}