speccoder_runtime/new_project/
mod.rs1mod plan;
16mod run;
17mod stacks;
18mod 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Creation {
41 pub path: String,
43 pub mode: ProjectMode,
45 pub commit: Option<String>,
47 pub plan_digest: String,
49 pub name: String,
51 pub idea: String,
53 pub stack: Stack,
55 pub format: String,
57 pub by: Option<GitIdentity>,
59 pub via: Via,
61 pub at: u64,
63 #[serde(default)]
65 pub first_feature_offered: bool,
66 #[serde(default)]
68 pub undone_at: Option<u64>,
69 #[serde(default)]
72 pub before: Before,
73 #[serde(default)]
76 pub left: Option<Snapshot>,
77 #[serde(default)]
80 pub git: Option<String>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
85pub struct Before {
86 pub made: bool,
88 pub entries: Vec<String>,
90 pub ignored: Vec<String>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
96pub struct Snapshot {
97 pub entries: Vec<String>,
99 pub porcelain: Vec<String>,
101}
102
103pub(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
116pub(crate) fn snapshot(dir: &Path) -> Snapshot {
118 Snapshot {
119 entries: entries_of(dir),
120 porcelain: if dir.join(".git").exists() {
122 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
139pub(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
156pub(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
203fn sha(bytes: &[u8]) -> String {
205 crate::integrity::sha256_hex(bytes)
206}
207
208fn 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#[must_use]
219pub fn path_digest(target: &Path) -> String {
220 format!("sha256:{}", sha(target.to_string_lossy().as_bytes()))
221}
222
223#[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#[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
261fn 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 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
321fn 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#[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#[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#[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}