1use std::path::Path;
5
6use serde_json::Value;
7use speccoder_core::CommandData;
8use speccoder_core::actions::{FeatureSource, PlanFrom, Runnable, Through};
9use speccoder_core::ask::{Proposal, ProposalFields};
10use speccoder_core::ladder::quoted;
11
12pub const KINDS: [&str; 5] = ["quick_change", "bug_fix", "nudge", "answer", "start_spec"];
14
15fn text(v: &Value, key: &str, max: usize) -> Option<String> {
16 v[key]
17 .as_str()
18 .map(str::trim)
19 .filter(|t| !t.is_empty())
20 .map(|t| t.chars().take(max).collect())
21}
22
23fn list(v: &Value, key: &str, max: usize) -> Vec<String> {
24 v[key]
25 .as_array()
26 .into_iter()
27 .flatten()
28 .filter_map(|x| x.as_str().map(str::trim).filter(|x| !x.is_empty()))
29 .take(max)
30 .map(|x| x.chars().take(1000).collect())
31 .collect()
32}
33
34#[must_use]
37pub fn validate(
38 root: &Path,
39 feature: Option<&str>,
40 conversation: &str,
41 arguments: &Value,
42) -> Proposal {
43 let kind = arguments["kind"].as_str().unwrap_or_default().to_owned();
44 let title = text(arguments, "title", 120).unwrap_or_else(|| kind.replace('_', " "));
45 let fields = ProposalFields {
46 target: text(arguments, "target", 40),
47 request: text(arguments, "request", 2000),
48 checks: list(arguments, "checks", 8),
49 note: text(arguments, "note", 2000),
50 answer: text(arguments, "answer", 4000),
51 description: text(arguments, "description", 4000),
52 symptom: text(arguments, "symptom", 2000),
53 expected: list(arguments, "expected", 8),
54 unchanged: list(arguments, "unchanged", 8),
55 };
56 let mut p = Proposal {
57 kind: kind.clone(),
58 title,
59 fields,
60 runnable: None,
61 input: None,
62 blocked: None,
63 command: String::new(),
64 conversation: Some(conversation.to_owned()),
65 turn: None,
66 };
67 let result = build(root, feature, &mut p);
68 if let Err(why) = result {
69 p.runnable = None;
70 p.blocked = Some(why);
71 }
72 p
73}
74
75#[must_use]
77pub fn revalidate(root: &Path, feature: Option<&str>, p: &Proposal) -> Proposal {
78 let mut p = p.clone();
79 p.blocked = None;
80 if let Err(why) = build(root, feature, &mut p) {
81 p.runnable = None;
82 p.blocked = Some(why);
83 }
84 p
85}
86
87fn asked(p: &Proposal) -> Option<String> {
90 p.conversation.as_ref().map(|c| match p.turn {
91 Some(t) => format!("{c}#{t}"),
92 None => c.clone(),
93 })
94}
95
96fn build(root: &Path, feature: Option<&str>, p: &mut Proposal) -> Result<(), String> {
97 let f = &p.fields;
98 match p.kind.as_str() {
99 "quick_change" => {
100 let request = f
101 .request
102 .clone()
103 .ok_or("a quick change needs its request")?;
104 let checks: String = f
105 .checks
106 .iter()
107 .map(|c| format!(" --check {}", quoted(c)))
108 .collect();
109 p.command = format!("speccoder do {}{checks}", quoted(&request));
110 p.runnable = Some(Runnable::QuickChange {
111 request,
112 checks: f.checks.clone(),
113 run: true,
114 asked: asked(p),
115 });
116 }
117 "bug_fix" => {
118 let symptom = f.symptom.clone().ok_or("a bug fix needs its symptom")?;
119 if f.expected.is_empty() {
120 return Err("a bug fix needs what should happen instead".to_owned());
121 }
122 let flags: String = f
123 .expected
124 .iter()
125 .map(|e| format!(" --expected {}", quoted(e)))
126 .chain(
127 f.unchanged
128 .iter()
129 .map(|u| format!(" --unchanged {}", quoted(u))),
130 )
131 .collect();
132 p.command = format!("speccoder bug {}{flags}", quoted(&symptom));
133 p.runnable = Some(Runnable::BugFix {
134 symptom,
135 expected: f.expected.clone(),
136 unchanged: f.unchanged.clone(),
137 issue: None,
138 tests: Vec::new(),
139 candidate: None,
140 slug: None,
141 run: true,
142 asked: asked(p),
143 });
144 }
145 "nudge" => {
146 let task = f.target.clone().ok_or("a nudge names its task")?;
147 let note = f.note.clone().ok_or("a nudge needs its note")?;
148 match task_state(root, feature, &task)?.as_str() {
149 "ready" | "blocked" => {}
150 "skipped" => return Err(format!("{task} is skipped")),
151 _ => return Err(format!("{task} is already ticked")),
152 }
153 p.command = format!("speccoder task nudge {task} --note {}", quoted(¬e));
154 p.runnable = Some(Runnable::Nudge { task });
155 p.input = Some(note);
156 }
157 "answer" => {
158 let question = f.target.clone().ok_or("an answer names its question")?;
159 let words = f.answer.clone().ok_or("an answer needs its words")?;
160 let report = crate::review::report(root, feature);
161 let Some(CommandData::Review(r)) = report.data() else {
162 return Err(report.message().to_owned());
163 };
164 if !r.questions.iter().any(|q| q.id == question) {
165 return Err(format!(
166 "{question} is not an open question of {}",
167 r.feature
168 ));
169 }
170 p.command = format!("speccoder review answer {question} {}", quoted(&words));
171 p.runnable = Some(Runnable::Review {
172 ids: vec![question],
173 verdict: "answer".to_owned(),
174 });
175 p.input = Some(words);
176 }
177 "start_spec" => {
178 let description = f
179 .description
180 .clone()
181 .ok_or("a new spec needs its description")?;
182 let creates = crate::format::at(root).planning().creates;
183 let Some(skill) = creates.first() else {
184 return Err("this spec format has no skill that creates a spec".to_owned());
185 };
186 p.command = format!(
187 "speccoder spec {skill} {} --through tasks",
188 quoted(&description)
189 );
190 p.runnable = Some(Runnable::PlanThrough {
191 from: PlanFrom::Create {
192 description,
193 source: FeatureSource::Description,
194 },
195 through: Through::Tasks,
196 });
197 }
198 other => {
199 return Err(format!(
200 "`{other}` is not a kind of proposal ({})",
201 KINDS.join(", ")
202 ));
203 }
204 }
205 Ok(())
206}
207
208fn task_state(root: &Path, feature: Option<&str>, task: &str) -> Result<String, String> {
209 let report = crate::status::report(root, feature);
210 let Some(CommandData::Report(r)) = report.data() else {
211 return Err(report.message().to_owned());
212 };
213 r.tasks
214 .iter()
215 .find(|t| t.id == task)
216 .map(|t| t.state.clone())
217 .ok_or_else(|| format!("no task {task} in {}", r.feature))
218}