Skip to main content

speccoder_runtime/ask/
proposal.rs

1//! A `propose` call made a person's action: code checks the target now and
2//! builds the `Runnable` the sheet confirms. Nothing here performs it.
3
4use 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
12/// The kinds a proposal may be.
13pub 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/// The proposal for `arguments`, suggested in `conversation`, checked
35/// against the project at `root`.
36#[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/// Re-checks `p` as the person edited it.
76#[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
87/// What a confirmed proposal records as its origin: the conversation, and
88/// the turn that suggested it when known (`c-1#3`).
89fn 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(&note));
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}