flatland3 0.2.1

Flatland3 terminal play client and account CLI
//! Character list / create — explicit only; play never auto-creates.

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 characters on the logged-in account
    List,
    /// Create a character (session auth only; names are unique per account)
    Create {
        /// Character name (letters, digits, _ and -; 1–32 chars; must start with a letter)
        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(())
}

/// Shared client-side check (server enforces the same rules).
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());
    }
}