pub fn normalize_workspace_name(name: &str) -> String {
name.trim().to_lowercase()
}
pub fn validate_workspace_name(
name: &str,
existing_local: &[String],
existing_server: Option<&[String]>,
) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("Please enter a workspace name".into());
}
let normalized = normalize_workspace_name(trimmed);
if existing_local
.iter()
.any(|n| normalize_workspace_name(n) == normalized)
{
return Err("A local workspace with that name already exists".into());
}
if let Some(server_names) = existing_server
&& server_names
.iter()
.any(|n| normalize_workspace_name(n) == normalized)
{
return Err("A synced workspace with that name already exists".into());
}
Ok(trimmed.to_string())
}
pub fn validate_publishing_slug(slug: &str) -> Result<(), String> {
let slug = slug.trim();
if slug.len() < 3 || slug.len() > 64 {
return Err("Use 3-64 lowercase letters, numbers, or hyphens.".into());
}
if !slug
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err("Use 3-64 lowercase letters, numbers, or hyphens.".into());
}
Ok(())
}
pub fn normalize_server_url(url: &str) -> String {
let url = url.trim();
if url.is_empty() {
return String::new();
}
if !url.starts_with("http://") && !url.starts_with("https://") {
format!("https://{url}")
} else {
url.to_string()
}
}
pub fn to_websocket_base_url(http_url: &str) -> String {
let url = http_url
.replace("https://", "wss://")
.replace("http://", "ws://");
let url = url.trim_end_matches('/');
let url = url
.strip_suffix("/sync2")
.or_else(|| url.strip_suffix("/sync"))
.unwrap_or(url);
url.trim_end_matches('/').to_string()
}
pub fn to_websocket_sync_url(http_url: &str) -> String {
let base = to_websocket_base_url(http_url);
format!("{base}/sync2")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_trims_and_lowercases() {
assert_eq!(normalize_workspace_name(" My Journal "), "my journal");
}
#[test]
fn normalize_empty() {
assert_eq!(normalize_workspace_name(""), "");
assert_eq!(normalize_workspace_name(" "), "");
}
#[test]
fn validate_empty_name() {
let result = validate_workspace_name("", &[], None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("enter a workspace name"));
}
#[test]
fn validate_whitespace_only() {
let result = validate_workspace_name(" ", &[], None);
assert!(result.is_err());
}
#[test]
fn validate_unique_name() {
let result = validate_workspace_name("New Journal", &[], None);
assert_eq!(result.unwrap(), "New Journal");
}
#[test]
fn validate_duplicate_local_case_insensitive() {
let locals = vec!["my journal".to_string()];
let result = validate_workspace_name("My Journal", &locals, None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("local workspace"));
}
#[test]
fn validate_duplicate_server() {
let locals = vec![];
let servers = vec!["Work Notes".to_string()];
let result = validate_workspace_name("work notes", &locals, Some(&servers));
assert!(result.is_err());
assert!(result.unwrap_err().contains("synced workspace"));
}
#[test]
fn validate_no_server_check_when_none() {
let servers_exist = vec!["work notes".to_string()];
let result = validate_workspace_name("work notes", &[], None);
assert!(result.is_ok());
let result = validate_workspace_name("work notes", &[], Some(&servers_exist));
assert!(result.is_err());
}
#[test]
fn validate_trims_but_returns_original_casing() {
let result = validate_workspace_name(" My Journal ", &[], None);
assert_eq!(result.unwrap(), "My Journal");
}
#[test]
fn slug_valid() {
assert!(validate_publishing_slug("my-site").is_ok());
assert!(validate_publishing_slug("abc").is_ok());
assert!(validate_publishing_slug("a-1").is_ok());
assert!(validate_publishing_slug(&"a".repeat(64)).is_ok());
}
#[test]
fn slug_too_short() {
assert!(validate_publishing_slug("ab").is_err());
assert!(validate_publishing_slug("").is_err());
}
#[test]
fn slug_too_long() {
assert!(validate_publishing_slug(&"a".repeat(65)).is_err());
}
#[test]
fn slug_invalid_chars() {
assert!(validate_publishing_slug("My-Site").is_err()); assert!(validate_publishing_slug("my_site").is_err()); assert!(validate_publishing_slug("my site").is_err()); assert!(validate_publishing_slug("my.site").is_err()); }
#[test]
fn normalize_adds_https() {
assert_eq!(normalize_server_url("example.com"), "https://example.com");
}
#[test]
fn normalize_preserves_https() {
assert_eq!(
normalize_server_url("https://example.com"),
"https://example.com"
);
}
#[test]
fn normalize_preserves_http() {
assert_eq!(
normalize_server_url("http://localhost:8080"),
"http://localhost:8080"
);
}
#[test]
fn normalize_trims_whitespace() {
assert_eq!(
normalize_server_url(" example.com "),
"https://example.com"
);
}
#[test]
fn normalize_empty_url() {
assert_eq!(normalize_server_url(""), "");
assert_eq!(normalize_server_url(" "), "");
}
#[test]
fn ws_base_converts_https() {
assert_eq!(
to_websocket_base_url("https://example.com"),
"wss://example.com"
);
}
#[test]
fn ws_base_converts_http() {
assert_eq!(
to_websocket_base_url("http://localhost:8080"),
"ws://localhost:8080"
);
}
#[test]
fn ws_base_strips_trailing_slash() {
assert_eq!(
to_websocket_base_url("https://example.com/"),
"wss://example.com"
);
}
#[test]
fn ws_base_strips_sync2_suffix() {
assert_eq!(
to_websocket_base_url("https://example.com/sync2"),
"wss://example.com"
);
}
#[test]
fn ws_base_strips_sync_suffix() {
assert_eq!(
to_websocket_base_url("https://example.com/sync"),
"wss://example.com"
);
}
#[test]
fn ws_sync_appends_sync2() {
assert_eq!(
to_websocket_sync_url("https://example.com"),
"wss://example.com/sync2"
);
}
#[test]
fn ws_sync_idempotent_with_existing_sync2() {
assert_eq!(
to_websocket_sync_url("https://example.com/sync2"),
"wss://example.com/sync2"
);
}
#[test]
fn ws_sync_replaces_sync_with_sync2() {
assert_eq!(
to_websocket_sync_url("https://example.com/sync"),
"wss://example.com/sync2"
);
}
#[test]
fn ws_sync_with_port() {
assert_eq!(
to_websocket_sync_url("http://localhost:3000"),
"ws://localhost:3000/sync2"
);
}
}