Skip to main content

spec_core/
lib.rs

1//! spec-core: Core library for parsing and generating Rust code from .unit.spec files
2//!
3//! This crate provides the core functionality for the spec toolchain:
4//! - Loading and parsing .unit.spec YAML files
5//! - Validating specs against the JSON Schema
6//! - Normalizing to internal representation (IR)
7//! - Generating readable Rust code
8
9pub const AUTHORED_SPEC_VERSION: &str = "0.3.0";
10
11pub mod export;
12pub mod generator;
13pub mod loader;
14pub mod normalizer;
15pub mod passport;
16pub mod pipeline;
17mod syntax;
18pub mod types;
19pub mod validator;
20
21use thiserror::Error;
22
23/// Error types for spec-core operations
24#[derive(Error, Debug)]
25pub enum SpecError {
26    #[error("IO error: {0}")]
27    Io(#[from] std::io::Error),
28
29    #[error("File is not valid UTF-8: {path}")]
30    InvalidUtf8 { path: String },
31
32    #[error("YAML parse error: {message}")]
33    YamlParse { message: String, path: String },
34
35    #[error("JSON error: {0}")]
36    Json(#[from] serde_json::Error),
37
38    #[error("Schema validation failed: {message}")]
39    SchemaValidation { message: String, path: String },
40
41    #[error("Semantic validation error: {message}")]
42    SemanticValidation { message: String, path: String },
43
44    #[error("ID segment '{segment}' is a Rust reserved keyword in '{id}' at {path}")]
45    RustKeyword {
46        segment: String,
47        id: String,
48        path: String,
49    },
50
51    #[error("Duplicate ID '{id}' in {file1} and {file2}")]
52    DuplicateId {
53        id: String,
54        file1: String,
55        file2: String,
56    },
57
58    #[error("Dep fn_name collision: '{dep1}' and '{dep2}' both resolve to '{fn_name}' at {path}")]
59    DepCollision {
60        dep1: String,
61        dep2: String,
62        fn_name: String,
63        path: String,
64    },
65
66    #[error("❌ dep '{dep}' not found in this spec set")]
67    MissingDep { dep: String, path: String },
68
69    #[error("❌ cycle detected: {}", cycle_path.join(" → "))]
70    CyclicDep {
71        cycle_path: Vec<String>,
72        path: String,
73    },
74
75    #[error(
76        "body.rust must not contain use statements; declare external imports via imports (and internal unit deps via deps) at {path}"
77    )]
78    UseStatementInBody { path: String },
79
80    #[error("body.rust failed to parse as a block: {message} at {path}")]
81    BodyRustMustBeBlock { message: String, path: String },
82
83    #[error(
84        "body.rust looks like a full function declaration — spec 0.3.0 expects only the function body block. \
85         Remove the `pub fn name(params) -> ReturnType` line and keep only the `{{ ... }}` block. \
86         See migration guide. at {path}"
87    )]
88    BodyRustLooksLikeFnDeclaration { path: String },
89
90    #[error("local_tests[{id}].expect is not a valid Rust expression: {message} at {path}")]
91    LocalTestExpectNotExpr {
92        id: String,
93        message: String,
94        path: String,
95    },
96
97    #[error("duplicate local_tests id '{id}' at {path}")]
98    DuplicateLocalTestId { id: String, path: String },
99
100    #[error("contract.{field} has invalid Rust type '{type_str}': {message} at {path}")]
101    ContractTypeInvalid {
102        field: String,
103        type_str: String,
104        message: String,
105        path: String,
106    },
107
108    #[error("contract.inputs key '{name}' is not a valid Rust identifier: {message} at {path}")]
109    ContractInputNameInvalid {
110        name: String,
111        message: String,
112        path: String,
113    },
114
115    #[error("Traversal error: {message} at {path}")]
116    Traversal { message: String, path: String },
117
118    #[error("Generator error: {message}")]
119    Generator { message: String },
120
121    #[error("Output directory error: {message}")]
122    OutputDir { message: String },
123
124    #[error("Missing .spec-generated marker in {path} - refusing to clean without marker")]
125    MissingMarker { path: String },
126}
127
128impl From<walkdir::Error> for SpecError {
129    fn from(err: walkdir::Error) -> Self {
130        SpecError::Io(std::io::Error::other(err))
131    }
132}
133
134/// Result type alias for spec-core operations
135pub type Result<T> = std::result::Result<T, SpecError>;
136
137#[derive(Error, Debug)]
138pub enum SpecWarning {
139    #[error("⚠ dep '{dep}' not found in this spec set")]
140    MissingDep { dep: String, path: String },
141
142    #[error("⚠ skipped symlink cycle at '{path}'; subtree was skipped")]
143    SymlinkCycleSkipped { path: String },
144
145    #[error(
146        "⚠ spec_version not set in {path} — add `spec_version: \"{version}\"` to suppress this warning"
147    )]
148    MissingSpecVersion { path: String, version: &'static str },
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_error_display() {
157        let err = SpecError::InvalidUtf8 {
158            path: "foo.unit.spec".to_string(),
159        };
160        assert_eq!(err.to_string(), "File is not valid UTF-8: foo.unit.spec");
161
162        let err = SpecError::RustKeyword {
163            segment: "type".to_string(),
164            id: "pricing/type".to_string(),
165            path: "test.unit.spec".to_string(),
166        };
167        assert_eq!(
168            err.to_string(),
169            "ID segment 'type' is a Rust reserved keyword in 'pricing/type' at test.unit.spec"
170        );
171    }
172}