1use regex::Regex;
4use serde_json::{json, Value};
5use std::sync::LazyLock;
6
7use crate::error::{Error, Result};
8
9static KEBAB_RE: LazyLock<Regex> =
10 LazyLock::new(|| Regex::new(r"([a-z0-9])([A-Z])").expect("kebab regex"));
11
12pub fn to_kebab(name: &str) -> String {
14 KEBAB_RE
15 .replace_all(name, "$1-$2")
16 .replace('_', "-")
17 .to_lowercase()
18}
19
20fn coerce_item(value: &str, item_type: Option<&str>) -> Value {
21 match item_type {
22 Some("integer") => value
23 .parse::<i64>()
24 .map(Value::from)
25 .unwrap_or_else(|_| Value::String(value.to_string())),
26 Some("number") => value
27 .parse::<f64>()
28 .map(Value::from)
29 .unwrap_or_else(|_| Value::String(value.to_string())),
30 Some("boolean") => {
31 let v = value.to_lowercase();
32 Value::Bool(matches!(v.as_str(), "true" | "1" | "yes"))
33 }
34 _ => Value::String(value.to_string()),
35 }
36}
37
38pub fn coerce_value(value: Option<Value>, schema: &Value) -> Option<Value> {
40 let value = value?;
41 let t = schema.get("type").and_then(|x| x.as_str());
42
43 match t {
44 Some("array") => {
45 if value.is_array() {
46 return Some(value);
47 }
48 if let Some(s) = value.as_str() {
49 if let Ok(parsed) = serde_json::from_str::<Value>(s) {
50 if parsed.is_array() {
51 return Some(parsed);
52 }
53 }
54 let item_type = schema
55 .get("items")
56 .and_then(|i| i.get("type"))
57 .and_then(|x| x.as_str());
58 if s.contains(',') {
59 let items: Vec<Value> = s
60 .split(',')
61 .map(|part| coerce_item(part.trim(), item_type))
62 .collect();
63 return Some(Value::Array(items));
64 }
65 return Some(Value::Array(vec![coerce_item(s, item_type)]));
66 }
67 Some(value)
68 }
69 Some("object") => {
70 if let Some(s) = value.as_str() {
71 if let Ok(parsed) = serde_json::from_str::<Value>(s) {
72 return Some(parsed);
73 }
74 }
75 Some(value)
76 }
77 Some("boolean") => Some(Value::Bool(match &value {
78 Value::Bool(b) => *b,
79 Value::String(s) => {
80 let v = s.to_lowercase();
81 matches!(v.as_str(), "true" | "1" | "yes")
82 }
83 Value::Number(n) => n.as_i64().unwrap_or(0) != 0,
84 _ => !value.is_null(),
85 })),
86 Some("integer") => {
87 if let Some(n) = value.as_i64() {
88 return Some(json!(n));
89 }
90 if let Some(s) = value.as_str() {
91 if let Ok(n) = s.parse::<i64>() {
92 return Some(json!(n));
93 }
94 }
95 Some(value)
96 }
97 Some("number") => {
98 if let Some(n) = value.as_f64() {
99 return Some(json!(n));
100 }
101 if let Some(s) = value.as_str() {
102 if let Ok(n) = s.parse::<f64>() {
103 return Some(json!(n));
104 }
105 }
106 Some(value)
107 }
108 None => {
109 if let Some(s) = value.as_str() {
111 let stripped = s.trim();
112 if stripped.starts_with('{') || stripped.starts_with('[') {
113 if let Ok(parsed) = serde_json::from_str::<Value>(stripped) {
114 if parsed.is_object() || parsed.is_array() {
115 return Some(parsed);
116 }
117 }
118 }
119 }
120 Some(value)
121 }
122 _ => Some(value),
123 }
124}
125
126pub fn resolve_secret(value: &str) -> Result<String> {
132 if let Some(var) = value.strip_prefix("env:") {
133 return std::env::var(var)
134 .map_err(|_| Error::runtime(format!("environment variable {var:?} is not set")));
135 }
136 if let Some(path) = value.strip_prefix("file:") {
137 let path = std::path::Path::new(path);
138 if !path.exists() {
139 return Err(Error::runtime(format!(
140 "secret file not found: {}",
141 path.display()
142 )));
143 }
144 let text = std::fs::read_to_string(path)?;
145 return Ok(text.trim_end_matches('\n').to_string());
146 }
147 Err(Error::runtime(format!(
148 "refusing literal secret value {value:?}: use env:VAR or file:/path instead of putting secrets directly on the command line"
149 )))
150}
151
152pub fn apply_head(data: Value, n: usize) -> Value {
154 match data {
155 Value::Array(mut arr) => {
156 arr.truncate(n);
157 Value::Array(arr)
158 }
159 other => other,
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use serde_json::json;
167
168 #[test]
169 fn schema_types() {
170 use crate::model::{schema_type_to_python, ParamType};
171 assert_eq!(
172 schema_type_to_python(&json!({"type": "integer"})),
173 (ParamType::Integer, "")
174 );
175 assert_eq!(
176 schema_type_to_python(&json!({"type": "number"})),
177 (ParamType::Float, "")
178 );
179 assert_eq!(
180 schema_type_to_python(&json!({"type": "boolean"})),
181 (ParamType::Boolean, "")
182 );
183 assert_eq!(
184 schema_type_to_python(&json!({"type": "string"})),
185 (ParamType::String, "")
186 );
187 let (t, s) = schema_type_to_python(&json!({"type": "array"}));
188 assert_eq!(t, ParamType::String);
189 assert!(s.contains("JSON array"));
190 let (t, s) = schema_type_to_python(&json!({"type": "object"}));
191 assert_eq!(t, ParamType::String);
192 assert!(s.contains("JSON object"));
193 assert_eq!(schema_type_to_python(&json!({})), (ParamType::String, ""));
194 }
195
196 #[test]
197 #[allow(clippy::approx_constant)]
198 fn coerce_basics() {
199 assert!(coerce_value(None, &json!({"type": "string"})).is_none());
200 assert_eq!(
201 coerce_value(Some(json!("42")), &json!({"type": "integer"})),
202 Some(json!(42))
203 );
204 assert_eq!(
205 coerce_value(Some(json!("3.14")), &json!({"type": "number"})),
206 Some(json!(3.14))
207 );
208 assert_eq!(
209 coerce_value(Some(json!(true)), &json!({"type": "boolean"})),
210 Some(json!(true))
211 );
212 assert_eq!(
213 coerce_value(Some(json!("[1, 2, 3]")), &json!({"type": "array"})),
214 Some(json!([1, 2, 3]))
215 );
216 assert_eq!(
217 coerce_value(Some(json!("{\"a\": 1}")), &json!({"type": "object"})),
218 Some(json!({"a": 1}))
219 );
220 assert_eq!(
221 coerce_value(Some(json!("not json")), &json!({"type": "array"})),
222 Some(json!(["not json"]))
223 );
224 assert_eq!(
225 coerce_value(Some(json!("hello")), &json!({"type": "string"})),
226 Some(json!("hello"))
227 );
228 }
229
230 #[test]
231 fn coerce_arrays() {
232 assert_eq!(
233 coerce_value(Some(json!("TO_DO,IN_PROGRESS")), &json!({"type": "array"})),
234 Some(json!(["TO_DO", "IN_PROGRESS"]))
235 );
236 assert_eq!(
237 coerce_value(Some(json!("TO_DO")), &json!({"type": "array"})),
238 Some(json!(["TO_DO"]))
239 );
240 assert_eq!(
241 coerce_value(
242 Some(json!("[\"TO_DO\",\"IN_PROGRESS\"]")),
243 &json!({"type": "array"})
244 ),
245 Some(json!(["TO_DO", "IN_PROGRESS"]))
246 );
247 assert_eq!(
248 coerce_value(Some(json!(["a", "b"])), &json!({"type": "array"})),
249 Some(json!(["a", "b"]))
250 );
251 assert_eq!(
252 coerce_value(
253 Some(json!("1,2,3")),
254 &json!({"type": "array", "items": {"type": "number"}})
255 ),
256 Some(json!([1.0, 2.0, 3.0]))
257 );
258 assert_eq!(
259 coerce_value(
260 Some(json!("1,2,3")),
261 &json!({"type": "array", "items": {"type": "integer"}})
262 ),
263 Some(json!([1, 2, 3]))
264 );
265 assert_eq!(
266 coerce_value(
267 Some(json!("true,false")),
268 &json!({"type": "array", "items": {"type": "boolean"}})
269 ),
270 Some(json!([true, false]))
271 );
272 }
273
274 #[test]
275 fn coerce_schemaless() {
276 assert_eq!(
277 coerce_value(Some(json!("{\"key\": \"val\"}")), &json!({})),
278 Some(json!({"key": "val"}))
279 );
280 assert_eq!(
281 coerce_value(Some(json!("[1, 2, 3]")), &json!({})),
282 Some(json!([1, 2, 3]))
283 );
284 assert_eq!(
285 coerce_value(Some(json!("hello")), &json!({})),
286 Some(json!("hello"))
287 );
288 assert_eq!(
289 coerce_value(Some(json!("{not valid json")), &json!({})),
290 Some(json!("{not valid json"))
291 );
292 }
293
294 #[test]
295 fn kebab_cases() {
296 assert_eq!(to_kebab("findPetsByStatus"), "find-pets-by-status");
297 assert_eq!(to_kebab("list_items"), "list-items");
298 assert_eq!(to_kebab("list-items"), "list-items");
299 assert_eq!(to_kebab("getHTTPResponse"), "get-httpresponse");
300 }
301
302 #[test]
303 fn resolve_secret_env_prefix() {
304 std::env::set_var("SKIFF_TEST_RESOLVE_SECRET_ENV", "shh");
305 assert_eq!(
306 resolve_secret("env:SKIFF_TEST_RESOLVE_SECRET_ENV").unwrap(),
307 "shh"
308 );
309 std::env::remove_var("SKIFF_TEST_RESOLVE_SECRET_ENV");
310 }
311
312 #[test]
313 fn resolve_secret_env_missing() {
314 assert!(resolve_secret("env:SKIFF_TEST_RESOLVE_SECRET_MISSING").is_err());
315 }
316
317 #[test]
318 fn resolve_secret_file_prefix() {
319 let dir = std::env::temp_dir();
320 let path = dir.join(format!("skiff-resolve-secret-test-{}", std::process::id()));
321 std::fs::write(&path, "topsecret\n").unwrap();
322 let resolved = resolve_secret(&format!("file:{}", path.display())).unwrap();
323 assert_eq!(resolved, "topsecret");
324 std::fs::remove_file(&path).unwrap();
325 }
326
327 #[test]
328 fn resolve_secret_rejects_literal() {
329 let err = resolve_secret("literal-token-on-argv").unwrap_err();
330 let msg = err.to_string();
331 assert!(msg.contains("env:VAR"));
332 assert!(msg.contains("file:/path"));
333 }
334
335 #[test]
336 fn head_truncation() {
337 assert_eq!(apply_head(json!([1, 2, 3, 4, 5]), 3), json!([1, 2, 3]));
338 assert_eq!(apply_head(json!({"a": 1}), 2), json!({"a": 1}));
339 assert_eq!(apply_head(json!([]), 5), json!([]));
340 assert_eq!(apply_head(json!([1, 2]), 10), json!([1, 2]));
341 }
342}