Skip to main content

skiff_cli/
coerce.rs

1//! Value coercion and naming (`to_kebab`, JSON Schema → CLI values, `env:`/`file:` secrets).
2
3use 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
12/// Convert camelCase / snake_case names to kebab-case (Python `to_kebab`).
13pub 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
38/// Coerce a CLI string (or JSON value) according to a JSON Schema fragment.
39pub 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            // Schema-less fallback: try to parse JSON objects/arrays from strings
110            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
126/// Resolve `env:VAR` or `file:/path` secret values.
127///
128/// Literal values (no recognized prefix) are rejected: secrets must never be
129/// passed directly on argv, where they leak via `ps`, `/proc/<pid>/cmdline`,
130/// and shell history.
131pub 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
152/// Truncate JSON arrays to first N elements; pass through other values.
153pub 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}