Skip to main content

speccoder_runtime/new_project/
run.rs

1//! Running a confirmed plan: the steps in order, `init` in process, the
2//! one commit as the person, the creation record and `project.created`.
3
4use 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
20/// A lock on creating the folder: one creation at a time per folder.
21struct 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
63/// Whether a step's effect is already in the folder.
64fn already(target: &Path, spec: &Spec) -> bool {
65    !spec.marker.is_empty() && target.join(&spec.marker).exists()
66}
67
68/// The paths the steps produced: every path `git status` lists (the
69/// tools' own ignore files respected), build output and dependencies left
70/// out, and nothing that was in the folder before the first step.
71fn 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        // A rename lists its source next.
81        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
96/// The one commit, as the person: exactly `paths`.
97fn 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/// Creates the project: the plan recomputed and compared with the one the
113/// person confirmed (`plan_digest`), each step run in order (a step whose
114/// effect is already there is skipped), `init` in process, one commit as
115/// the person, the creation record, `project.created`.
116#[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    // Resumed only as the plan said: the folder exactly as it was left.
200    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                // `git init` made the repository: from here on, its own
258                // files are speccoder's to keep as they are.
259                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                // An existing repository: the person's tracked files stay theirs.
266                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    // Steps after a failure wait.
357    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    // How this run leaves the folder: what a resume must find.
369    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
510/// What made a recomputed plan differ, in words.
511fn 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}