amagi 0.1.2

Rust SDK, CLI, and Web API service skeleton for multi-platform social web adapters.
Documentation
use reqwest::Url;

use crate::error::AppError;

const TWITTER_HOSTS: &[&str] = &[
    "x.com",
    "www.x.com",
    "twitter.com",
    "www.twitter.com",
    "mobile.twitter.com",
];

const RESERVED_USER_PATHS: &[&str] = &[
    "home",
    "explore",
    "search",
    "i",
    "notifications",
    "messages",
    "compose",
    "settings",
    "tos",
    "privacy",
    "intent",
    "share",
    "hashtag",
];

pub(super) fn resolve_user_reference(input: &str) -> Result<String, AppError> {
    parse_user_reference(input)
}

pub(super) fn resolve_tweet_reference(input: &str) -> Result<String, AppError> {
    let candidate = normalized_reference_input(input)?;
    if is_numeric_identifier(candidate) {
        return Ok(candidate.to_owned());
    }

    let url = parse_twitter_url(candidate)?;
    let segments = twitter_path_segments(&url)?;

    if let Some(status_index) = segments
        .iter()
        .position(|segment| matches!(*segment, "status" | "statuses"))
    {
        let tweet_id = segments
            .get(status_index + 1)
            .copied()
            .filter(|value| is_numeric_identifier(value))
            .ok_or_else(|| {
                AppError::InvalidRequestConfig(format!(
                    "twitter tweet reference `{input}` does not contain a valid tweet id"
                ))
            })?;
        return Ok(tweet_id.to_owned());
    }

    Err(AppError::InvalidRequestConfig(format!(
        "unsupported twitter tweet reference `{input}`"
    )))
}

pub(super) fn resolve_space_reference(input: &str) -> Result<String, AppError> {
    let candidate = normalized_reference_input(input)?;
    if !looks_like_url(candidate) {
        return Ok(candidate.to_owned());
    }

    let url = parse_twitter_url(candidate)?;
    let segments = twitter_path_segments(&url)?;
    if segments.len() >= 3 && segments[0] == "i" && segments[1] == "spaces" {
        return Ok(segments[2].to_owned());
    }

    Err(AppError::InvalidRequestConfig(format!(
        "unsupported twitter space reference `{input}`"
    )))
}

pub(super) fn parse_user_reference(input: &str) -> Result<String, AppError> {
    let candidate = normalized_reference_input(input)?;
    if let Some(stripped) = candidate.strip_prefix('@') {
        return normalize_screen_name(stripped);
    }

    if !looks_like_url(candidate) {
        if is_numeric_identifier(candidate) {
            return Err(AppError::InvalidRequestConfig(format!(
                "twitter user reference `{input}` must be a screen name or profile url, numeric user ids are not supported"
            )));
        }

        return normalize_screen_name(candidate);
    }

    let url = parse_twitter_url(candidate)?;
    let segments = twitter_path_segments(&url)?;

    if segments.len() >= 3 && segments[0] == "i" && segments[1] == "user" {
        return Err(AppError::InvalidRequestConfig(format!(
            "twitter user reference `{input}` must contain a screen name, numeric user ids are not supported"
        )));
    }

    let first_segment = segments.first().copied().ok_or_else(|| {
        AppError::InvalidRequestConfig(format!(
            "twitter user reference `{input}` does not contain a user path"
        ))
    })?;
    if RESERVED_USER_PATHS.contains(&first_segment) {
        return Err(AppError::InvalidRequestConfig(format!(
            "unsupported twitter user reference `{input}`"
        )));
    }

    if is_numeric_identifier(first_segment) {
        return Err(AppError::InvalidRequestConfig(format!(
            "twitter user reference `{input}` must contain a screen name, numeric user ids are not supported"
        )));
    }

    normalize_screen_name(first_segment)
}

fn normalized_reference_input(input: &str) -> Result<&str, AppError> {
    let candidate = input.trim();
    if candidate.is_empty() {
        return Err(AppError::InvalidRequestConfig(
            "twitter reference input cannot be empty".into(),
        ));
    }

    Ok(candidate)
}

fn parse_twitter_url(input: &str) -> Result<Url, AppError> {
    let candidate = if input.contains("://") {
        input.to_owned()
    } else {
        format!("https://{input}")
    };

    let url = Url::parse(&candidate).map_err(|error| {
        AppError::InvalidRequestConfig(format!("invalid twitter reference url `{input}`: {error}"))
    })?;
    let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
    if !TWITTER_HOSTS.contains(&host.as_str()) {
        return Err(AppError::InvalidRequestConfig(format!(
            "unsupported twitter host in `{input}`"
        )));
    }

    Ok(url)
}

fn twitter_path_segments(url: &Url) -> Result<Vec<&str>, AppError> {
    let segments = url
        .path_segments()
        .map(|items| {
            items
                .filter(|segment| !segment.is_empty())
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    if segments.is_empty() {
        return Err(AppError::InvalidRequestConfig(format!(
            "twitter url `{url}` does not contain path segments"
        )));
    }

    Ok(segments)
}

fn looks_like_url(value: &str) -> bool {
    value.contains("://")
        || value.starts_with("x.com/")
        || value.starts_with("www.x.com/")
        || value.starts_with("twitter.com/")
        || value.starts_with("www.twitter.com/")
        || value.starts_with("mobile.twitter.com/")
}

fn is_numeric_identifier(value: &str) -> bool {
    !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
}

fn normalize_screen_name(value: &str) -> Result<String, AppError> {
    let normalized = value.trim().trim_start_matches('@').trim_end_matches('/');
    if normalized.is_empty() {
        return Err(AppError::InvalidRequestConfig(
            "twitter screen name cannot be empty".into(),
        ));
    }

    if normalized.contains('/') {
        return Err(AppError::InvalidRequestConfig(format!(
            "invalid twitter screen name `{value}`"
        )));
    }

    Ok(normalized.to_owned())
}