greentic-flow 0.5.2

Generic YGTC flow schema/loader/IR for self-describing component nodes.
Documentation
use std::{fmt, path::PathBuf};
use thiserror::Error;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FlowErrorLocation {
    pub path: Option<String>,
    pub source_path: Option<PathBuf>,
    pub line: Option<usize>,
    pub col: Option<usize>,
    pub json_pointer: Option<String>,
}

impl FlowErrorLocation {
    pub fn new<P: Into<Option<String>>>(path: P, line: Option<usize>, col: Option<usize>) -> Self {
        FlowErrorLocation {
            path: path.into(),
            line,
            col,
            source_path: None,
            json_pointer: None,
        }
    }

    pub fn at_path(path: impl Into<String>) -> Self {
        FlowErrorLocation::new(Some(path.into()), None, None)
    }

    pub fn at_path_with_position(
        path: impl Into<String>,
        line: Option<usize>,
        col: Option<usize>,
    ) -> Self {
        FlowErrorLocation::new(Some(path.into()), line, col)
    }

    pub fn with_source_path(mut self, source_path: Option<&std::path::Path>) -> Self {
        self.source_path = source_path.map(|p| p.to_path_buf());
        self
    }

    pub fn with_json_pointer(mut self, pointer: Option<impl Into<String>>) -> Self {
        self.json_pointer = pointer.map(|p| p.into());
        self
    }

    pub fn describe(&self) -> Option<String> {
        if self.path.is_none() && self.line.is_none() && self.col.is_none() {
            return None;
        }
        let mut parts = String::new();
        if let Some(path) = &self.path {
            parts.push_str(path);
        }
        match (self.line, self.col) {
            (Some(line), Some(column)) => {
                if !parts.is_empty() {
                    parts.push(':');
                }
                parts.push_str(&format!("{line}:{column}"));
            }
            (Some(line), None) => {
                if !parts.is_empty() {
                    parts.push(':');
                }
                parts.push_str(&line.to_string());
            }
            (None, Some(column)) => {
                if !parts.is_empty() {
                    parts.push(':');
                }
                parts.push_str(&column.to_string());
            }
            _ => {}
        }
        Some(parts)
    }
}

impl fmt::Display for FlowErrorLocation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.path.is_none() && self.line.is_none() && self.col.is_none() {
            return Ok(());
        }
        write!(f, " at ")?;
        if let Some(path) = &self.path {
            write!(f, "{path}")?;
            if self.line.is_some() || self.col.is_some() {
                write!(f, ":")?;
            }
        }
        match (self.line, self.col) {
            (Some(line), Some(column)) => write!(f, "{line}:{column}")?,
            (Some(line), None) => write!(f, "{line}")?,
            (None, Some(column)) => write!(f, "{column}")?,
            _ => {}
        }
        Ok(())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaErrorDetail {
    pub message: String,
    pub location: FlowErrorLocation,
}

#[derive(Debug, Error)]
pub enum FlowError {
    #[error("YAML parse error{location}: {message}")]
    Yaml {
        message: String,
        location: FlowErrorLocation,
    },
    #[error("Schema validation failed{location}:\n{message}")]
    Schema {
        message: String,
        details: Vec<SchemaErrorDetail>,
        location: FlowErrorLocation,
    },
    #[error("Unknown flow type '{flow_type}'{location}")]
    UnknownFlowType {
        flow_type: String,
        location: FlowErrorLocation,
    },
    #[error("Invalid identifier for {kind} '{value}'{location}: {detail}")]
    InvalidIdentifier {
        kind: &'static str,
        value: String,
        detail: String,
        location: FlowErrorLocation,
    },
    #[error(
        "Node '{node_id}' must contain exactly one component key like 'qa.process' plus optional 'routing'{location}"
    )]
    NodeComponentShape {
        node_id: String,
        location: FlowErrorLocation,
    },
    #[error(
        "Invalid component key '{component}' in node '{node_id}' (expected namespace.adapter.operation or builtin like 'questions'/'template'){location}"
    )]
    BadComponentKey {
        component: String,
        node_id: String,
        location: FlowErrorLocation,
    },
    #[error("Invalid routing block in node '{node_id}'{location}: {message}")]
    Routing {
        node_id: String,
        message: String,
        location: FlowErrorLocation,
    },
    #[error("Missing node '{target}' referenced in routing from '{node_id}'{location}")]
    MissingNode {
        target: String,
        node_id: String,
        location: FlowErrorLocation,
    },
    #[error("Internal error{location}: {message}")]
    Internal {
        message: String,
        location: FlowErrorLocation,
    },
}

#[allow(clippy::result_large_err)]
pub type Result<T> = std::result::Result<T, FlowError>;

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    #[test]
    fn flow_error_location_describes_paths_and_positions() {
        let loc = FlowErrorLocation::at_path_with_position("nodes.start", Some(3), Some(9))
            .with_source_path(Some(Path::new("flow.ygtc")))
            .with_json_pointer(Some("/nodes/start"));
        assert_eq!(loc.describe().as_deref(), Some("nodes.start:3:9"));
        assert_eq!(loc.source_path.as_deref(), Some(Path::new("flow.ygtc")));
        assert_eq!(loc.json_pointer.as_deref(), Some("/nodes/start"));
        assert_eq!(loc.to_string(), " at nodes.start:3:9");
    }

    #[test]
    fn flow_error_location_handles_empty_location() {
        let loc = FlowErrorLocation::new(None, None, None);
        assert_eq!(loc.describe(), None);
        assert_eq!(loc.to_string(), "");
    }
}