tailtriage-cli 0.2.0

CLI for tailtriage artifact loading, diagnosis, and report generation
Documentation
use std::path::{Path, PathBuf};

use serde_json::Value;
use tailtriage_core::{Run, SCHEMA_VERSION};

const SUPPORTED_SCHEMA_VERSION: u64 = SCHEMA_VERSION;

/// A validated run artifact plus non-fatal loader warnings.
#[derive(Debug)]
pub struct LoadedArtifact {
    /// Parsed run artifact data used by analyzer and renderer flows.
    pub run: Run,
    /// Non-fatal loader findings that did not block loading.
    pub warnings: Vec<String>,
}

/// Errors returned when loading and validating run artifacts from disk.
#[derive(Debug)]
pub enum ArtifactLoadError {
    /// The file could not be read from disk.
    Read {
        /// Path that failed to read.
        path: PathBuf,
        /// Underlying I/O failure.
        source: std::io::Error,
    },
    /// JSON parsing or schema-shape decoding failed.
    Parse {
        /// Path that failed to parse.
        path: PathBuf,
        /// Human-readable parse or decoding error detail.
        message: String,
    },
    /// `schema_version` did not match this binary's supported version.
    UnsupportedSchemaVersion {
        /// Artifact path that contained the unsupported version.
        path: PathBuf,
        /// Found schema version in the artifact.
        found: u64,
        /// Supported schema version expected by this binary.
        supported: u64,
    },
    /// Required top-level `schema_version` key was missing.
    MissingSchemaVersion {
        /// Artifact path missing `schema_version`.
        path: PathBuf,
    },
    /// Top-level `schema_version` existed but was not an integer.
    InvalidSchemaVersionType {
        /// Artifact path with invalid `schema_version` type.
        path: PathBuf,
    },
    /// Additional validation rejected the artifact contents.
    Validation {
        /// Artifact path that failed validation.
        path: PathBuf,
        /// Validation failure detail.
        message: String,
    },
}

impl std::fmt::Display for ArtifactLoadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Read { path, source } => {
                write!(f, "failed to read run artifact '{}': {source}", path.display())
            }
            Self::Parse { path, message } => {
                write!(f, "failed to parse run artifact '{}': {message}", path.display())
            }
            Self::UnsupportedSchemaVersion {
                path,
                found,
                supported,
            } => write!(
                f,
                "unsupported run artifact schema_version={found} in '{}'; supported schema_version is {supported}. Re-generate the artifact with a compatible tailtriage version.",
                path.display()
            ),
            Self::MissingSchemaVersion { path } => write!(
                f,
                "invalid run artifact in '{}': missing required top-level schema_version.",
                path.display()
            ),
            Self::InvalidSchemaVersionType { path } => write!(
                f,
                "invalid run artifact in '{}': schema_version must be an integer.",
                path.display()
            ),
            Self::Validation { path, message } => write!(
                f,
                "invalid run artifact '{}': {message}",
                path.display()
            ),
        }
    }
}

impl std::error::Error for ArtifactLoadError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        if let Self::Read { source, .. } = self {
            Some(source)
        } else {
            None
        }
    }
}

/// Loads and validates a tailtriage run artifact from disk.
///
/// Validation is strict:
/// - top-level `schema_version` must exist and match this binary exactly
/// - the decoded artifact must include at least one request event
///
/// Loader warnings are non-fatal findings and are returned in
/// [`LoadedArtifact::warnings`].
///
/// # Errors
/// Returns [`ArtifactLoadError`] when the file cannot be read, the JSON is malformed,
/// the schema is unsupported, or required sections are missing.
pub fn load_run_artifact(path: &Path) -> Result<LoadedArtifact, ArtifactLoadError> {
    let input = std::fs::read_to_string(path).map_err(|source| ArtifactLoadError::Read {
        path: path.to_path_buf(),
        source,
    })?;

    let raw: Value = serde_json::from_str(&input).map_err(|err| ArtifactLoadError::Parse {
        path: path.to_path_buf(),
        message: parse_error_message(&err),
    })?;

    validate_schema_version(&raw, path)?;

    let run: Run = serde_json::from_value(raw).map_err(|err| ArtifactLoadError::Parse {
        path: path.to_path_buf(),
        message: format!(
            "JSON shape does not match the tailtriage run schema ({err}). Check for missing required fields such as metadata.run_id and requests[]."
        ),
    })?;

    validate_required_sections(&run, path)?;

    let mut warnings = run.metadata.lifecycle_warnings.clone();
    if run.metadata.unfinished_requests.count > 0 {
        warnings.push(format!(
            "artifact recorded {} unfinished request(s) at shutdown",
            run.metadata.unfinished_requests.count
        ));
    }

    Ok(LoadedArtifact { run, warnings })
}

