Skip to main content

specado/
lib.rs

1//! Public Specado API re-exported for downstream consumers.
2//!
3//! This crate wraps the internal `specado-core` implementation and exposes
4//! the same types and helpers while reserving the `specado` name on crates.io.
5
6#![allow(clippy::all)]
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::Path;
11
12pub use specado_core::*;
13pub use specado_core::{
14    Error, Message, MessageRole, PromptSpec, ResponseConfig, SamplingConfig, StrictMode, Tool,
15    ToolChoice,
16};
17
18/// Load a prompt specification (YAML or JSON) from disk.
19///
20/// This helper mirrors the CLI behaviour and provides a frictionless way to
21/// execute specs from files in Rust applications.
22pub fn load_prompt_from_path(
23    path: impl AsRef<Path>,
24) -> specado_core::Result<specado_core::PromptSpec> {
25    let path = path.as_ref();
26    let contents = std::fs::read_to_string(path).map_err(|err| {
27        specado_core::Error::Config(format!("Failed to read prompt {}: {}", path.display(), err))
28    })?;
29
30    let ext = path
31        .extension()
32        .and_then(|ext| ext.to_str())
33        .map(|ext| ext.to_ascii_lowercase())
34        .unwrap_or_default();
35
36    if ext == "json" {
37        serde_json::from_str(&contents).map_err(|err| {
38            specado_core::Error::Config(format!("Invalid JSON prompt {}: {}", path.display(), err))
39        })
40    } else {
41        match serde_yaml::from_str(&contents) {
42            Ok(spec) => Ok(spec),
43            Err(yaml_err) if ext == "json" => Err(specado_core::Error::Config(format!(
44                "Invalid JSON prompt {}: {}",
45                path.display(),
46                yaml_err
47            ))),
48            Err(yaml_err) => serde_json::from_str(&contents).map_err(|json_err| {
49                specado_core::Error::Config(format!(
50                    "Failed to parse prompt {} as YAML ({}) or JSON ({}).",
51                    path.display(),
52                    yaml_err,
53                    json_err
54                ))
55            }),
56        }
57    }
58}
59
60/// Structured options for building a prompt specification.
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct PromptBuilder {
64    pub messages: Vec<Message>,
65    #[serde(default)]
66    pub sampling: Option<SamplingConfig>,
67    #[serde(default, alias = "strict_mode")]
68    pub strict_mode: Option<StrictMode>,
69    #[serde(default)]
70    pub response: Option<ResponseConfig>,
71    #[serde(default)]
72    pub tools: Option<Vec<Tool>>,
73    #[serde(default, alias = "tool_choice")]
74    pub tool_choice: Option<ToolChoice>,
75    pub metadata: Option<HashMap<String, serde_json::Value>>,
76}
77
78/// Construct a prompt specification from the provided builder options.
79pub fn create_prompt(builder: PromptBuilder) -> PromptSpec {
80    PromptSpec {
81        version: "1".to_string(),
82        messages: builder.messages,
83        sampling: builder.sampling.unwrap_or_default(),
84        strict_mode: builder.strict_mode.unwrap_or_default(),
85        response: builder.response.unwrap_or_default(),
86        tools: builder.tools.unwrap_or_default(),
87        tool_choice: builder.tool_choice,
88        metadata: builder.metadata.unwrap_or_default(),
89    }
90}
91
92/// Convenience options for constructing a simple user/system prompt.
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct SimplePromptOptions {
96    pub message: Option<String>,
97    pub user: Option<String>,
98    pub system: Option<String>,
99    pub temperature: Option<f64>,
100    #[serde(default)]
101    pub sampling: Option<SamplingConfig>,
102    #[serde(alias = "strict_mode")]
103    pub strict_mode: Option<StrictMode>,
104}
105
106/// Create a minimal prompt specification from simple text inputs.
107pub fn simple_prompt(mut options: SimplePromptOptions) -> specado_core::Result<PromptSpec> {
108    let primary = options
109        .user
110        .take()
111        .or_else(|| options.message.take())
112        .unwrap_or_default();
113
114    if primary.trim().is_empty() {
115        return Err(Error::Config(
116            "simple prompt requires a non-empty user message".to_string(),
117        ));
118    }
119
120    let mut messages = Vec::new();
121    if let Some(system) = options.system.take() {
122        if system.trim().len() > 0 {
123            messages.push(Message {
124                role: MessageRole::System,
125                content: system,
126            });
127        }
128    }
129    messages.push(Message {
130        role: MessageRole::User,
131        content: primary,
132    });
133
134    let mut sampling = options.sampling.unwrap_or_default();
135    if let Some(temperature) = options.temperature {
136        sampling.temperature = Some(temperature);
137    }
138
139    Ok(create_prompt(PromptBuilder {
140        messages,
141        sampling: Some(sampling),
142        strict_mode: options.strict_mode,
143        ..PromptBuilder::default()
144    }))
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn simple_prompt_adds_user_and_system_messages() {
153        let prompt = simple_prompt(SimplePromptOptions {
154            message: Some("Hello assistant".into()),
155            system: Some("Behave helpfully".into()),
156            temperature: Some(0.3),
157            ..SimplePromptOptions::default()
158        })
159        .expect("prompt should construct");
160
161        assert_eq!(prompt.messages.len(), 2);
162        assert_eq!(prompt.messages[0].role, MessageRole::System);
163        assert_eq!(prompt.messages[1].role, MessageRole::User);
164        assert_eq!(prompt.sampling.temperature, Some(0.3));
165    }
166
167    #[test]
168    fn simple_prompt_requires_user_content() {
169        let err = simple_prompt(SimplePromptOptions::default()).unwrap_err();
170        assert!(matches!(err, Error::Config(_)));
171    }
172}