1use std::path::{Path, PathBuf};
5
6use speccoder_core::actions::Via;
7use speccoder_core::new_project::{
8 NewChoices, PlanStepResult, PlanStepState, ProjectMode, ProjectPlan, StepAccess,
9};
10use speccoder_core::{CommandData, CommandResult, CommandStatus};
11
12use super::{
13 Before, COMMAND, Creation, Kind, Spec, entries_of, format_kind, git_digest, ignored, now,
14 path_digest, plan_with, record_dir, refused_result, report, shown, snapshot, steps, undo_offer,
15 write_creation,
16};
17use crate::RunOptions;
18use crate::git::Git;
19
20struct Lock(PathBuf);
22
23impl Lock {
24 fn take(state: &Path, target: &Path) -> Result<Self, String> {
25 let dir = record_dir(state, target);
26 std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
27 let file = dir.join("lock");
28 let mine = std::process::id().to_string();
29 for _ in 0..2 {
30 match std::fs::OpenOptions::new()
31 .write(true)
32 .create_new(true)
33 .open(&file)
34 {
35 Ok(mut f) => {
36 use std::io::Write as _;
37 let _ = f.write_all(mine.as_bytes());
38 return Ok(Self(file));
39 }
40 Err(_) => {
41 let holder = std::fs::read_to_string(&file).unwrap_or_default();
42 let alive = holder
43 .trim()
44 .parse::<u32>()
45 .is_ok_and(crate::progress::alive);
46 if alive {
47 return Err(holder.trim().to_owned());
48 }
49 let _ = std::fs::remove_file(&file);
50 }
51 }
52 }
53 Err(String::new())
54 }
55}
56
57impl Drop for Lock {
58 fn drop(&mut self) {
59 let _ = std::fs::remove_file(&self.0);
60 }
61}
62
63fn already(target: &Path, spec: &Spec) -> bool {
65 !spec.marker.is_empty() && target.join(&spec.marker).exists()
66}
67
68fn produced(target: &Path, before: &Before) -> Result<Vec<String>, String> {
72 let out = Git::at(target)
73 .run(&["status", "--porcelain", "--untracked-files=all", "-z"])
74 .map_err(|e| e.0)?;
75 let text = String::from_utf8_lossy(&out).into_owned();
76 let mut paths: Vec<String> = Vec::new();
77 let mut it = text.split('\0').filter(|s| !s.is_empty());
78 while let Some(entry) = it.next() {
79 let (code, path) = entry.split_at(entry.len().min(3));
80 if code.starts_with('R') || code.starts_with('C') {
82 let _ = it.next();
83 }
84 let top = path.split('/').next().unwrap_or(path);
85 if !speccoder_core::collection::is_build_or_dependency(path)
86 && !before.entries.iter().any(|e| e == top || e == path)
87 {
88 paths.push(path.to_owned());
89 }
90 }
91 paths.sort();
92 paths.dedup();
93 Ok(paths)
94}
95
96fn commit(target: &Path, paths: &[String], subject: &str) -> Result<String, String> {
98 if paths.is_empty() {
99 return Err("the steps produced nothing to commit".to_owned());
100 }
101 let g = Git::at(target);
102 let names: Vec<&str> = paths.iter().map(String::as_str).collect();
103 let mut add = vec!["add", "--"];
104 add.extend(&names);
105 g.run(&add).map_err(|e| e.0)?;
106 let mut c = vec!["commit", "-q", "-m", subject, "--"];
107 c.extend(&names);
108 g.run(&c).map_err(|e| e.0)?;
109 g.line(&["rev-parse", "--short", "HEAD"]).map_err(|e| e.0)
110}
111
112#[must_use]
117pub fn perform(
118 target: &Path,
119 choices: &NewChoices,
120 plan_digest: &str,
121 via: Via,
122 state: &Path,
123 options: &RunOptions,
124) -> CommandResult {
125 let (plan, specs, resumed) = plan_with(target, choices, state, true);
126 if plan.refusal.is_some() {
127 return refused_result(plan, Vec::new());
128 }
129 if plan.digest != plan_digest {
130 return CommandResult::new(
131 COMMAND,
132 CommandStatus::UsageError,
133 &format!(
134 "the plan changed since you saw it ({}); nothing ran — review it again",
135 changed_what(&plan)
136 ),
137 )
138 .with_code("NEW-PLAN-CHANGED")
139 .with_data(CommandData::NewProject(Box::new(report(
140 plan,
141 Vec::new(),
142 false,
143 ))));
144 }
145 let root = PathBuf::from(&plan.target);
146 let _lock = match Lock::take(state, &root) {
147 Ok(l) => l,
148 Err(holder) => {
149 return CommandResult::new(
150 COMMAND,
151 CommandStatus::UsageError,
152 &format!(
153 "a new project is being created in {}{}",
154 shown(&root),
155 if holder.is_empty() {
156 String::new()
157 } else {
158 format!(" (process {holder})")
159 }
160 ),
161 )
162 .with_code("NEW-IN-PROGRESS");
163 }
164 };
165 if let Err(e) = std::fs::create_dir_all(&root) {
166 return CommandResult::new(
167 COMMAND,
168 CommandStatus::Error,
169 &format!("cannot create {}: {e}", shown(&root)),
170 );
171 }
172 let existed = entries_of(&root);
173 let fresh_record = || Creation {
174 path: plan.target.clone(),
175 mode: plan.mode,
176 commit: None,
177 plan_digest: plan.digest.clone(),
178 name: plan.name.clone(),
179 idea: choices.idea.clone(),
180 stack: plan.stack.id,
181 format: plan.format.clone(),
182 by: plan.identity.clone(),
183 via,
184 at: now(),
185 first_feature_offered: false,
186 undone_at: None,
187 before: Before {
188 made: existed.iter().all(|e| e == ".DS_Store"),
189 ignored: if plan.mode == ProjectMode::ExistingRepository {
190 ignored(&root)
191 } else {
192 Vec::new()
193 },
194 entries: existed.clone(),
195 },
196 left: Some(snapshot(&root)),
197 git: git_digest(&root),
198 };
199 let mut record = resumed.unwrap_or_else(fresh_record);
201 record.plan_digest.clone_from(&plan.digest);
202 if let Err(e) = write_creation(state, &record) {
203 return CommandResult::new(COMMAND, CommandStatus::Error, &e);
204 }
205 if let Some(a) = format_kind(&plan.format).and_then(crate::format::adapter) {
206 let tool = a.tool().map_or("format", |(t, _)| t);
207 let init = crate::format::FormatInit {
208 extensions: plan.extensions.clone(),
209 preset: plan.preset,
210 state: state.to_path_buf(),
211 cache: steps::cache_dir(state, tool),
212 };
213 if let Err(e) = a.prepare_init(&init) {
214 return CommandResult::new(COMMAND, CommandStatus::Error, &e);
215 }
216 }
217 let event = |e: crate::progress::ProjectEvent| {
218 if let Some(p) = &options.progress {
219 p.event(&crate::progress::Event::Project { step: e });
220 }
221 };
222 let mut results: Vec<PlanStepResult> = Vec::new();
223 let mut init_report = None;
224 let mut made = None;
225 for spec in &specs {
226 let access = match spec.step.access {
227 StepAccess::Local => "local",
228 StepAccess::Network => "network",
229 };
230 event(crate::progress::ProjectEvent::ScaffoldStarted {
231 step: spec.step.id.clone(),
232 argv: spec.step.argv.clone(),
233 access: access.to_owned(),
234 });
235 let began = std::time::Instant::now();
236 let result = match spec.kind {
237 _ if already(&root, spec) => PlanStepResult {
238 id: spec.step.id.clone(),
239 state: PlanStepState::Skipped,
240 exit: None,
241 duration_ms: 0,
242 output_tail: vec!["already done".to_owned()],
243 },
244 Kind::Command => {
245 let repo = git_digest(&root);
246 let mut r = steps::run(
247 &root,
248 state,
249 &steps::Run {
250 id: &spec.step.id,
251 argv: &spec.step.argv,
252 access: spec.step.access,
253 limit_s: spec.step.limit_s,
254 env: &spec.env,
255 },
256 );
257 if spec.step.id == "git_init" && r.state == PlanStepState::Done {
260 record.git = git_digest(&root);
261 } else if repo.is_some() && git_digest(&root) != repo {
262 r.state = PlanStepState::Failed;
263 r.output_tail.push("the step changed the repository's own files (.git): speccoder stops before its commit — run the step yourself to see what it does".to_owned());
264 }
265 if plan.mode == ProjectMode::ExistingRepository && r.state == PlanStepState::Done {
267 let changed: Vec<String> = snapshot(&root)
268 .porcelain
269 .into_iter()
270 .filter(|l| !l.starts_with("??"))
271 .map(|l| l.get(3..).unwrap_or_default().to_owned())
272 .collect();
273 if !changed.is_empty() {
274 r.state = PlanStepState::Failed;
275 r.output_tail.push(format!(
276 "it changed files of yours: {} — speccoder commits none of them; restore them (git restore) and add the format yourself",
277 changed.join(", ")
278 ));
279 }
280 }
281 r
282 }
283 Kind::Init => match crate::init::prepare(&root, None, None, false, false) {
284 Ok(p) => {
285 let lines = p
286 .report
287 .ran
288 .iter()
289 .map(|r| format!("{} {}", r.name, r.status))
290 .collect();
291 init_report = Some(p.report);
292 PlanStepResult {
293 id: spec.step.id.clone(),
294 state: PlanStepState::Done,
295 exit: Some(0),
296 duration_ms: 0,
297 output_tail: lines,
298 }
299 }
300 Err(r) => PlanStepResult {
301 id: spec.step.id.clone(),
302 state: PlanStepState::Failed,
303 exit: None,
304 duration_ms: 0,
305 output_tail: vec![r.message().to_owned()],
306 },
307 },
308 Kind::Commit if record.git.is_some() && git_digest(&root) != record.git => {
309 PlanStepResult {
310 id: spec.step.id.clone(),
311 state: PlanStepState::Failed,
312 exit: None,
313 duration_ms: 0,
314 output_tail: vec!["the repository's own files (.git) changed since speccoder made or found them: nothing is committed".to_owned()],
315 }
316 }
317 Kind::Commit => {
318 let subject = spec.step.argv.last().cloned().unwrap_or_default();
319 match produced(&root, &record.before).and_then(|paths| commit(&root, &paths, &subject)) {
320 Ok(id) => {
321 made = Some(id.clone());
322 PlanStepResult {
323 id: spec.step.id.clone(),
324 state: PlanStepState::Done,
325 exit: Some(0),
326 duration_ms: 0,
327 output_tail: vec![id],
328 }
329 }
330 Err(e) => PlanStepResult {
331 id: spec.step.id.clone(),
332 state: PlanStepState::Failed,
333 exit: None,
334 duration_ms: 0,
335 output_tail: steps::tail(&e),
336 },
337 }
338 }
339 };
340 let mut result = result;
341 if result.duration_ms == 0 && result.state != PlanStepState::Skipped {
342 result.duration_ms = u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX);
343 }
344 event(crate::progress::ProjectEvent::ScaffoldFinished {
345 step: result.id.clone(),
346 state: result.state.word().to_owned(),
347 exit: result.exit,
348 duration_ms: result.duration_ms,
349 });
350 let failed = result.state == PlanStepState::Failed;
351 results.push(result);
352 if failed {
353 break;
354 }
355 }
356 for spec in specs.iter().skip(results.len()) {
358 results.push(PlanStepResult {
359 id: spec.step.id.clone(),
360 state: PlanStepState::Waiting,
361 exit: None,
362 duration_ms: 0,
363 output_tail: Vec::new(),
364 });
365 }
366 let mut out = report(plan.clone(), results, false);
367 out.init = init_report;
368 record.left = Some(snapshot(&root));
370 let kept = write_creation(state, &record);
371 if let Some(failed) = out
372 .results
373 .iter()
374 .find(|r| r.state == PlanStepState::Failed)
375 {
376 let n = out
377 .results
378 .iter()
379 .position(|r| r.id == failed.id)
380 .map_or(0, |i| i + 1);
381 let argv = specs
382 .iter()
383 .find(|s| s.step.id == failed.id)
384 .map(|s| s.step.argv.join(" "))
385 .unwrap_or_default();
386 let how = match failed.exit {
387 Some(0) => "was stopped".to_owned(),
388 Some(e) => format!("exited {e}"),
389 None => "failed".to_owned(),
390 };
391 let mut message = format!("step {n} `{argv}` {how}; later steps did not run");
392 if let Some(why) = failed
393 .output_tail
394 .iter()
395 .rev()
396 .find(|l| l.contains("speccoder"))
397 {
398 message = format!("{message}: {why}");
399 }
400 if let Err(e) = &kept {
401 message = format!("{message}; the record to resume from could not be written ({e})");
402 }
403 out.undo = undo_offer(state, &root);
404 return CommandResult::new(COMMAND, CommandStatus::Error, &message)
405 .with_code("NEW-STEP-FAILED")
406 .with_fix(vec![
407 format!("→ retry: run the same `speccoder new` again (it resumes at step {n})"),
408 format!("→ or run it yourself in {}: {argv}", shown(&root)),
409 ])
410 .with_data(CommandData::NewProject(Box::new(out)));
411 }
412 let Some(commit_id) = made else {
413 return CommandResult::new(COMMAND, CommandStatus::Error, "no commit was made")
414 .with_data(CommandData::NewProject(Box::new(out)));
415 };
416 let full = Git::at(&root)
417 .line(&["rev-parse", "HEAD"])
418 .unwrap_or_else(|_| commit_id.clone());
419 record.commit = Some(full);
420 record.at = now();
421 let recorded = write_creation(state, &record);
422 crate::welcome::remember(state, &root, &plan.name, true);
423 event(crate::progress::ProjectEvent::Created {
424 path_digest: path_digest(&root),
425 mode: match plan.mode {
426 ProjectMode::NewFolder => "new_folder",
427 ProjectMode::ExistingRepository => "existing_repository",
428 }
429 .to_owned(),
430 stack: plan.stack.id.as_str().to_owned(),
431 format: plan.format.clone(),
432 commit: commit_id.clone(),
433 via: via.as_str().to_owned(),
434 });
435 out.commit = Some(commit_id.clone());
436 out.undo = undo_offer(state, &root);
437 out.first_feature = (!choices.idea.trim().is_empty()).then(|| choices.idea.clone());
438 let checks = out.init.as_ref().map_or_else(String::new, |i| {
439 if i.ran.is_empty() {
440 "no checks yet: add a test runner before the first run".to_owned()
441 } else {
442 let passing: Vec<&str> = i
443 .ran
444 .iter()
445 .filter(|r| r.passed && r.status != "no_tests")
446 .map(|r| r.name.as_str())
447 .collect();
448 let empty: Vec<&str> = i
449 .ran
450 .iter()
451 .filter(|r| r.status == "no_tests")
452 .map(|r| r.name.as_str())
453 .collect();
454 let failing: Vec<&str> = i
455 .ran
456 .iter()
457 .filter(|r| !r.passed && r.status != "no_tests")
458 .map(|r| r.name.as_str())
459 .collect();
460 let mut parts = Vec::new();
461 if !passing.is_empty() {
462 parts.push(format!(
463 "{} pass{} on the untouched code",
464 passing.join(", "),
465 if passing.len() == 1 { "es" } else { "" }
466 ));
467 }
468 if !empty.is_empty() {
469 parts.push(format!("{}: no tests yet", empty.join(", ")));
470 }
471 if !failing.is_empty() {
472 parts.push(format!(
473 "{} fail{} before any change",
474 failing.join(", "),
475 if failing.len() == 1 { "s" } else { "" }
476 ));
477 }
478 parts.join("; ")
479 }
480 });
481 let mut message = format!("{} is ready — {checks}; committed {commit_id}", plan.name);
482 if let Err(e) = recorded {
483 message = format!(
484 "{message}; but its creation record could not be written ({e}): Undo is not offered"
485 );
486 }
487 let create = format_kind(&plan.format)
488 .and_then(crate::format::adapter)
489 .and_then(|a| a.planning().creates.first().copied());
490 let mut next = Vec::new();
491 if let Some(create) = create.filter(|_| !choices.idea.trim().is_empty()) {
492 next.push(speccoder_core::next::NextStep {
493 command: format!(
494 "speccoder spec {create} {} --through tasks -C {}",
495 speccoder_core::ladder::quoted(&choices.idea),
496 shown(&root)
497 ),
498 why: "write the first feature from your idea".to_owned(),
499 });
500 }
501 next.push(speccoder_core::next::NextStep {
502 command: format!("speccoder ui -C {}", shown(&root)),
503 why: "open it in the app".to_owned(),
504 });
505 CommandResult::new(COMMAND, CommandStatus::Ok, &message)
506 .with_data(CommandData::NewProject(Box::new(out)))
507 .with_next(next)
508}
509
510fn changed_what(plan: &ProjectPlan) -> String {
512 let n = plan.observed.entries.len();
513 if plan.observed.exists && n > 0 {
514 format!(
515 "the folder now has {n} entr{}",
516 if n == 1 { "y" } else { "ies" }
517 )
518 } else {
519 "a tool or the folder changed".to_owned()
520 }
521}