Skip to main content

kowalski_core/
operator_input.rs

1//! Operator-facing form fields for horde runs (declared in agent frontmatter).
2
3use crate::error::KowalskiError;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7/// One field in a pre-run operator form (`[[inputs]]` in `agents/<step>.md`).
8#[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/// Form shown before starting a horde run (usually the first pipeline step).
25#[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
32/// Validate answers against a form spec; returns errors keyed by field id.
33pub 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
68/// Build a single prompt string from form answers (for horde run `source` / `prompt`).
69pub 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        // `crate_focus` has default "cli" and is unanswered → default is emitted.
161        assert!(prompt.contains("**Primary project shape:** cli"));
162    }
163}
164
165/// Default ingest-stage form for Rust / greenfield project hordes.
166pub 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}