fn validate_schema_version(raw: &Value, path: &Path) -> Result<(), ArtifactLoadError> {
    let Some(version) = raw.get("schema_version") else {
        return Err(ArtifactLoadError::MissingSchemaVersion {
            path: path.to_path_buf(),
        });
    };

    let Some(found) = version.as_u64() else {
        return Err(ArtifactLoadError::InvalidSchemaVersionType {
            path: path.to_path_buf(),
        });
    };

    if found != SUPPORTED_SCHEMA_VERSION {
        return Err(ArtifactLoadError::UnsupportedSchemaVersion {
            path: path.to_path_buf(),
            found,
            supported: SUPPORTED_SCHEMA_VERSION,
        });
    }

    Ok(())
}

fn validate_required_sections(run: &Run, path: &Path) -> Result<(), ArtifactLoadError> {
    if run.requests.is_empty() {
        return Err(ArtifactLoadError::Validation {
            path: path.to_path_buf(),
            message: "requests section is empty. Capture at least one request event before running triage.".to_string(),
        });
    }

    Ok(())
}

fn parse_error_message(error: &serde_json::Error) -> String {
    match error.classify() {
        serde_json::error::Category::Eof => {
            format!("JSON ended unexpectedly ({error}). The artifact may be truncated; re-run capture and ensure the file was fully written.")
        }
        serde_json::error::Category::Syntax => {
            format!("malformed JSON ({error}).")
        }
        serde_json::error::Category::Data => {
            format!("JSON data is incompatible with the expected run schema ({error}).")
        }
        serde_json::error::Category::Io => {
            format!("I/O error while parsing JSON ({error}).")
        }
    }
}

#[cfg(test)]
mod tests {
    use super::load_run_artifact;

    #[test]
    fn rejects_malformed_json() {
        let dir = tempfile::tempdir().expect("tempdir should build");
        let path = dir.path().join("bad.json");
        std::fs::write(&path, "{ not json").expect("fixture should write");

        let error = load_run_artifact(&path).expect_err("expected parse failure");
        let message = error.to_string();

        assert!(message.contains("failed to parse run artifact"));
        assert!(message.contains("malformed JSON"));
    }

