greentic_flow/
error.rs

1use std::{fmt, path::PathBuf};
2use thiserror::Error;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct FlowErrorLocation {
6    pub path: Option<String>,
7    pub source_path: Option<PathBuf>,
8    pub line: Option<usize>,
9    pub col: Option<usize>,
10    pub json_pointer: Option<String>,
11}
12
13impl FlowErrorLocation {
14    pub fn new<P: Into<Option<String>>>(path: P, line: Option<usize>, col: Option<usize>) -> Self {
15        FlowErrorLocation {
16            path: path.into(),
17            line,
18            col,
19            source_path: None,
20            json_pointer: None,
21        }
22    }
23
24    pub fn at_path(path: impl Into<String>) -> Self {
25        FlowErrorLocation::new(Some(path.into()), None, None)
26    }
27
28    pub fn at_path_with_position(
29        path: impl Into<String>,
30        line: Option<usize>,
31        col: Option<usize>,
32    ) -> Self {
33        FlowErrorLocation::new(Some(path.into()), line, col)
34    }
35
36    pub fn with_source_path(mut self, source_path: Option<&std::path::Path>) -> Self {
37        self.source_path = source_path.map(|p| p.to_path_buf());
38        self
39    }
40
41    pub fn with_json_pointer(mut self, pointer: Option<impl Into<String>>) -> Self {
42        self.json_pointer = pointer.map(|p| p.into());
43        self
44    }
45
46    pub fn describe(&self) -> Option<String> {
47        if self.path.is_none() && self.line.is_none() && self.col.is_none() {
48            return None;
49        }
50        let mut parts = String::new();
51        if let Some(path) = &self.path {
52            parts.push_str(path);
53        }
54        match (self.line, self.col) {
55            (Some(line), Some(column)) => {
56                if !parts.is_empty() {
57                    parts.push(':');
58                }
59                parts.push_str(&format!("{line}:{column}"));
60            }
61            (Some(line), None) => {
62                if !parts.is_empty() {
63                    parts.push(':');
64                }
65                parts.push_str(&line.to_string());
66            }
67            (None, Some(column)) => {
68                if !parts.is_empty() {
69                    parts.push(':');
70                }
71                parts.push_str(&column.to_string());
72            }
73            _ => {}
74        }
75        Some(parts)
76    }
77}
78
79impl fmt::Display for FlowErrorLocation {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        if self.path.is_none() && self.line.is_none() && self.col.is_none() {
82            return Ok(());
83        }
84        write!(f, " at ")?;
85        if let Some(path) = &self.path {
86            write!(f, "{path}")?;
87            if self.line.is_some() || self.col.is_some() {
88                write!(f, ":")?;
89            }
90        }
91        match (self.line, self.col) {
92            (Some(line), Some(column)) => write!(f, "{line}:{column}")?,
93            (Some(line), None) => write!(f, "{line}")?,
94            (None, Some(column)) => write!(f, "{column}")?,
95            _ => {}
96        }
97        Ok(())
98    }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct SchemaErrorDetail {
103    pub message: String,
104    pub location: FlowErrorLocation,
105}
106
107#[derive(Debug, Error)]
108pub enum FlowError {
109    #[error("YAML parse error{location}: {message}")]
110    Yaml {
111        message: String,
112        location: FlowErrorLocation,
113    },
114    #[error("Schema validation failed{location}:\n{message}")]
115    Schema {
116        message: String,
117        details: Vec<SchemaErrorDetail>,
118        location: FlowErrorLocation,
119    },
120    #[error("Unknown flow type '{flow_type}'{location}")]
121    UnknownFlowType {
122        flow_type: String,
123        location: FlowErrorLocation,
124    },
125    #[error("Invalid identifier for {kind} '{value}'{location}: {detail}")]
126    InvalidIdentifier {
127        kind: &'static str,
128        value: String,
129        detail: String,
130        location: FlowErrorLocation,
131    },
132    #[error(
133        "Node '{node_id}' must contain exactly one component key like 'qa.process' plus optional 'routing'{location}"
134    )]
135    NodeComponentShape {
136        node_id: String,
137        location: FlowErrorLocation,
138    },
139    #[error(
140        "Invalid component key '{component}' in node '{node_id}' (expected namespace.adapter.operation or builtin like 'questions'/'template'){location}"
141    )]
142    BadComponentKey {
143        component: String,
144        node_id: String,
145        location: FlowErrorLocation,
146    },
147    #[error("Invalid routing block in node '{node_id}'{location}: {message}")]
148    Routing {
149        node_id: String,
150        message: String,
151        location: FlowErrorLocation,
152    },
153    #[error("Missing node '{target}' referenced in routing from '{node_id}'{location}")]
154    MissingNode {
155        target: String,
156        node_id: String,
157        location: FlowErrorLocation,
158    },
159    #[error("Internal error{location}: {message}")]
160    Internal {
161        message: String,
162        location: FlowErrorLocation,
163    },
164}
165
166#[allow(clippy::result_large_err)]
167pub type Result<T> = std::result::Result<T, FlowError>;