preprinttui 0.1.15

Terminal User Interface (TUI) for PreConnect printer.
use serde::{Deserialize, Deserializer, Serialize};

fn lenient_timestamp<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<u64>, D::Error> {
    Ok(
        match Option::<serde_json::Value>::deserialize(deserializer)? {
            Some(serde_json::Value::Number(n)) => n.as_u64(),
            Some(serde_json::Value::String(s)) => s.trim().parse().ok(),
            _ => None,
        },
    )
}

#[derive(Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PrintStats {
    pub status: Option<String>,
    pub uptime_seconds: Option<u64>,
    pub active_printers: Option<usize>,
    pub queued_count: Option<usize>,
    pub history_count: Option<usize>,
    pub last_job_at: Option<u64>,
}

#[derive(Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct WorkerInfo {
    pub id: Option<String>,
    pub ip: Option<String>,
    pub user_agent: Option<String>,
    pub jobs_completed: Option<usize>,
    pub connected_at: Option<u64>,
    pub last_seen_at: Option<u64>,
    #[serde(default, deserialize_with = "lenient_timestamp")]
    pub last_update_checked: Option<u64>,
    pub connections: Option<usize>,
    pub status: Option<String>,
}

#[derive(Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct HistoryEntry {
    pub id: Option<serde_json::Value>,
    pub queue_id: Option<serde_json::Value>,
    pub file_name: Option<String>,
    pub student: Option<String>,
    pub hostname: Option<String>,
    pub client_ip: Option<String>,
    pub ip: Option<String>,
    pub queue: Option<String>,
    pub status: Option<String>,
    pub timestamp: Option<u64>,
    pub size_bytes: Option<u64>,
}

impl HistoryEntry {
    pub fn display_id(&self) -> String {
        match self.queue_id.as_ref().or(self.id.as_ref()) {
            Some(serde_json::Value::Number(n)) => n.to_string(),
            Some(serde_json::Value::String(s)) => s.clone(),
            _ => "-".to_string(),
        }
    }
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SseInit {
    pub stats: Option<PrintStats>,
    pub workers: Option<Vec<WorkerInfo>>,
}

#[derive(Clone, Deserialize)]
pub struct Student {
    #[serde(rename = "Student_ID")]
    pub id: String,
    #[serde(rename = "Name")]
    pub name: String,
    #[serde(rename = "Dept")]
    pub dept: String,
}

#[derive(Deserialize)]
pub struct StudentFile {
    pub students: Vec<Student>,
}

#[derive(Clone, Deserialize, Default)]
pub struct IpInfo {
    #[serde(default)]
    pub success: bool,
    pub message: Option<String>,
    #[serde(rename = "type")]
    pub kind: Option<String>,
    pub city: Option<String>,
    pub region: Option<String>,
    pub region_code: Option<String>,
    pub country: Option<String>,
    pub country_code: Option<String>,
    pub postal: Option<String>,
    pub calling_code: Option<String>,
    pub latitude: Option<f64>,
    pub longitude: Option<f64>,
    #[serde(default)]
    pub connection: IpConnection,
    #[serde(default)]
    pub timezone: IpTimezone,
}

#[derive(Clone, Deserialize, Default)]
pub struct IpConnection {
    pub asn: Option<u64>,
    pub org: Option<String>,
    pub isp: Option<String>,
    pub domain: Option<String>,
}

#[derive(Clone, Deserialize, Default)]
pub struct IpTimezone {
    pub id: Option<String>,
    pub utc: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::WorkerInfo;
    use serde_json::json;

    #[test]
    fn update_check_accepts_numbers_and_strings() {
        for (value, expected) in [
            (json!(1_700_000_000_000_u64), Some(1_700_000_000_000)),
            (json!("1700000000000"), Some(1_700_000_000_000)),
            (json!("soon"), None),
            (json!(null), None),
        ] {
            let worker: WorkerInfo =
                serde_json::from_value(json!({ "lastUpdateChecked": value })).expect("json");
            assert_eq!(worker.last_update_checked, expected);
        }
    }
}