mod plan;
mod run;
mod stacks;
pub(crate) mod steps;
pub mod undo;
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use speccoder_core::actions::Via;
use speccoder_core::new_project::{
GitIdentity, NewChoices, NewProjectReport, PlanRefusal, PlanStepResult, ProjectMode,
ProjectPlan, Stack, UndoOffer,
};
use speccoder_core::{CommandData, CommandResult, CommandStatus};
pub(crate) use plan::{Kind, Spec, format_kind, plan_with};
pub use run::perform;
pub use undo::{undo, undo_offer};
const COMMAND: &str = "new";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Creation {
pub path: String,
pub mode: ProjectMode,
pub commit: Option<String>,
pub plan_digest: String,
pub name: String,
pub idea: String,
pub stack: Stack,
pub format: String,
pub by: Option<GitIdentity>,
pub via: Via,
pub at: u64,
#[serde(default)]
pub first_feature_offered: bool,
#[serde(default)]
pub undone_at: Option<u64>,
#[serde(default)]
pub before: Before,
#[serde(default)]
pub left: Option<Snapshot>,
#[serde(default)]
pub git: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Before {
pub made: bool,
pub entries: Vec<String>,
pub ignored: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Snapshot {
pub entries: Vec<String>,
pub porcelain: Vec<String>,
}
pub(crate) fn entries_of(dir: &Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(dir)
.map(|d| {
d.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default();
v.sort();
v
}
pub(crate) fn snapshot(dir: &Path) -> Snapshot {
Snapshot {
entries: entries_of(dir),
porcelain: if dir.join(".git").exists() {
crate::git::Git::at(dir)
.run(&["status", "--porcelain", "--untracked-files=all"])
.map(|b| {
String::from_utf8_lossy(&b)
.lines()
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
},
}
}
pub(crate) fn ignored(dir: &Path) -> Vec<String> {
crate::git::Git::at(dir)
.line(&[
"status",
"--porcelain",
"--ignored",
"--untracked-files=all",
])
.map(|l| {
l.lines()
.filter_map(|x| x.strip_prefix("!! ").map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
pub(crate) fn git_digest(dir: &Path) -> Option<String> {
fn walk(base: &Path, p: &Path, out: &mut Vec<(String, Vec<u8>)>) {
let Ok(rd) = std::fs::read_dir(p) else {
return;
};
for e in rd.flatten() {
let path = e.path();
let rel = path
.strip_prefix(base)
.map(|r| r.display().to_string())
.unwrap_or_default();
if matches!(rel.as_str(), "objects" | "index" | "logs" | "index.lock") {
continue;
}
if path.is_dir() {
walk(base, &path, out);
} else {
out.push((rel, std::fs::read(&path).unwrap_or_default()));
}
}
}
let git = dir.join(".git");
if !git.is_dir() {
return None;
}
let mut files = Vec::new();
walk(&git, &git, &mut files);
files.sort();
let mut all = Vec::new();
for (p, b) in files {
all.extend_from_slice(p.as_bytes());
all.push(0);
all.extend_from_slice(sha(&b).as_bytes());
all.push(0);
}
Some(sha(&all))
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn sha(bytes: &[u8]) -> String {
crate::integrity::sha256_hex(bytes)
}
fn record_dir(state: &Path, target: &Path) -> PathBuf {
let id: String = sha(target.to_string_lossy().as_bytes())
.chars()
.take(16)
.collect();
state.join("new").join(id)
}
#[must_use]
pub fn path_digest(target: &Path) -> String {
format!("sha256:{}", sha(target.to_string_lossy().as_bytes()))
}
#[must_use]
pub fn creation(state: &Path, target: &Path) -> Option<Creation> {
let t = std::fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf());
let text = std::fs::read_to_string(record_dir(state, &t).join("creation.json")).ok()?;
serde_json::from_str(&text).ok()
}
pub(crate) fn write_creation(state: &Path, c: &Creation) -> Result<(), String> {
let dir = record_dir(state, Path::new(&c.path));
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let text = serde_json::to_string_pretty(c).map_err(|e| e.to_string())?;
let f = dir.join("creation.json");
let tmp = f.with_extension("json.tmp");
std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &f).map_err(|e| format!("{}: {e}", f.display()))
}
#[must_use]
pub fn first_feature(state: &Path, root: &Path) -> Option<String> {
let mut c = creation(state, root)?;
if c.first_feature_offered || c.commit.is_none() || c.undone_at.is_some() || c.idea.is_empty() {
return None;
}
let has_unit = crate::format::open(root, None)
.map(|o| !o.snapshot.features.is_empty())
.unwrap_or(true);
if has_unit {
return None;
}
c.first_feature_offered = true;
write_creation(state, &c).ok()?;
Some(c.idea)
}
fn resolve(path: &Path) -> Result<PathBuf, PlanRefusal> {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| {
refusal(
"USAGE",
&format!("no current directory: {e}"),
"→ give an absolute path",
)
})?
.join(path)
};
let mut lexical = PathBuf::new();
for c in abs.components() {
match c {
Component::ParentDir => {
lexical.pop();
}
Component::CurDir => {}
other => lexical.push(other),
}
}
if std::fs::symlink_metadata(&lexical).is_ok_and(|m| m.file_type().is_symlink()) {
return Err(refusal(
"NEW-TARGET-NOT-EMPTY",
&format!("{} is a symbolic link", lexical.display()),
"→ choose the folder it points to, or another one",
));
}
let mut existing = lexical.clone();
let mut rest: Vec<std::ffi::OsString> = Vec::new();
while !existing.exists() {
match (existing.file_name(), existing.parent()) {
(Some(n), Some(p)) => {
rest.push(n.to_os_string());
existing = p.to_path_buf();
}
_ => break,
}
}
let mut out = std::fs::canonicalize(&existing).unwrap_or(existing);
for n in rest.iter().rev() {
out.push(n);
}
Ok(out)
}
fn refusal(code: &str, message: &str, fix: &str) -> PlanRefusal {
PlanRefusal {
code: code.to_owned(),
message: message.to_owned(),
fix: fix.to_owned(),
}
}
fn shown(p: &Path) -> String {
match std::env::var_os("HOME").map(PathBuf::from) {
Some(h) if p.starts_with(&h) => {
format!(
"~/{}",
p.strip_prefix(&h)
.map_or_else(|_| p.display().to_string(), |r| r.display().to_string())
)
}
_ => p.display().to_string(),
}
}
#[must_use]
pub fn digest(plan: &ProjectPlan) -> String {
let bound = serde_json::json!({
"target": plan.target,
"name": plan.name,
"mode": plan.mode,
"stack": plan.stack,
"format": plan.format,
"extensions": plan.extensions,
"preset": plan.preset,
"steps": plan.steps,
"sandbox": plan.sandbox,
"identity": plan.identity,
"refusal": plan.refusal,
"observed": plan.observed,
});
format!("sha256:{}", sha(bound.to_string().as_bytes()))
}
#[must_use]
pub fn plan(path: &Path, choices: &NewChoices, state: &Path) -> ProjectPlan {
plan_with(path, choices, state, false).0
}
fn report(plan: ProjectPlan, results: Vec<PlanStepResult>, dry_run: bool) -> NewProjectReport {
NewProjectReport {
plan,
results,
init: None,
commit: None,
undo: UndoOffer {
available: false,
kind: "none".to_owned(),
reason: None,
},
dry_run,
first_feature: None,
}
}
fn refused_result(plan: ProjectPlan, results: Vec<PlanStepResult>) -> CommandResult {
let r = plan
.refusal
.clone()
.unwrap_or_else(|| refusal("USAGE", "refused", ""));
let status =
speccoder_core::codes::lookup(&r.code).map_or(CommandStatus::UsageError, |e| e.status);
CommandResult::new(COMMAND, status, &r.message)
.with_code(&r.code)
.with_fix(vec![r.fix.clone()])
.with_data(CommandData::NewProject(Box::new(report(
plan, results, false,
))))
}
#[must_use]
pub fn dry_run(path: &Path, choices: &NewChoices, state: &Path) -> CommandResult {
let plan = plan(path, choices, state);
if plan.refusal.is_some() {
let r = refused_result(plan, Vec::new());
return r;
}
let message = format!(
"plan for {} in {}: {} commands (nothing ran)",
plan.name,
shown(Path::new(&plan.target)),
plan.steps.len()
);
CommandResult::new(COMMAND, CommandStatus::Ok, &message).with_data(CommandData::NewProject(
Box::new(report(plan, Vec::new(), true)),
))
}