pfp 0.4.0

Prefect CLI - a fast Rust CLI for managing Prefect deployments and flow runs
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize)]
pub struct Deployment {
    pub id: String,
    pub name: String,
    #[serde(default)]
    pub flow_id: String,
    #[serde(default)]
    pub flow_name: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub paused: bool,
    #[serde(default)]
    pub work_pool_name: Option<String>,
    #[serde(default)]
    pub tags: Vec<String>,
    #[serde(default)]
    pub parameters: serde_json::Value,
    #[serde(default)]
    pub parameter_openapi_schema: Option<serde_json::Value>,
}

impl Deployment {
    pub fn full_name(&self) -> String {
        format!("{}/{}", self.flow_name, self.name)
    }

    pub fn status_str(&self) -> &str {
        if self.paused {
            "paused"
        } else {
            "active"
        }
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct FlowRun {
    pub id: String,
    pub name: String,
    #[serde(default)]
    pub state_type: String,
    #[serde(default)]
    pub state_name: String,
    #[serde(default)]
    pub deployment_id: Option<String>,
    #[serde(default)]
    pub start_time: Option<String>,
    #[serde(default)]
    pub end_time: Option<String>,
    #[serde(default)]
    pub total_run_time: f64,
    #[serde(default)]
    pub parameters: serde_json::Value,
}

impl FlowRun {
    pub fn is_terminal(&self) -> bool {
        matches!(
            self.state_type.as_str(),
            "COMPLETED" | "FAILED" | "CANCELLED" | "CRASHED"
        )
    }

    pub fn is_success(&self) -> bool {
        self.state_type == "COMPLETED"
    }

    pub fn duration_str(&self) -> String {
        let secs = self.total_run_time as u64;
        if secs < 60 {
            format!("{}s", secs)
        } else {
            format!("{}m {:02}s", secs / 60, secs % 60)
        }
    }

    pub fn short_id(&self) -> &str {
        if self.id.len() >= 8 {
            &self.id[..8]
        } else {
            &self.id
        }
    }

    pub fn start_time_short(&self) -> String {
        match &self.start_time {
            Some(t) => {
                if t.len() >= 16 {
                    t[..16].replace('T', " ")
                } else {
                    t.clone()
                }
            }
            None => "-".to_string(),
        }
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct LogEntry {
    pub level: u32,
    pub message: String,
    pub timestamp: String,
}

impl LogEntry {
    pub fn level_name(&self) -> &str {
        match self.level {
            0..=9 => "TRACE",
            10 => "DEBUG",
            20 => "INFO",
            30 => "WARNING",
            40 => "ERROR",
            _ => "CRITICAL",
        }
    }
}

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

    #[test]
    fn deployment_full_name() {
        let d: Deployment = serde_json::from_value(json!({
            "id": "abc",
            "name": "my-deploy-prod",
            "flow_name": "my_flow"
        }))
        .unwrap();
        assert_eq!(d.full_name(), "my_flow/my-deploy-prod");
    }

    #[test]
    fn deployment_status_active() {
        let d: Deployment = serde_json::from_value(json!({
            "id": "abc", "name": "d", "flow_name": "f", "paused": false
        }))
        .unwrap();
        assert_eq!(d.status_str(), "active");
    }

    #[test]
    fn deployment_status_paused() {
        let d: Deployment = serde_json::from_value(json!({
            "id": "abc", "name": "d", "flow_name": "f", "paused": true
        }))
        .unwrap();
        assert_eq!(d.status_str(), "paused");
    }

    #[test]
    fn flow_run_terminal_states() {
        for state in &["COMPLETED", "FAILED", "CANCELLED", "CRASHED"] {
            let fr: FlowRun = serde_json::from_value(json!({
                "id": "abc", "name": "run", "state_type": state, "state_name": state
            }))
            .unwrap();
            assert!(fr.is_terminal(), "{} should be terminal", state);
        }
    }

    #[test]
    fn flow_run_non_terminal_states() {
        for state in &["SCHEDULED", "PENDING", "RUNNING"] {
            let fr: FlowRun = serde_json::from_value(json!({
                "id": "abc", "name": "run", "state_type": state, "state_name": state
            }))
            .unwrap();
            assert!(!fr.is_terminal(), "{} should not be terminal", state);
        }
    }

    #[test]
    fn flow_run_duration_seconds_only() {
        let fr: FlowRun = serde_json::from_value(json!({
            "id": "a", "name": "r", "total_run_time": 45.0
        }))
        .unwrap();
        assert_eq!(fr.duration_str(), "45s");
    }

    #[test]
    fn flow_run_duration_with_minutes() {
        let fr: FlowRun = serde_json::from_value(json!({
            "id": "a", "name": "r", "total_run_time": 125.0
        }))
        .unwrap();
        assert_eq!(fr.duration_str(), "2m 05s");
    }

    #[test]
    fn flow_run_short_id() {
        let fr: FlowRun = serde_json::from_value(json!({
            "id": "171a3f55-e9a5-4100-a2dd-efe5c711f847", "name": "r"
        }))
        .unwrap();
        assert_eq!(fr.short_id(), "171a3f55");
    }

    #[test]
    fn flow_run_start_time_formatting() {
        let fr: FlowRun = serde_json::from_value(json!({
            "id": "a", "name": "r", "start_time": "2026-02-21T17:34:05.301Z"
        }))
        .unwrap();
        assert_eq!(fr.start_time_short(), "2026-02-21 17:34");
    }

    #[test]
    fn flow_run_start_time_none() {
        let fr: FlowRun = serde_json::from_value(json!({
            "id": "a", "name": "r", "start_time": null
        }))
        .unwrap();
        assert_eq!(fr.start_time_short(), "-");
    }

    #[test]
    fn log_entry_deserializes() {
        let entry: LogEntry = serde_json::from_value(json!({
            "level": 20, "message": "Flow started", "timestamp": "2026-02-21T17:34:05.301Z"
        }))
        .unwrap();
        assert_eq!(entry.level, 20);
        assert_eq!(entry.level_name(), "INFO");
    }

    #[test]
    fn deployment_deserializes_parameter_openapi_schema() {
        let d: Deployment = serde_json::from_value(json!({
            "id": "abc",
            "name": "d",
            "flow_name": "f",
            "parameter_openapi_schema": {
                "type": "object",
                "properties": {
                    "config": { "$ref": "#/definitions/FlowConfig" }
                },
                "definitions": {
                    "FlowConfig": {
                        "type": "object",
                        "properties": {
                            "dry_run": { "type": "boolean" }
                        }
                    }
                }
            }
        }))
        .unwrap();
        assert!(d.parameter_openapi_schema.is_some());
        let schema = d.parameter_openapi_schema.unwrap();
        assert!(schema.get("properties").is_some());
    }

    #[test]
    fn deployment_missing_schema_is_none() {
        let d: Deployment = serde_json::from_value(json!({
            "id": "abc", "name": "d", "flow_name": "f"
        }))
        .unwrap();
        assert!(d.parameter_openapi_schema.is_none());
    }
}