use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct App {
pub app_id: String,
pub repo_root: String,
pub ui_bridge_url: String,
pub display_name: String,
pub created_at_ms: i64,
pub last_seen_at_ms: i64,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RegisterAppRequest {
pub app_id: String,
pub repo_root: String,
pub ui_bridge_url: String,
pub display_name: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAppRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_bridge_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AppListResponse {
pub ok: bool,
pub apps: Vec<App>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, thiserror::Error)]
#[serde(
tag = "reason",
rename_all = "kebab-case",
rename_all_fields = "camelCase"
)]
pub enum AppError {
#[error("app id '{app_id}' is not registered")]
NotRegistered { app_id: String },
#[error("app id '{app_id}' is not a valid slug")]
InvalidAppId { app_id: String },
#[error("repo root '{repo_root}' does not exist or is not a directory")]
InvalidRepoRoot { repo_root: String },
#[error("app id '{app_id}' is already registered")]
AlreadyRegistered { app_id: String },
}
pub fn validate_app_id(s: &str) -> Result<(), AppError> {
let len_ok = (1..=64).contains(&s.len());
let first_ok = s
.chars()
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
let rest_ok = s
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if len_ok && first_ok && rest_ok {
Ok(())
} else {
Err(AppError::InvalidAppId { app_id: s.into() })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_accepts_canonical_slugs() {
assert!(validate_app_id("qontinui-runner").is_ok());
assert!(validate_app_id("qontinui-web").is_ok());
assert!(validate_app_id("my-saas").is_ok());
assert!(validate_app_id("customer-portal-v2").is_ok());
assert!(validate_app_id("a").is_ok()); assert!(validate_app_id("0").is_ok()); assert!(validate_app_id(&"a".repeat(64)).is_ok()); }
#[test]
fn validate_rejects_invalid_slugs() {
assert!(matches!(
validate_app_id(""),
Err(AppError::InvalidAppId { .. })
));
assert!(matches!(
validate_app_id("-leading-hyphen"),
Err(AppError::InvalidAppId { .. })
));
assert!(matches!(
validate_app_id("Has_Underscore"),
Err(AppError::InvalidAppId { .. })
));
assert!(matches!(
validate_app_id("Upper"),
Err(AppError::InvalidAppId { .. })
));
assert!(matches!(
validate_app_id(&"a".repeat(65)),
Err(AppError::InvalidAppId { .. })
));
}
#[test]
fn app_error_serializes_with_tagged_reason() {
let err = AppError::NotRegistered {
app_id: "ghost".into(),
};
let s = serde_json::to_string(&err).unwrap();
assert!(s.contains(r#""reason":"not-registered""#));
assert!(s.contains(r#""appId":"ghost""#));
}
}