1use std::path::Path;
8use std::sync::Arc;
9
10use agentplane::model::ModelProvider;
11use serde_json::Value;
12use speccoder_core::shape::{Assumption, check};
13use speccoder_core::{CommandData, CommandResult, CommandStatus, ShapeReport};
14
15use crate::substrate;
16
17pub const FIELDS: [&str; 8] = [
21 "requirement",
22 "feature",
23 "quick",
24 "bug_symptom",
25 "bug_expected",
26 "bug_unchanged",
27 "answer",
28 "note",
29];
30
31#[must_use]
33pub fn is_requirement(field: &str) -> bool {
34 matches!(field, "requirement" | "feature")
35}
36
37#[must_use]
40pub fn findings(field: &str, text: &str) -> Vec<speccoder_core::shape::Finding> {
41 let text: String = text
42 .chars()
43 .take(substrate::shape::MAX_WORDS_CHARS)
44 .collect();
45 check(&text, is_requirement(field))
46}
47
48#[must_use]
53pub fn judge(field: &str, original: &str, proposal: &str, assumptions: &[Assumption]) -> Value {
54 let cap = |t: &str| -> String { t.chars().take(substrate::shape::MAX_WORDS_CHARS).collect() };
55 let (original, proposal) = (cap(original), cap(proposal));
56 let (original, proposal) = (original.as_str(), proposal.as_str());
57 let diff = speccoder_core::shape::diff(original, proposal);
58 serde_json::json!({
59 "unexplained": speccoder_core::shape::unexplained(&diff, assumptions),
60 "coverage": speccoder_core::shape::coverage(original, proposal),
61 "findings": findings(field, proposal),
62 "diff": diff,
63 })
64}
65
66fn refuse(status: CommandStatus, code: &str, message: &str) -> CommandResult {
67 CommandResult::new("shape", status, message).with_code(code)
68}
69
70#[must_use]
73pub fn shape(path: &Path, field: &str, text: &str, state: Option<&Path>) -> CommandResult {
74 let Some(state) = state
75 .map(Path::to_path_buf)
76 .or_else(crate::worktree::state_dir)
77 else {
78 return refuse(
79 CommandStatus::ConfigError,
80 "NO-STATE-DIR",
81 "no state directory (set SPECCODER_STATE_DIR or HOME)",
82 );
83 };
84 let config = crate::verify::find_config(path)
85 .and_then(|root| std::fs::read_to_string(root.join(crate::verify::config::FILE)).ok())
86 .unwrap_or_default();
87 let model = match substrate::model_config::parse(&config) {
88 Ok(m) => m,
89 Err(e) => {
90 return refuse(
91 CommandStatus::ConfigError,
92 "CONFIG",
93 &format!("speccoder.toml: {e}"),
94 );
95 }
96 };
97 let Some(model) = model else {
98 return refuse(
99 CommandStatus::ConfigError,
100 "CONFIG",
101 "Shape needs a model ([model] in speccoder.toml); the writing check works without one",
102 );
103 };
104 match substrate::model_config::open_driver(&model) {
105 Ok((provider, _)) => shape_with(path, field, text, &state, &model, provider),
106 Err(e) => substrate::model_config::refused("shape", &e),
107 }
108}
109
110#[must_use]
112pub fn shape_with(
113 path: &Path,
114 field: &str,
115 text: &str,
116 state: &Path,
117 model: &substrate::model_config::ModelConfig,
118 provider: Arc<dyn ModelProvider>,
119) -> CommandResult {
120 if !FIELDS.contains(&field) {
121 return refuse(
122 CommandStatus::UsageError,
123 "USAGE",
124 &format!(
125 "no such field to shape: {field} (one of {})",
126 FIELDS.join(", ")
127 ),
128 );
129 }
130 if !text.chars().any(char::is_alphanumeric) {
131 return refuse(
132 CommandStatus::UsageError,
133 "USAGE",
134 "write a few words first — any words",
135 );
136 }
137 let (original, cut) = match text.char_indices().nth(substrate::shape::MAX_WORDS_CHARS) {
138 Some((i, _)) => (text[..i].to_owned(), true),
139 None => (text.to_owned(), false),
140 };
141 let adapter = crate::format::at(path);
142 let format = adapter.kind().name();
143 let sample = sample(path, adapter);
144 let Some(project) = adapter.detect(path) else {
145 return refuse(
146 CommandStatus::NotAProject,
147 "NO-PROJECT",
148 "not a spec project here: Shape writes in the project's spec format",
149 );
150 };
151 let plane_dir = crate::worktree::project_state_dir(state, &project).join("shape");
152 let rt = match crate::pipeline::tokio_runtime() {
153 Ok(rt) => rt,
154 Err(e) => return refuse(CommandStatus::Error, "ERROR", &e),
155 };
156 let outcome = rt.block_on(async {
157 let plane = substrate::Plane::open_with(
158 &plane_dir,
159 model,
160 provider,
161 None,
162 substrate::Hooks::default(),
163 )?;
164 plane
165 .shape(substrate::shape::input(field, format, &sample, &original))
166 .await
167 });
168 let outcome = match outcome {
169 Ok(o) => o,
170 Err(e) => return refuse(CommandStatus::RunFailed, "RUN-FAILED", &e.0),
171 };
172 let output = outcome
173 .output
174 .as_ref()
175 .map(|o| o.peek().clone())
176 .unwrap_or_default();
177 let answer = &output["answer"];
178 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
179 let status = format!("{:?}", outcome.status);
180 let why = substrate::provider_refusal(&status).map_or_else(
181 || substrate::why_not(&outcome.status),
182 |r| format!("the model provider refused the request: {r}"),
183 );
184 return refuse(
185 CommandStatus::RunFailed,
186 "RUN-FAILED",
187 &format!("Shape got no usable proposal: {why} — your words are unchanged"),
188 );
189 }
190 let report = report(
191 field,
192 format,
193 &original,
194 cut,
195 answer,
196 &output["usage"],
197 &outcome.run_id.to_string(),
198 );
199 let message = summary(&report);
200 CommandResult::new("shape", CommandStatus::Ok, &message)
201 .with_data(CommandData::Shape(Box::new(report)))
202}
203
204#[must_use]
208pub fn report(
209 field: &str,
210 format: &str,
211 original: &str,
212 cut: bool,
213 answer: &Value,
214 usage: &Value,
215 run_id: &str,
216) -> ShapeReport {
217 let s = |v: &Value| v.as_str().unwrap_or_default().trim().to_owned();
218 let proposal = s(&answer["proposal"]);
219 let back = Some(s(&answer["back_translation"])).filter(|b| !b.is_empty());
220 let declared: Vec<Assumption> = answer["assumptions"]
221 .as_array()
222 .into_iter()
223 .flatten()
224 .filter_map(|a| serde_json::from_value(a.clone()).ok())
225 .filter(|a: &Assumption| !a.words.trim().is_empty())
226 .collect();
227 let (assumptions, open): (Vec<Assumption>, Vec<Assumption>) = declared
231 .into_iter()
232 .map(|mut a| {
233 a.words = a.words.trim().to_owned();
234 a
235 })
236 .partition(|a| proposal.contains(&a.words));
237 let mut questions: Vec<String> = Vec::new();
238 let asked = answer["questions"]
239 .as_array()
240 .into_iter()
241 .flatten()
242 .filter_map(|q| q.as_str().map(|q| q.trim().to_owned()));
243 for q in asked.chain(open.into_iter().map(|a| a.question)) {
244 if !q.is_empty() && !questions.contains(&q) {
245 questions.push(q);
246 }
247 }
248 let unexplained = speccoder_core::shape::unexplained(
252 &speccoder_core::shape::diff(original, &proposal),
253 &assumptions,
254 );
255 let against = back.as_deref().unwrap_or(&proposal);
257 let diff = speccoder_core::shape::diff(original, against);
258 ShapeReport {
259 field: field.to_owned(),
260 format: format.to_owned(),
261 original: original.to_owned(),
262 cut,
263 coverage: speccoder_core::shape::coverage(original, against),
264 findings: findings(field, &proposal),
265 findings_before: findings(field, original),
266 language: back.as_ref().map(|_| s(&answer["language"])),
267 back_translation: back,
268 diff,
269 unexplained,
270 questions,
271 assumptions,
272 proposal,
273 run_id: (!run_id.is_empty()).then(|| run_id.to_owned()),
274 usage: (!usage.is_null()).then(|| usage.clone()),
275 }
276}
277
278fn summary(r: &ShapeReport) -> String {
280 let n = |count: usize, one: &str, many: &str| {
281 format!("{count} {}", if count == 1 { one } else { many })
282 };
283 let mut parts = Vec::new();
284 if !r.assumptions.is_empty() {
285 parts.push(n(r.assumptions.len(), "assumption", "assumptions"));
286 }
287 if !r.unexplained.is_empty() {
288 parts.push(n(
289 r.unexplained.len(),
290 "unexplained addition",
291 "unexplained additions",
292 ));
293 }
294 let dropped = r.coverage.iter().filter(|c| c.dropped).count();
295 if dropped > 0 {
296 parts.push(n(dropped, "sentence dropped", "sentences dropped"));
297 }
298 if !r.questions.is_empty() {
299 parts.push(n(r.questions.len(), "open question", "open questions"));
300 }
301 if parts.is_empty() {
302 return "shaped: nothing was added without saying so — read it, then use it".to_owned();
303 }
304 format!(
305 "shaped: {} — decide each before you use it",
306 parts.join(", ")
307 )
308}
309
310fn sample(path: &Path, adapter: &dyn crate::format::Adapter) -> String {
313 let text = adapter
314 .inspect(path, None)
315 .and_then(|snap| {
316 let root = Path::new(&snap.root).to_path_buf();
317 let dir = snap
318 .feature
319 .as_ref()
320 .map(|f| f.feature.dir.clone())
321 .or_else(|| snap.features.last().map(|f| f.dir.clone()))?;
322 let [rules, spec, _, _] = adapter.convergence_texts(&root, &root.join(dir));
323 Some(if spec.trim().is_empty() { rules } else { spec })
324 })
325 .unwrap_or_default();
326 let cut: String = text.chars().take(1500).collect();
327 if cut.trim().is_empty() {
328 "(no spec yet: write in the person's language, in plain English if unsure)".to_owned()
329 } else {
330 cut
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use serde_json::json;
338
339 #[test]
340 fn code_decides_what_the_proposal_added_dropped_and_assumed() {
341 let answer = json!({
342 "proposal": "The export MUST write every row as CSV within 2 seconds for up to 10 000 rows.",
343 "assumptions": [{ "words": "up to 10 000 rows", "from": "big tables", "question": "How many rows at most?" }],
344 "questions": [],
345 "language": "en",
346 "back_translation": "",
347 });
348 let original = "export every row as csv fast. it must never delete the source file.";
349 let r = report(
350 "requirement",
351 "speckit",
352 original,
353 false,
354 &answer,
355 &Value::Null,
356 "run_1",
357 );
358 assert_eq!(r.unexplained, ["2 seconds"]);
359 assert!(r.coverage[1].dropped, "{:?}", r.coverage);
360 assert!(r.findings_before.iter().any(|f| f.words == "fast"));
361 assert!(r.findings.iter().all(|f| f.words != "fast"));
362 assert_eq!(r.language, None);
363 assert_eq!(
364 summary(&r),
365 "shaped: 1 assumption, 1 unexplained addition, 1 sentence dropped — decide each before you use it"
366 );
367 }
368
369 #[test]
370 fn in_another_language_the_diff_is_against_the_back_translation() {
371 let answer = json!({
372 "proposal": "The export MUST write CSV.",
373 "assumptions": [],
374 "questions": [],
375 "language": "de",
376 "back_translation": "Der Export MUSS CSV schreiben.",
377 });
378 let r = report(
379 "requirement",
380 "speckit",
381 "Der Export soll CSV schreiben",
382 false,
383 &answer,
384 &Value::Null,
385 "",
386 );
387 assert_eq!(r.language.as_deref(), Some("de"));
388 let added: Vec<&str> = r
389 .diff
390 .proposal
391 .iter()
392 .filter(|s| s.origin == speccoder_core::shape::Origin::Added)
393 .map(|s| s.text.trim())
394 .collect();
395 assert_eq!(added, ["MUSS"]);
396 let sly = json!({
399 "proposal": "The export MUST write CSV and delete the source after 3 days.",
400 "assumptions": [], "questions": ["Which?", "Which?"], "language": "de",
401 "back_translation": "Der Export MUSS CSV schreiben.",
402 });
403 let r = report(
404 "requirement",
405 "speckit",
406 "Der Export soll CSV schreiben",
407 false,
408 &sly,
409 &Value::Null,
410 "",
411 );
412 assert_eq!(r.unexplained, ["3 days"]);
413 assert_eq!(r.questions, ["Which?"]);
414 }
415}