    #[test]
    fn rejects_missing_required_fields() {
        let dir = tempfile::tempdir().expect("tempdir should build");
        let path = dir.path().join("missing-fields.json");
        std::fs::write(&path, r#"{"schema_version":1,"metadata":{},"requests":[],"stages":[],"queues":[],"inflight":[],"runtime_snapshots":[]}"#)
            .expect("fixture should write");

        let error = load_run_artifact(&path).expect_err("expected schema failure");
        let message = error.to_string();

        assert!(message.contains("JSON shape does not match"));
        assert!(message.contains("missing required fields"));
    }

    #[test]
    fn rejects_empty_requests_section() {
        let dir = tempfile::tempdir().expect("tempdir should build");
        let path = dir.path().join("empty-requests.json");
        std::fs::write(&path, valid_run_json_with_requests("[]")).expect("fixture should write");

        let error = load_run_artifact(&path).expect_err("expected validation failure");
        let message = error.to_string();

        assert!(message.contains("requests section is empty"));
    }

    #[test]
    fn rejects_missing_schema_version() {
        let dir = tempfile::tempdir().expect("tempdir should build");
        let path = dir.path().join("missing-version.json");
        std::fs::write(&path, valid_run_json_with_prefix("")).expect("fixture should write");

        let error = load_run_artifact(&path).expect_err("expected missing version failure");
        let message = error.to_string();

        assert!(message.contains("missing required top-level schema_version"));
    }

    #[test]
    fn rejects_non_integer_schema_versions() {
        let dir = tempfile::tempdir().expect("tempdir should build");
        let path = dir.path().join("string-version.json");
        std::fs::write(
            &path,
            valid_run_json_with_prefix("\"schema_version\": \"1\","),
        )
        .expect("fixture should write");

        let error = load_run_artifact(&path).expect_err("expected schema type failure");
        let message = error.to_string();

        assert!(message.contains("schema_version must be an integer"));
    }

    #[test]
    fn rejects_unsupported_schema_versions() {
        let dir = tempfile::tempdir().expect("tempdir should build");
        let path = dir.path().join("unsupported-version.json");
        std::fs::write(&path, valid_run_json_with_prefix("\"schema_version\": 99,"))
            .expect("fixture should write");

        let error = load_run_artifact(&path).expect_err("expected version incompatibility");
        let message = error.to_string();

        assert!(message.contains("unsupported run artifact"));
        assert!(message.contains("schema_version=99"));
    }

    #[test]
    fn flags_truncation_like_parse_errors() {
        let dir = tempfile::tempdir().expect("tempdir should build");
        let path = dir.path().join("truncated.json");
        std::fs::write(&path, "{\"metadata\": {\"run_id\": \"x\"").expect("fixture should write");

        let error = load_run_artifact(&path).expect_err("expected parse failure");
        let message = error.to_string();

        assert!(message.contains("may be truncated"));
    }

    #[test]
    fn surfaces_unfinished_request_warnings() {
        let dir = tempfile::tempdir().expect("tempdir should build");
        let path = dir.path().join("with-warning.json");
        std::fs::write(
            &path,
            r#"{"schema_version":1,"metadata":{"run_id":"r1","service_name":"svc","service_version":null,"started_at_unix_ms":1,"finished_at_unix_ms":2,"mode":"light","host":null,"pid":null,"lifecycle_warnings":["x"],"unfinished_requests":{"count":1,"sample":[{"request_id":"req1","route":"/"}]}},"requests":[{"request_id":"req1","route":"/","kind":null,"started_at_unix_ms":1,"finished_at_unix_ms":2,"latency_us":10,"outcome":"ok"}],"stages":[],"queues":[],"inflight":[],"runtime_snapshots":[]}"#,
        )
        .expect("fixture should write");

        let artifact = load_run_artifact(&path).expect("load should succeed");
        assert!(artifact
            .warnings
            .iter()
            .any(|warning| warning.contains("unfinished request")));
    }

    fn valid_run_json_with_requests(requests_json: &str) -> String {
        format!(
            "{{\"schema_version\":1,\"metadata\":{{\"run_id\":\"r1\",\"service_name\":\"svc\",\"service_version\":null,\"started_at_unix_ms\":1,\"finished_at_unix_ms\":2,\"mode\":\"light\",\"host\":null,\"pid\":null,\"lifecycle_warnings\":[],\"unfinished_requests\":{{\"count\":0,\"sample\":[]}}}},\"requests\":{requests_json},\"stages\":[],\"queues\":[],\"inflight\":[],\"runtime_snapshots\":[]}}"
        )
    }

    fn valid_run_json_with_prefix(prefix: &str) -> String {
        format!(
            "{{{prefix}\"metadata\":{{\"run_id\":\"r1\",\"service_name\":\"svc\",\"service_version\":null,\"started_at_unix_ms\":1,\"finished_at_unix_ms\":2,\"mode\":\"light\",\"host\":null,\"pid\":null,\"lifecycle_warnings\":[],\"unfinished_requests\":{{\"count\":0,\"sample\":[]}}}},\"requests\":[{{\"request_id\":\"req1\",\"route\":\"/\",\"kind\":null,\"started_at_unix_ms\":1,\"finished_at_unix_ms\":2,\"latency_us\":10,\"outcome\":\"ok\"}}],\"stages\":[],\"queues\":[],\"inflight\":[],\"runtime_snapshots\":[]}}"
        )
    }
}