1use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8use speccoder_core::ladder::Origin;
9
10#[must_use]
12pub fn file(project_state: &Path) -> PathBuf {
13 project_state.join("ladder").join("origins.json")
14}
15
16#[must_use]
18pub fn origins(project_state: &Path) -> BTreeMap<String, Vec<Origin>> {
19 std::fs::read_to_string(file(project_state))
20 .ok()
21 .and_then(|t| serde_json::from_str(&t).ok())
22 .unwrap_or_default()
23}
24
25#[must_use]
27pub fn of(project_state: &Path, subject: &str) -> Vec<Origin> {
28 origins(project_state).remove(subject).unwrap_or_default()
29}
30
31pub fn record_origin(
37 project_state: &Path,
38 subject: &str,
39 origin: Vec<Origin>,
40) -> Result<(), String> {
41 let mut all = origins(project_state);
42 if all.contains_key(subject) {
43 return Err(format!(
44 "the origin of {subject} is recorded already; it is written once"
45 ));
46 }
47 all.insert(subject.to_owned(), origin);
48 let f = file(project_state);
49 if let Some(parent) = f.parent() {
50 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
51 }
52 let text = serde_json::to_string_pretty(&all).map_err(|e| e.to_string())?;
53 let tmp = f.with_extension("json.tmp");
54 std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
55 std::fs::rename(&tmp, &f).map_err(|e| format!("{}: {e}", f.display()))
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Offer {
61 pub unit: String,
63 pub task: String,
65 pub requirement: Option<(String, String)>,
67 pub signals: Vec<speccoder_core::ladder::Signal>,
69 pub bug: bool,
71}
72
73const PROMOTE_FILES: u64 = 8;
75
76fn promote_files(root: &Path) -> u64 {
77 crate::verify::find_config(root)
78 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
79 .and_then(|t| t.parse::<toml::Table>().ok())
80 .and_then(|t| t.get("ladder")?.get("promote_files")?.as_integer())
81 .and_then(|n| u64::try_from(n).ok())
82 .unwrap_or(PROMOTE_FILES)
83}
84
85fn judges(path: &str) -> bool {
87 path == crate::verify::config::FILE || path.starts_with(".github/workflows/")
88}
89
90fn packages(root: &Path, files: &[String]) -> u64 {
92 const MANIFESTS: [&str; 4] = ["Cargo.toml", "package.json", "pyproject.toml", "go.mod"];
93 let mut found: Vec<PathBuf> = files
94 .iter()
95 .map(|f| {
96 let mut dir = Path::new(f).parent().map(Path::to_path_buf);
97 while let Some(d) = dir {
98 if MANIFESTS.iter().any(|m| root.join(&d).join(m).is_file()) {
99 return d;
100 }
101 dir = d.parent().map(Path::to_path_buf);
102 }
103 PathBuf::new()
104 })
105 .collect();
106 found.sort();
107 found.dedup();
108 found.len() as u64
109}
110
111fn changed(root: &Path, commit: &str) -> Vec<String> {
112 crate::git::Git::at(root)
113 .run(&["diff", "--name-only", &format!("{commit}^"), commit])
114 .map(|b| {
115 String::from_utf8_lossy(&b)
116 .lines()
117 .map(str::to_owned)
118 .filter(|l| !l.is_empty())
119 .collect()
120 })
121 .unwrap_or_default()
122}
123
124fn declined_file(project_state: &Path) -> PathBuf {
126 project_state.join("ladder").join("declined.json")
127}
128
129fn declined(project_state: &Path) -> BTreeMap<String, Vec<String>> {
130 std::fs::read_to_string(declined_file(project_state))
131 .ok()
132 .and_then(|t| serde_json::from_str(&t).ok())
133 .unwrap_or_default()
134}
135
136pub fn decline(project_state: &Path, offer: &Offer) -> Result<(), String> {
143 let mut all = declined(project_state);
144 all.insert(
145 format!("{}/{}", offer.unit, offer.task),
146 offer.signals.iter().map(|s| s.id.clone()).collect(),
147 );
148 let f = declined_file(project_state);
149 if let Some(parent) = f.parent() {
150 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
151 }
152 let text = serde_json::to_string_pretty(&all).map_err(|e| e.to_string())?;
153 std::fs::write(&f, text).map_err(|e| format!("{}: {e}", f.display()))
154}
155
156#[must_use]
160pub fn offers(root: &Path, project_state: &Path, unit: &str) -> Vec<Offer> {
161 let format = crate::format::at(root);
162 let rung = crate::oracle::rung(format, root, unit);
163 if rung == speccoder_core::ladder::Rung::Feature {
164 return Vec::new();
165 }
166 let report = crate::status::report(root, Some(unit));
167 let Some(speccoder_core::CommandData::Report(r)) = report.data() else {
168 return Vec::new();
169 };
170 let review = crate::review::report(root, Some(unit));
171 let requirements: Vec<(String, String)> = match review.data() {
172 Some(speccoder_core::CommandData::Review(v)) => v
173 .requirements
174 .iter()
175 .map(|q| (q.id.clone(), q.text.clone()))
176 .collect(),
177 _ => Vec::new(),
178 };
179 let saved = crate::attempts::list(root, unit);
180 let promoted = origins(project_state);
181 let declined = declined(project_state);
182 let limit = promote_files(root);
183 let mut out = Vec::new();
184 for t in &r.tasks {
185 let attempts: Vec<&crate::attempts::Saved> =
186 saved.iter().filter(|a| a.task == t.id).collect();
187 let Some(commit) = t
188 .commit
189 .clone()
190 .or_else(|| attempts.last().map(|a| a.commit.clone()))
191 else {
192 continue;
193 };
194 if promoted.values().flatten().any(|o| {
195 matches!(o, Origin::PromotedFrom { from_unit, task, .. } if from_unit == unit && *task == t.id)
196 }) {
197 continue;
198 }
199 let files: Vec<String> = changed(root, &commit)
201 .into_iter()
202 .filter(|f| !Path::new(f).components().any(|c| c.as_os_str() == unit))
203 .collect();
204 let requirement = requirements
205 .iter()
206 .find(|(id, _)| {
207 t.title
208 .split_whitespace()
209 .any(|w| w.trim_matches(|c: char| !c.is_alphanumeric() && c != '-') == id)
210 })
211 .cloned();
212 let ambiguity = requirement.as_ref().map_or(0, |(_, text)| {
213 let flagged = crate::shape::findings("requirement", text)
214 .iter()
215 .filter(|f| matches!(f.rule.as_str(), "vague" | "escape_clause" | "open_ended"))
216 .count();
217 (flagged + text.matches("[NEEDS CLARIFICATION").count()) as u64
218 });
219 let input = speccoder_core::ladder::SignalInput {
220 files_changed: files.len() as u64,
221 packages_changed: packages(root, &files),
222 red_causes: attempts
223 .iter()
224 .filter(|a| {
225 !matches!(
226 a.outcome.as_str(),
227 "blocked" | "interrupted" | "awaiting_approval"
228 )
229 })
230 .map(|a| {
231 a.reason
232 .lines()
233 .next()
234 .unwrap_or_default()
235 .trim()
236 .to_owned()
237 })
238 .collect(),
239 ambiguity_markers: ambiguity,
240 judge_paths: files.iter().filter(|f| judges(f)).cloned().collect(),
241 promote_files: limit,
242 };
243 let signals = speccoder_core::ladder::signals(&input);
244 let before = declined.get(&format!("{unit}/{}", t.id));
245 if signals.is_empty() || before.is_some_and(|b| signals.iter().all(|s| b.contains(&s.id))) {
246 continue;
247 }
248 out.push(Offer {
249 unit: unit.to_owned(),
250 task: t.id.clone(),
251 requirement,
252 signals,
253 bug: rung == speccoder_core::ladder::Rung::Bug,
254 });
255 }
256 out
257}
258
259#[must_use]
262pub fn suggested_by(project_state: &Path, offer: &Offer) -> Vec<String> {
263 let requirement = offer.requirement.as_ref().map_or("", |(id, _)| id.as_str());
264 of(
265 project_state,
266 &speccoder_core::ladder::subject(&offer.unit, requirement),
267 )
268 .into_iter()
269 .filter_map(|x| match x {
270 Origin::Asked { conversation, .. } => Some(conversation),
271 _ => None,
272 })
273 .collect()
274}
275
276#[must_use]
279pub fn promotion(offer: &Offer, conversations: Vec<String>) -> speccoder_core::actions::Runnable {
280 use speccoder_core::actions::{FeatureSource, PlanFrom, Runnable, Through};
281 Runnable::PlanThrough {
282 from: PlanFrom::Create {
283 description: offer
284 .requirement
285 .clone()
286 .map(|(_, w)| w)
287 .unwrap_or_default(),
288 source: FeatureSource::Promotion {
289 task: format!("{}/{}", offer.unit, offer.task),
290 conversations,
291 },
292 },
293 through: Through::Tasks,
294 }
295}
296
297#[must_use]
299pub fn offer_for(root: &Path, project_state: &Path, task: &str) -> Option<Offer> {
300 let (unit, id) = task.rsplit_once('/')?;
301 offers(root, project_state, unit)
302 .into_iter()
303 .find(|o| o.task == id)
304}
305
306#[must_use]
308pub fn promoted_to(project_state: &Path, task: &str) -> Option<String> {
309 let (unit, id) = task.rsplit_once('/')?;
310 origins(project_state).into_iter().find_map(|(to, o)| {
311 o.iter()
312 .any(|o| matches!(o, Origin::PromotedFrom { from_unit, task, .. } if from_unit == unit && task == id))
313 .then_some(to)
314 })
315}
316
317#[must_use]
320pub fn decline_task(root: &Path, state: &Path, task: &str) -> speccoder_core::CommandResult {
321 use speccoder_core::{CommandResult, CommandStatus};
322 let plane = crate::worktree::project_state_dir(state, root);
323 let Some((unit, id)) = task.rsplit_once('/') else {
324 return CommandResult::new(
325 "promote",
326 CommandStatus::UsageError,
327 "name the task as <unit>/<task>",
328 );
329 };
330 let Some(offer) = offers(root, &plane, unit)
331 .into_iter()
332 .find(|o| o.task == id)
333 else {
334 return CommandResult::new(
335 "promote",
336 CommandStatus::NotFound,
337 &format!("no promotion is offered for {task}"),
338 );
339 };
340 match decline(&plane, &offer) {
341 Ok(()) => CommandResult::ok(
342 "promote",
343 &format!(
344 "not now: {task} stays a {}; offered again only when another signal holds",
345 if offer.bug { "bug fix" } else { "quick change" }
346 ),
347 ),
348 Err(e) => CommandResult::new("promote", CommandStatus::Error, &e),
349 }
350}
351
352#[must_use]
355pub fn promotion_input(
356 root: &Path,
357 state: &Path,
358 description: &str,
359 conversations: &[String],
360) -> String {
361 let mut out = description.trim().to_owned();
362 for id in conversations {
363 if let Some((words, _)) = crate::ask::words(root, state, id) {
364 out.push_str(&format!(
365 "\n\n<context kind=\"conversation\" id=\"{id}\">\nAn Ask conversation that led to this change; the person chose to carry it.\n\n{words}</context>"
366 ));
367 }
368 }
369 out
370}
371
372pub fn promoted(
375 root: &Path,
376 state: &Path,
377 made: &speccoder_core::CommandResult,
378 task: &str,
379 conversations: &[String],
380 via: speccoder_core::actions::Via,
381 progress: Option<&std::sync::Arc<dyn crate::progress::Progress>>,
382) {
383 let Some(speccoder_core::CommandData::PlanningChain(r)) = made.data() else {
384 return;
385 };
386 let (Some(unit), Some((from_unit, id))) = (r.unit.as_deref(), task.rsplit_once('/')) else {
387 return;
388 };
389 let plane = crate::worktree::project_state_dir(state, root);
390 let offer = offers(root, &plane, from_unit)
391 .into_iter()
392 .find(|o| o.task == id);
393 let (requirement, words) = offer
394 .as_ref()
395 .and_then(|o| o.requirement.clone())
396 .unwrap_or_default();
397 let carried: Vec<(String, String)> = conversations
398 .iter()
399 .filter_map(|c| crate::ask::words(root, state, c).map(|(_, d)| (c.clone(), d)))
400 .collect();
401 let signals: Vec<String> = offer
402 .map(|o| o.signals.into_iter().map(|s| s.id).collect())
403 .unwrap_or_default();
404 let requirement_sha256 = crate::integrity::sha256_hex(words.as_bytes());
405 let by = crate::approvals::who_in(root);
406 let written = record_origin(
407 &plane,
408 unit,
409 vec![Origin::PromotedFrom {
410 from_unit: from_unit.to_owned(),
411 requirement: requirement.clone(),
412 task: id.to_owned(),
413 requirement_sha256: requirement_sha256.clone(),
414 conversations: carried.clone(),
415 signals: signals.clone(),
416 by: by.clone(),
417 via,
418 at: crate::bug::confirm::now(),
419 }],
420 );
421 if let (Ok(()), Some(p)) = (written, progress) {
422 p.event(&crate::progress::Event::UnitPromoted {
423 unit: unit.to_owned(),
424 from_unit: from_unit.to_owned(),
425 requirement,
426 task: id.to_owned(),
427 requirement_sha256,
428 conversation_sha256: carried.into_iter().map(|(_, d)| d).collect(),
429 signals,
430 by,
431 via: via.as_str().to_owned(),
432 });
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn an_origin_is_written_once() {
442 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
443 let o = vec![Origin::Issue {
444 number: 7,
445 repo: None,
446 context_sha256: None,
447 }];
448 assert!(record_origin(state.path(), "007-bugfixes/FR-001", o.clone()).is_ok());
449 assert!(record_origin(state.path(), "007-bugfixes/FR-001", o.clone()).is_err());
450 assert!(record_origin(state.path(), "007-bugfixes/FR-002", o.clone()).is_ok());
451 assert_eq!(of(state.path(), "007-bugfixes/FR-001"), o);
452 }
453}