1use crate::error::KowalskiError;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct OperatorInputField {
10 pub id: String,
11 #[serde(rename = "type")]
12 pub field_type: String,
13 pub label: String,
14 #[serde(default)]
15 pub required: bool,
16 #[serde(default)]
17 pub placeholder: Option<String>,
18 #[serde(default)]
19 pub options: Vec<String>,
20 #[serde(default)]
21 pub default: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct HordeRunFormSpec {
27 pub step: String,
28 pub display_name: Option<String>,
29 pub inputs: Vec<OperatorInputField>,
30}
31
32pub fn validate_form_answers(
34 form: &HordeRunFormSpec,
35 answers: &BTreeMap<String, String>,
36) -> Result<(), KowalskiError> {
37 let mut errs = Vec::new();
38 for field in &form.inputs {
39 let v = answers.get(&field.id).map(|s| s.trim()).unwrap_or("");
40 if field.required && v.is_empty() {
41 errs.push(format!("`{}` is required", field.label));
42 continue;
43 }
44 if v.is_empty() {
45 continue;
46 }
47 match field.field_type.as_str() {
48 "url" if !v.starts_with("http://") && !v.starts_with("https://") => {
49 errs.push(format!("`{}` must be a valid URL", field.label));
50 }
51 "choice" if !field.options.is_empty() && !field.options.iter().any(|o| o == v) => {
52 errs.push(format!(
53 "`{}` must be one of: {}",
54 field.label,
55 field.options.join(", ")
56 ));
57 }
58 _ => {}
59 }
60 }
61 if errs.is_empty() {
62 Ok(())
63 } else {
64 Err(KowalskiError::Validation(errs.join("; ")))
65 }
66}
67
68pub fn answers_to_prompt(form: &HordeRunFormSpec, answers: &BTreeMap<String, String>) -> String {
70 let mut lines = vec![format!(
71 "# Operator input ({})",
72 form.display_name.as_deref().unwrap_or(&form.step)
73 )];
74 for field in &form.inputs {
75 let v = answers
76 .get(&field.id)
77 .map(|s| s.trim())
78 .filter(|s| !s.is_empty())
79 .or(field.default.as_deref());
80 if let Some(val) = v {
81 lines.push(format!("**{}:** {}", field.label, val));
82 }
83 }
84 lines.join("\n\n")
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 fn sample_form() -> HordeRunFormSpec {
92 HordeRunFormSpec {
93 step: "ingest".into(),
94 display_name: Some("Project Input".into()),
95 inputs: default_ingest_form_fields(),
96 }
97 }
98
99 fn answers(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
100 pairs
101 .iter()
102 .map(|(k, v)| (k.to_string(), v.to_string()))
103 .collect()
104 }
105
106 #[test]
107 fn missing_required_field_is_rejected() {
108 let form = sample_form();
109 let err = validate_form_answers(&form, &answers(&[("project_name", "demo")]))
110 .unwrap_err()
111 .to_string();
112 assert!(err.contains("Goals and constraints"), "got: {err}");
113 }
114
115 #[test]
116 fn required_fields_present_passes() {
117 let form = sample_form();
118 let a = answers(&[("project_name", "demo"), ("project_goals", "cli tool")]);
119 assert!(validate_form_answers(&form, &a).is_ok());
120 }
121
122 #[test]
123 fn invalid_url_is_rejected() {
124 let form = sample_form();
125 let a = answers(&[
126 ("project_name", "demo"),
127 ("project_goals", "cli tool"),
128 ("repo_url", "not-a-url"),
129 ]);
130 assert!(validate_form_answers(&form, &a).is_err());
131 }
132
133 #[test]
134 fn choice_outside_options_is_rejected() {
135 let form = sample_form();
136 let a = answers(&[
137 ("project_name", "demo"),
138 ("project_goals", "cli tool"),
139 ("crate_focus", "mainframe"),
140 ]);
141 assert!(validate_form_answers(&form, &a).is_err());
142 }
143
144 #[test]
145 fn prompt_includes_answered_fields_and_skips_blanks() {
146 let form = sample_form();
147 let a = answers(&[("project_name", "demo"), ("project_goals", "cli tool")]);
148 let prompt = answers_to_prompt(&form, &a);
149 assert!(prompt.contains("# Operator input (Project Input)"));
150 assert!(prompt.contains("**Project name:** demo"));
151 assert!(prompt.contains("**Goals and constraints:** cli tool"));
152 assert!(!prompt.contains("Existing repository URL"));
153 }
154
155 #[test]
156 fn prompt_falls_back_to_field_default() {
157 let form = sample_form();
158 let a = answers(&[("project_name", "demo"), ("project_goals", "cli tool")]);
159 let prompt = answers_to_prompt(&form, &a);
160 assert!(prompt.contains("**Primary project shape:** cli"));
162 }
163}
164
165pub fn default_ingest_form_fields() -> Vec<OperatorInputField> {
167 vec![
168 OperatorInputField {
169 id: "project_name".into(),
170 field_type: "text".into(),
171 label: "Project name".into(),
172 required: true,
173 placeholder: Some("my-rust-service".into()),
174 options: vec![],
175 default: None,
176 },
177 OperatorInputField {
178 id: "project_goals".into(),
179 field_type: "textarea".into(),
180 label: "Goals and constraints".into(),
181 required: true,
182 placeholder: Some(
183 "e.g. CLI tool, async HTTP, SQLite, no cloud deps…".into(),
184 ),
185 options: vec![],
186 default: None,
187 },
188 OperatorInputField {
189 id: "repo_url".into(),
190 field_type: "url".into(),
191 label: "Existing repository URL (optional)".into(),
192 required: false,
193 placeholder: Some("https://github.com/org/repo".into()),
194 options: vec![],
195 default: None,
196 },
197 OperatorInputField {
198 id: "crate_focus".into(),
199 field_type: "choice".into(),
200 label: "Primary project shape".into(),
201 required: false,
202 placeholder: None,
203 options: vec![
204 "cli".into(),
205 "web-api".into(),
206 "library".into(),
207 "embedded".into(),
208 ],
209 default: Some("cli".into()),
210 },
211 ]
212}