use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum Stack {
Rust,
Python,
Node,
Go,
None,
}
impl Stack {
pub const ALL: [Self; 5] = [Self::Rust, Self::Python, Self::Node, Self::Go, Self::None];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Rust => "rust",
Self::Python => "python",
Self::Node => "node",
Self::Go => "go",
Self::None => "none",
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Rust => "Rust (cargo)",
Self::Python => "Python (uv)",
Self::Node => "TypeScript (npm + vitest)",
Self::Go => "Go",
Self::None => "I'll add code myself",
}
}
#[must_use]
pub const fn tool(self) -> Option<&'static str> {
match self {
Self::Rust => Some("cargo"),
Self::Python => Some("uv"),
Self::Node => Some("npm"),
Self::Go => Some("go"),
Self::None => None,
}
}
#[must_use]
pub const fn install_hint(self) -> &'static str {
match self {
Self::Rust => "https://rustup.rs",
Self::Python => "https://docs.astral.sh/uv/",
Self::Node => "https://nodejs.org",
Self::Go => "https://go.dev/dl/",
Self::None => "",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|k| k.as_str() == s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum StepAccess {
Local,
Network,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ProjectMode {
NewFolder,
ExistingRepository,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PlanStepState {
Waiting,
Running,
Done,
Skipped,
Failed,
}
impl PlanStepState {
#[must_use]
pub const fn word(self) -> &'static str {
match self {
Self::Waiting => "waiting",
Self::Running => "running",
Self::Done => "done",
Self::Skipped => "skipped",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ExtensionChoice {
pub source: String,
pub kind: ExtensionKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExtensionKind {
Bundled,
Path,
Url,
}
impl ExtensionChoice {
#[must_use]
pub fn of(source: &str) -> Self {
let s = source.trim();
let kind = if s.starts_with("https://") || s.starts_with("http://") {
ExtensionKind::Url
} else if s.contains('/') || s.starts_with('.') || s.starts_with('~') {
ExtensionKind::Path
} else {
ExtensionKind::Bundled
};
Self {
source: s.to_owned(),
kind,
}
}
#[must_use]
pub fn host(&self) -> Option<String> {
if self.kind != ExtensionKind::Url {
return None;
}
let rest = self.source.split_once("://").map(|(_, r)| r)?;
let host = rest.split(['/', '?', '#']).next()?;
let host = host.rsplit('@').next()?.split(':').next()?;
(!host.is_empty()).then(|| host.to_owned())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema)]
pub struct NewChoices {
pub idea: String,
pub stack: Option<Stack>,
pub format: Option<String>,
#[serde(default)]
pub extensions: Vec<String>,
#[serde(default)]
pub no_preset: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PlanStep {
pub id: String,
pub argv: Vec<String>,
pub cwd: String,
pub access: StepAccess,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hosts: Vec<String>,
pub writes: String,
pub limit_s: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
pub const LOCAL_LIMIT_S: u32 = 120;
pub const NETWORK_LIMIT_S: u32 = 600;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct StackPick {
pub id: Stack,
pub detected: bool,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct StackOption {
pub id: Stack,
pub label: String,
pub version: Option<String>,
pub disabled: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct FormatOption {
pub id: String,
pub label: String,
pub version: Option<String>,
pub disabled: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GitIdentity {
pub name: String,
pub email: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PlanRefusal {
pub code: String,
pub message: String,
pub fix: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ObservedFolder {
pub exists: bool,
pub entries: Vec<String>,
pub head: Option<String>,
pub porcelain: Vec<String>,
pub tools: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SandboxNote {
pub backend: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ProjectPlan {
pub target: String,
pub name: String,
pub mode: ProjectMode,
pub stack: StackPick,
pub stacks: Vec<StackOption>,
pub format: String,
pub formats: Vec<FormatOption>,
pub extensions: Vec<ExtensionChoice>,
pub preset: bool,
pub steps: Vec<PlanStep>,
pub sandbox: SandboxNote,
pub identity: Option<GitIdentity>,
pub refusal: Option<PlanRefusal>,
pub observed: ObservedFolder,
pub digest: String,
}
impl ProjectPlan {
#[must_use]
pub fn network_hosts(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for h in self
.steps
.iter()
.filter(|s| s.access == StepAccess::Network)
.flat_map(|s| s.hosts.iter())
{
if !out.contains(h) {
out.push(h.clone());
}
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PlanStepResult {
pub id: String,
pub state: PlanStepState,
pub exit: Option<i32>,
pub duration_ms: u64,
pub output_tail: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct UndoOffer {
pub available: bool,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
pub struct NewProjectReport {
pub plan: ProjectPlan,
pub results: Vec<PlanStepResult>,
pub init: Option<crate::InitReport>,
pub commit: Option<String>,
pub undo: UndoOffer,
pub dry_run: bool,
pub first_feature: Option<String>,
}
#[must_use]
pub fn ascii_fold(text: &str) -> String {
text.nfkd().filter(char::is_ascii).collect()
}
const FILLER: &[&str] = &[
"a", "an", "the", "that", "which", "who", "to", "of", "for", "and", "or", "in", "on", "with",
"my", "our", "your", "into", "from", "by", "at", "as", "is", "it", "its", "this", "i", "we",
"want", "build", "make", "write", "create", "app", "tool", "simple", "small", "little",
];
pub const SLUG_MAX: usize = 40;
#[must_use]
pub fn slug(idea: &str) -> String {
let folded = ascii_fold(idea).to_ascii_lowercase();
let words: Vec<&str> = folded
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|w| !w.is_empty() && !FILLER.contains(w))
.take(5)
.collect();
let mut out = String::new();
for w in words {
let next = if out.is_empty() {
w.to_owned()
} else {
format!("{out}-{w}")
};
if next.len() > SLUG_MAX {
if out.is_empty() {
out = w.chars().take(SLUG_MAX).collect();
}
break;
}
out = next;
}
if out.is_empty() {
"project".to_owned()
} else {
out
}
}
const CUES: &[(Stack, &[&str])] = &[
(
Stack::Rust,
&[
"rust",
"cargo",
"crate",
"cli",
"command-line",
"terminal",
"library",
"wasm",
],
),
(
Stack::Python,
&[
"python", "pip", "uv", "django", "flask", "fastapi", "pandas", "script", "notebook",
"data",
],
),
(
Stack::Node,
&[
"typescript",
"javascript",
"node",
"npm",
"react",
"svelte",
"vue",
"web",
"website",
"frontend",
"browser",
"extension",
],
),
(
Stack::Go,
&[
"go",
"golang",
"server",
"service",
"api",
"microservice",
"daemon",
],
),
];
#[must_use]
pub fn suggest_stack(idea: &str, installed: &[(Stack, String)]) -> StackPick {
let lower = ascii_fold(idea).to_ascii_lowercase();
let words: Vec<&str> = lower
.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
.filter(|w| !w.is_empty())
.collect();
let version = |s: Stack| {
installed
.iter()
.find(|(k, _)| *k == s)
.map(|(_, v)| v.as_str())
};
let tool_line = |s: Stack| {
let tool = s.tool().unwrap_or("");
match version(s) {
Some(v) if !v.is_empty() => format!("{tool} {v} is installed"),
_ => format!("{tool} is installed"),
}
};
for pass in [true, false] {
for (stack, cues) in CUES {
if version(*stack).is_none() {
continue;
}
let pool: &[&str] = if pass {
&cues[..cues.len().min(3)]
} else {
cues
};
if let Some(w) = words.iter().find(|w| pool.contains(w)) {
let shown = if w.len() <= 4 {
w.to_ascii_uppercase()
} else {
(*w).to_owned()
};
return StackPick {
id: *stack,
detected: false,
reason: format!("\"{shown}\" and {}", tool_line(*stack)),
};
}
}
}
match Stack::ALL.into_iter().find(|s| version(*s).is_some()) {
Some(s) => StackPick {
id: s,
detected: false,
reason: format!("{} — nothing in the idea names a stack", tool_line(s)),
},
None => StackPick {
id: Stack::None,
detected: false,
reason: "no stack tool is installed: add code yourself".to_owned(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_slug_takes_five_significant_words_folded() {
assert_eq!(
slug("a CLI that turns Markdown tables into CSV"),
"cli-turns-markdown-tables-csv"
);
assert_eq!(slug("Ünïcödé café ☕"), "unicode-cafe");
assert_eq!(slug(" "), "project");
assert_eq!(slug("the a of"), "project");
let long = slug("supercalifragilisticexpialidocious extraordinarily verbose naming");
assert!(long.len() <= SLUG_MAX, "{long}");
assert_eq!(slug("md2csv"), "md2csv");
}
#[test]
fn a_named_language_wins_and_missing_tools_are_never_suggested() {
let all = [
(Stack::Rust, "1.91.0".to_owned()),
(Stack::Python, "0.9.0".to_owned()),
(Stack::Node, "11.0.0".to_owned()),
(Stack::Go, "1.25".to_owned()),
];
let s = suggest_stack("a CLI that turns Markdown tables into CSV", &all);
assert_eq!(s.id, Stack::Rust);
assert_eq!(s.reason, "\"CLI\" and cargo 1.91.0 is installed");
assert_eq!(
suggest_stack("a python script for my photos", &all).id,
Stack::Python
);
assert_eq!(suggest_stack("a web dashboard", &all).id, Stack::Node);
assert_eq!(suggest_stack("an API in go", &all).id, Stack::Go);
let no_python = [(Stack::Rust, "1.91.0".to_owned())];
assert_eq!(suggest_stack("a python script", &no_python).id, Stack::Rust);
assert_eq!(suggest_stack("anything", &[]).id, Stack::None);
}
#[test]
fn extensions_are_classified_and_urls_name_their_host() {
assert_eq!(ExtensionChoice::of("bug").kind, ExtensionKind::Bundled);
assert_eq!(
ExtensionChoice::of("~/x/threatspec").kind,
ExtensionKind::Path
);
let u = ExtensionChoice::of("https://user@example.com:8443/x.zip");
assert_eq!(u.kind, ExtensionKind::Url);
assert_eq!(u.host().as_deref(), Some("example.com"));
}
#[test]
fn a_plan_lists_its_network_hosts_once() {
let step = |id: &str, access, hosts: &[&str]| PlanStep {
id: id.to_owned(),
argv: vec!["x".to_owned()],
cwd: "/t".to_owned(),
access,
hosts: hosts.iter().map(|h| (*h).to_owned()).collect(),
writes: String::new(),
limit_s: LOCAL_LIMIT_S,
note: None,
};
let plan = ProjectPlan {
target: "/t".to_owned(),
name: "t".to_owned(),
mode: ProjectMode::NewFolder,
stack: StackPick {
id: Stack::Python,
detected: false,
reason: String::new(),
},
stacks: Vec::new(),
format: "speckit".to_owned(),
formats: Vec::new(),
extensions: Vec::new(),
preset: true,
steps: vec![
step("a", StepAccess::Local, &[]),
step("b", StepAccess::Network, &["pypi.org"]),
step(
"c",
StepAccess::Network,
&["pypi.org", "files.pythonhosted.org"],
),
],
sandbox: SandboxNote { backend: None },
identity: None,
refusal: None,
observed: ObservedFolder::default(),
digest: String::new(),
};
assert_eq!(
plan.network_hosts(),
vec!["pypi.org", "files.pythonhosted.org"]
);
}
}