Skip to main content

speccoder_runtime/new_project/
mod.rs

1//! A new project from one line of intent: the plan — the stack's
2//! own scaffold, the spec format's own init, `speccoder init --yes` and
3//! one commit as the person — computed read-only, shown, confirmed, and
4//! then run exactly as shown. speccoder writes none of the stack's or the
5//! format's files itself (constitution VII); `speccoder.toml` is written
6//! only by `init`.
7//!
8//! The plan is bound to a digest over its commands and the folder as it
9//! was seen; [`perform`] recomputes it and refuses a plan that changed.
10//! A run that stopped resumes: each step's effect is detected, never
11//! repeated. Undo ([`undo()`]) moves a folder speccoder created to the
12//! Trash, or reverts speccoder's commit in an existing repository — only
13//! while nothing else happened there.
14
15mod plan;
16mod run;
17mod stacks;
18pub(crate) mod steps;
19pub mod undo;
20
21use std::path::{Component, Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24use speccoder_core::actions::Via;
25use speccoder_core::new_project::{
26    GitIdentity, NewChoices, NewProjectReport, PlanRefusal, PlanStepResult, ProjectMode,
27    ProjectPlan, Stack, UndoOffer,
28};
29use speccoder_core::{CommandData, CommandResult, CommandStatus};
30
31pub(crate) use plan::{Kind, Spec, format_kind, plan_with};
32pub use run::perform;
33pub use undo::{undo, undo_offer};
34
35const COMMAND: &str = "new";
36
37/// The creation record: what Undo, the recent projects and the new
38/// project's first view read. In speccoder's state, never the project.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Creation {
41    /// The folder, canonical.
42    pub path: String,
43    /// `new_folder` or `existing_repository`.
44    pub mode: ProjectMode,
45    /// speccoder's commit, once made.
46    pub commit: Option<String>,
47    /// The plan it ran.
48    pub plan_digest: String,
49    /// The project's name.
50    pub name: String,
51    /// What the person is building, verbatim (never in an event).
52    pub idea: String,
53    /// The stack.
54    pub stack: Stack,
55    /// The spec format.
56    pub format: String,
57    /// The person's git identity.
58    pub by: Option<GitIdentity>,
59    /// Through what.
60    pub via: Via,
61    /// Unix seconds.
62    pub at: u64,
63    /// The New Feature sheet was offered with the idea once.
64    #[serde(default)]
65    pub first_feature_offered: bool,
66    /// When it was undone.
67    #[serde(default)]
68    pub undone_at: Option<u64>,
69    /// The folder before the first step: what speccoder neither made nor
70    /// may commit or move.
71    #[serde(default)]
72    pub before: Before,
73    /// The folder as the last run left it: a run that resumes needs it
74    /// unchanged.
75    #[serde(default)]
76    pub left: Option<Snapshot>,
77    /// The repository's own files (`.git`, but its objects and index) as
78    /// speccoder made or found them: a step that changes them stops the run.
79    #[serde(default)]
80    pub git: Option<String>,
81}
82
83/// The folder before speccoder's first step.
84#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
85pub struct Before {
86    /// It did not exist, or held nothing (`.DS_Store` aside): speccoder made it.
87    pub made: bool,
88    /// Its entries.
89    pub entries: Vec<String>,
90    /// The paths git ignored then (an existing repository).
91    pub ignored: Vec<String>,
92}
93
94/// The folder at one moment.
95#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
96pub struct Snapshot {
97    /// Its entries.
98    pub entries: Vec<String>,
99    /// `git status --porcelain` there.
100    pub porcelain: Vec<String>,
101}
102
103/// The folder's entries, sorted.
104pub(crate) fn entries_of(dir: &Path) -> Vec<String> {
105    let mut v: Vec<String> = std::fs::read_dir(dir)
106        .map(|d| {
107            d.flatten()
108                .map(|e| e.file_name().to_string_lossy().into_owned())
109                .collect()
110        })
111        .unwrap_or_default();
112    v.sort();
113    v
114}
115
116/// The folder now.
117pub(crate) fn snapshot(dir: &Path) -> Snapshot {
118    Snapshot {
119        entries: entries_of(dir),
120        // Only the folder's own repository: never a parent's.
121        porcelain: if dir.join(".git").exists() {
122            // Untrimmed: a status line starts with a space (` M path`).
123            crate::git::Git::at(dir)
124                .run(&["status", "--porcelain", "--untracked-files=all"])
125                .map(|b| {
126                    String::from_utf8_lossy(&b)
127                        .lines()
128                        .filter(|l| !l.is_empty())
129                        .map(str::to_owned)
130                        .collect()
131                })
132                .unwrap_or_default()
133        } else {
134            Vec::new()
135        },
136    }
137}
138
139/// The paths git ignores in `dir` now.
140pub(crate) fn ignored(dir: &Path) -> Vec<String> {
141    crate::git::Git::at(dir)
142        .line(&[
143            "status",
144            "--porcelain",
145            "--ignored",
146            "--untracked-files=all",
147        ])
148        .map(|l| {
149            l.lines()
150                .filter_map(|x| x.strip_prefix("!! ").map(str::to_owned))
151                .collect()
152        })
153        .unwrap_or_default()
154}
155
156/// SHA-256 over `.git`'s files but its objects, index and logs — its
157/// configuration, attributes, refs: what decides what speccoder's own git
158/// runs. `None` without a repository.
159pub(crate) fn git_digest(dir: &Path) -> Option<String> {
160    fn walk(base: &Path, p: &Path, out: &mut Vec<(String, Vec<u8>)>) {
161        let Ok(rd) = std::fs::read_dir(p) else {
162            return;
163        };
164        for e in rd.flatten() {
165            let path = e.path();
166            let rel = path
167                .strip_prefix(base)
168                .map(|r| r.display().to_string())
169                .unwrap_or_default();
170            if matches!(rel.as_str(), "objects" | "index" | "logs" | "index.lock") {
171                continue;
172            }
173            if path.is_dir() {
174                walk(base, &path, out);
175            } else {
176                out.push((rel, std::fs::read(&path).unwrap_or_default()));
177            }
178        }
179    }
180    let git = dir.join(".git");
181    if !git.is_dir() {
182        return None;
183    }
184    let mut files = Vec::new();
185    walk(&git, &git, &mut files);
186    files.sort();
187    let mut all = Vec::new();
188    for (p, b) in files {
189        all.extend_from_slice(p.as_bytes());
190        all.push(0);
191        all.extend_from_slice(sha(&b).as_bytes());
192        all.push(0);
193    }
194    Some(sha(&all))
195}
196
197fn now() -> u64 {
198    std::time::SystemTime::now()
199        .duration_since(std::time::UNIX_EPOCH)
200        .map_or(0, |d| d.as_secs())
201}
202
203/// SHA-256 of `bytes`, hex.
204fn sha(bytes: &[u8]) -> String {
205    crate::integrity::sha256_hex(bytes)
206}
207
208/// The record's directory for the folder `target` (canonical).
209fn record_dir(state: &Path, target: &Path) -> PathBuf {
210    let id: String = sha(target.to_string_lossy().as_bytes())
211        .chars()
212        .take(16)
213        .collect();
214    state.join("new").join(id)
215}
216
217/// SHA-256 of the canonical path: what an event names instead of the path.
218#[must_use]
219pub fn path_digest(target: &Path) -> String {
220    format!("sha256:{}", sha(target.to_string_lossy().as_bytes()))
221}
222
223/// The creation record of the project at `target`, if speccoder created it.
224#[must_use]
225pub fn creation(state: &Path, target: &Path) -> Option<Creation> {
226    let t = std::fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf());
227    let text = std::fs::read_to_string(record_dir(state, &t).join("creation.json")).ok()?;
228    serde_json::from_str(&text).ok()
229}
230
231pub(crate) fn write_creation(state: &Path, c: &Creation) -> Result<(), String> {
232    let dir = record_dir(state, Path::new(&c.path));
233    std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
234    let text = serde_json::to_string_pretty(c).map_err(|e| e.to_string())?;
235    let f = dir.join("creation.json");
236    let tmp = f.with_extension("json.tmp");
237    std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
238    std::fs::rename(&tmp, &f).map_err(|e| format!("{}: {e}", f.display()))
239}
240
241/// The idea of a project speccoder created, offered once while it has no
242/// unit: the New Feature sheet opens prefilled with it. Marks it
243/// offered.
244#[must_use]
245pub fn first_feature(state: &Path, root: &Path) -> Option<String> {
246    let mut c = creation(state, root)?;
247    if c.first_feature_offered || c.commit.is_none() || c.undone_at.is_some() || c.idea.is_empty() {
248        return None;
249    }
250    let has_unit = crate::format::open(root, None)
251        .map(|o| !o.snapshot.features.is_empty())
252        .unwrap_or(true);
253    if has_unit {
254        return None;
255    }
256    c.first_feature_offered = true;
257    write_creation(state, &c).ok()?;
258    Some(c.idea)
259}
260
261/// The folder `path` names, absolute, with `.` and `..` resolved and
262/// symbolic links of the part that exists resolved: what the plan names.
263fn resolve(path: &Path) -> Result<PathBuf, PlanRefusal> {
264    let abs = if path.is_absolute() {
265        path.to_path_buf()
266    } else {
267        std::env::current_dir()
268            .map_err(|e| {
269                refusal(
270                    "USAGE",
271                    &format!("no current directory: {e}"),
272                    "→ give an absolute path",
273                )
274            })?
275            .join(path)
276    };
277    let mut lexical = PathBuf::new();
278    for c in abs.components() {
279        match c {
280            Component::ParentDir => {
281                lexical.pop();
282            }
283            Component::CurDir => {}
284            other => lexical.push(other),
285        }
286    }
287    if std::fs::symlink_metadata(&lexical).is_ok_and(|m| m.file_type().is_symlink()) {
288        return Err(refusal(
289            "NEW-TARGET-NOT-EMPTY",
290            &format!("{} is a symbolic link", lexical.display()),
291            "→ choose the folder it points to, or another one",
292        ));
293    }
294    // The longest part that exists, canonical; the rest appended.
295    let mut existing = lexical.clone();
296    let mut rest: Vec<std::ffi::OsString> = Vec::new();
297    while !existing.exists() {
298        match (existing.file_name(), existing.parent()) {
299            (Some(n), Some(p)) => {
300                rest.push(n.to_os_string());
301                existing = p.to_path_buf();
302            }
303            _ => break,
304        }
305    }
306    let mut out = std::fs::canonicalize(&existing).unwrap_or(existing);
307    for n in rest.iter().rev() {
308        out.push(n);
309    }
310    Ok(out)
311}
312
313fn refusal(code: &str, message: &str, fix: &str) -> PlanRefusal {
314    PlanRefusal {
315        code: code.to_owned(),
316        message: message.to_owned(),
317        fix: fix.to_owned(),
318    }
319}
320
321/// `~/x` for a path under the home directory.
322fn shown(p: &Path) -> String {
323    match std::env::var_os("HOME").map(PathBuf::from) {
324        Some(h) if p.starts_with(&h) => {
325            format!(
326                "~/{}",
327                p.strip_prefix(&h)
328                    .map_or_else(|_| p.display().to_string(), |r| r.display().to_string())
329            )
330        }
331        _ => p.display().to_string(),
332    }
333}
334
335/// SHA-256 over what the confirmation binds: everything the plan runs and
336/// the folder as seen — not the informational options beside it.
337#[must_use]
338pub fn digest(plan: &ProjectPlan) -> String {
339    let bound = serde_json::json!({
340        "target": plan.target,
341        "name": plan.name,
342        "mode": plan.mode,
343        "stack": plan.stack,
344        "format": plan.format,
345        "extensions": plan.extensions,
346        "preset": plan.preset,
347        "steps": plan.steps,
348        "sandbox": plan.sandbox,
349        "identity": plan.identity,
350        "refusal": plan.refusal,
351        "observed": plan.observed,
352    });
353    format!("sha256:{}", sha(bound.to_string().as_bytes()))
354}
355
356/// The plan for `path` with `choices` (read-only; the sheet's and
357/// `--dry-run`'s).
358#[must_use]
359pub fn plan(path: &Path, choices: &NewChoices, state: &Path) -> ProjectPlan {
360    plan_with(path, choices, state, false).0
361}
362
363fn report(plan: ProjectPlan, results: Vec<PlanStepResult>, dry_run: bool) -> NewProjectReport {
364    NewProjectReport {
365        plan,
366        results,
367        init: None,
368        commit: None,
369        undo: UndoOffer {
370            available: false,
371            kind: "none".to_owned(),
372            reason: None,
373        },
374        dry_run,
375        first_feature: None,
376    }
377}
378
379fn refused_result(plan: ProjectPlan, results: Vec<PlanStepResult>) -> CommandResult {
380    let r = plan
381        .refusal
382        .clone()
383        .unwrap_or_else(|| refusal("USAGE", "refused", ""));
384    let status =
385        speccoder_core::codes::lookup(&r.code).map_or(CommandStatus::UsageError, |e| e.status);
386    CommandResult::new(COMMAND, status, &r.message)
387        .with_code(&r.code)
388        .with_fix(vec![r.fix.clone()])
389        .with_data(CommandData::NewProject(Box::new(report(
390            plan, results, false,
391        ))))
392}
393
394/// `speccoder new --dry-run`: the plan and its digest; nothing changes.
395#[must_use]
396pub fn dry_run(path: &Path, choices: &NewChoices, state: &Path) -> CommandResult {
397    let plan = plan(path, choices, state);
398    if plan.refusal.is_some() {
399        let r = refused_result(plan, Vec::new());
400        return r;
401    }
402    let message = format!(
403        "plan for {} in {}: {} commands (nothing ran)",
404        plan.name,
405        shown(Path::new(&plan.target)),
406        plan.steps.len()
407    );
408    CommandResult::new(COMMAND, CommandStatus::Ok, &message).with_data(CommandData::NewProject(
409        Box::new(report(plan, Vec::new(), true)),
410    ))
411}