1use serde::{Deserialize, Serialize};
9use unicode_normalization::UnicodeNormalization;
10
11#[derive(
13 Debug,
14 Clone,
15 Copy,
16 PartialEq,
17 Eq,
18 PartialOrd,
19 Ord,
20 Hash,
21 Serialize,
22 Deserialize,
23 schemars::JsonSchema,
24)]
25#[serde(rename_all = "snake_case")]
26pub enum Stack {
27 Rust,
29 Python,
31 Node,
33 Go,
35 None,
37}
38
39impl Stack {
40 pub const ALL: [Self; 5] = [Self::Rust, Self::Python, Self::Node, Self::Go, Self::None];
42
43 #[must_use]
45 pub const fn as_str(self) -> &'static str {
46 match self {
47 Self::Rust => "rust",
48 Self::Python => "python",
49 Self::Node => "node",
50 Self::Go => "go",
51 Self::None => "none",
52 }
53 }
54
55 #[must_use]
57 pub const fn label(self) -> &'static str {
58 match self {
59 Self::Rust => "Rust (cargo)",
60 Self::Python => "Python (uv)",
61 Self::Node => "TypeScript (npm + vitest)",
62 Self::Go => "Go",
63 Self::None => "I'll add code myself",
64 }
65 }
66
67 #[must_use]
69 pub const fn tool(self) -> Option<&'static str> {
70 match self {
71 Self::Rust => Some("cargo"),
72 Self::Python => Some("uv"),
73 Self::Node => Some("npm"),
74 Self::Go => Some("go"),
75 Self::None => None,
76 }
77 }
78
79 #[must_use]
81 pub const fn install_hint(self) -> &'static str {
82 match self {
83 Self::Rust => "https://rustup.rs",
84 Self::Python => "https://docs.astral.sh/uv/",
85 Self::Node => "https://nodejs.org",
86 Self::Go => "https://go.dev/dl/",
87 Self::None => "",
88 }
89 }
90
91 #[must_use]
93 pub fn parse(s: &str) -> Option<Self> {
94 Self::ALL.into_iter().find(|k| k.as_str() == s)
95 }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
100#[serde(rename_all = "snake_case")]
101pub enum StepAccess {
102 Local,
104 Network,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
110#[serde(rename_all = "snake_case")]
111pub enum ProjectMode {
112 NewFolder,
114 ExistingRepository,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
120#[serde(rename_all = "snake_case")]
121pub enum PlanStepState {
122 Waiting,
124 Running,
126 Done,
128 Skipped,
130 Failed,
132}
133
134impl PlanStepState {
135 #[must_use]
137 pub const fn word(self) -> &'static str {
138 match self {
139 Self::Waiting => "waiting",
140 Self::Running => "running",
141 Self::Done => "done",
142 Self::Skipped => "skipped",
143 Self::Failed => "failed",
144 }
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
150pub struct ExtensionChoice {
151 pub source: String,
153 pub kind: ExtensionKind,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
159#[serde(rename_all = "snake_case")]
160pub enum ExtensionKind {
161 Bundled,
163 Path,
165 Url,
167}
168
169impl ExtensionChoice {
170 #[must_use]
173 pub fn of(source: &str) -> Self {
174 let s = source.trim();
175 let kind = if s.starts_with("https://") || s.starts_with("http://") {
176 ExtensionKind::Url
177 } else if s.contains('/') || s.starts_with('.') || s.starts_with('~') {
178 ExtensionKind::Path
179 } else {
180 ExtensionKind::Bundled
181 };
182 Self {
183 source: s.to_owned(),
184 kind,
185 }
186 }
187
188 #[must_use]
190 pub fn host(&self) -> Option<String> {
191 if self.kind != ExtensionKind::Url {
192 return None;
193 }
194 let rest = self.source.split_once("://").map(|(_, r)| r)?;
195 let host = rest.split(['/', '?', '#']).next()?;
196 let host = host.rsplit('@').next()?.split(':').next()?;
197 (!host.is_empty()).then(|| host.to_owned())
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema)]
203pub struct NewChoices {
204 pub idea: String,
206 pub stack: Option<Stack>,
208 pub format: Option<String>,
210 #[serde(default)]
212 pub extensions: Vec<String>,
213 #[serde(default)]
215 pub no_preset: bool,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
220pub struct PlanStep {
221 pub id: String,
223 pub argv: Vec<String>,
225 pub cwd: String,
227 pub access: StepAccess,
229 #[serde(default, skip_serializing_if = "Vec::is_empty")]
232 pub hosts: Vec<String>,
233 pub writes: String,
235 pub limit_s: u32,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub note: Option<String>,
240}
241
242pub const LOCAL_LIMIT_S: u32 = 120;
244pub const NETWORK_LIMIT_S: u32 = 600;
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
249pub struct StackPick {
250 pub id: Stack,
252 pub detected: bool,
254 pub reason: String,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
260pub struct StackOption {
261 pub id: Stack,
263 pub label: String,
265 pub version: Option<String>,
267 pub disabled: Option<String>,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
273pub struct FormatOption {
274 pub id: String,
276 pub label: String,
278 pub version: Option<String>,
280 pub disabled: Option<String>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
286pub struct GitIdentity {
287 pub name: String,
289 pub email: String,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
295pub struct PlanRefusal {
296 pub code: String,
298 pub message: String,
300 pub fix: String,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema)]
306pub struct ObservedFolder {
307 pub exists: bool,
309 pub entries: Vec<String>,
311 pub head: Option<String>,
313 pub porcelain: Vec<String>,
315 pub tools: std::collections::BTreeMap<String, String>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
321pub struct SandboxNote {
322 pub backend: Option<String>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
328pub struct ProjectPlan {
329 pub target: String,
331 pub name: String,
333 pub mode: ProjectMode,
335 pub stack: StackPick,
337 pub stacks: Vec<StackOption>,
339 pub format: String,
341 pub formats: Vec<FormatOption>,
343 pub extensions: Vec<ExtensionChoice>,
345 pub preset: bool,
347 pub steps: Vec<PlanStep>,
349 pub sandbox: SandboxNote,
351 pub identity: Option<GitIdentity>,
353 pub refusal: Option<PlanRefusal>,
355 pub observed: ObservedFolder,
357 pub digest: String,
359}
360
361impl ProjectPlan {
362 #[must_use]
364 pub fn network_hosts(&self) -> Vec<String> {
365 let mut out: Vec<String> = Vec::new();
366 for h in self
367 .steps
368 .iter()
369 .filter(|s| s.access == StepAccess::Network)
370 .flat_map(|s| s.hosts.iter())
371 {
372 if !out.contains(h) {
373 out.push(h.clone());
374 }
375 }
376 out
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
382pub struct PlanStepResult {
383 pub id: String,
385 pub state: PlanStepState,
387 pub exit: Option<i32>,
389 pub duration_ms: u64,
391 pub output_tail: Vec<String>,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
397pub struct UndoOffer {
398 pub available: bool,
400 pub kind: String,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub reason: Option<String>,
405}
406
407#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
409pub struct NewProjectReport {
410 pub plan: ProjectPlan,
412 pub results: Vec<PlanStepResult>,
414 pub init: Option<crate::InitReport>,
416 pub commit: Option<String>,
418 pub undo: UndoOffer,
420 pub dry_run: bool,
422 pub first_feature: Option<String>,
424}
425
426#[must_use]
428pub fn ascii_fold(text: &str) -> String {
429 text.nfkd().filter(char::is_ascii).collect()
430}
431
432const FILLER: &[&str] = &[
434 "a", "an", "the", "that", "which", "who", "to", "of", "for", "and", "or", "in", "on", "with",
435 "my", "our", "your", "into", "from", "by", "at", "as", "is", "it", "its", "this", "i", "we",
436 "want", "build", "make", "write", "create", "app", "tool", "simple", "small", "little",
437];
438
439pub const SLUG_MAX: usize = 40;
441
442#[must_use]
446pub fn slug(idea: &str) -> String {
447 let folded = ascii_fold(idea).to_ascii_lowercase();
448 let words: Vec<&str> = folded
449 .split(|c: char| !c.is_ascii_alphanumeric())
450 .filter(|w| !w.is_empty() && !FILLER.contains(w))
451 .take(5)
452 .collect();
453 let mut out = String::new();
454 for w in words {
455 let next = if out.is_empty() {
456 w.to_owned()
457 } else {
458 format!("{out}-{w}")
459 };
460 if next.len() > SLUG_MAX {
461 if out.is_empty() {
462 out = w.chars().take(SLUG_MAX).collect();
463 }
464 break;
465 }
466 out = next;
467 }
468 if out.is_empty() {
469 "project".to_owned()
470 } else {
471 out
472 }
473}
474
475const CUES: &[(Stack, &[&str])] = &[
477 (
478 Stack::Rust,
479 &[
480 "rust",
481 "cargo",
482 "crate",
483 "cli",
484 "command-line",
485 "terminal",
486 "library",
487 "wasm",
488 ],
489 ),
490 (
491 Stack::Python,
492 &[
493 "python", "pip", "uv", "django", "flask", "fastapi", "pandas", "script", "notebook",
494 "data",
495 ],
496 ),
497 (
498 Stack::Node,
499 &[
500 "typescript",
501 "javascript",
502 "node",
503 "npm",
504 "react",
505 "svelte",
506 "vue",
507 "web",
508 "website",
509 "frontend",
510 "browser",
511 "extension",
512 ],
513 ),
514 (
515 Stack::Go,
516 &[
517 "go",
518 "golang",
519 "server",
520 "service",
521 "api",
522 "microservice",
523 "daemon",
524 ],
525 ),
526];
527
528#[must_use]
533pub fn suggest_stack(idea: &str, installed: &[(Stack, String)]) -> StackPick {
534 let lower = ascii_fold(idea).to_ascii_lowercase();
535 let words: Vec<&str> = lower
536 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
537 .filter(|w| !w.is_empty())
538 .collect();
539 let version = |s: Stack| {
540 installed
541 .iter()
542 .find(|(k, _)| *k == s)
543 .map(|(_, v)| v.as_str())
544 };
545 let tool_line = |s: Stack| {
546 let tool = s.tool().unwrap_or("");
547 match version(s) {
548 Some(v) if !v.is_empty() => format!("{tool} {v} is installed"),
549 _ => format!("{tool} is installed"),
550 }
551 };
552 for pass in [true, false] {
554 for (stack, cues) in CUES {
555 if version(*stack).is_none() {
556 continue;
557 }
558 let pool: &[&str] = if pass {
559 &cues[..cues.len().min(3)]
560 } else {
561 cues
562 };
563 if let Some(w) = words.iter().find(|w| pool.contains(w)) {
564 let shown = if w.len() <= 4 {
565 w.to_ascii_uppercase()
566 } else {
567 (*w).to_owned()
568 };
569 return StackPick {
570 id: *stack,
571 detected: false,
572 reason: format!("\"{shown}\" and {}", tool_line(*stack)),
573 };
574 }
575 }
576 }
577 match Stack::ALL.into_iter().find(|s| version(*s).is_some()) {
578 Some(s) => StackPick {
579 id: s,
580 detected: false,
581 reason: format!("{} — nothing in the idea names a stack", tool_line(s)),
582 },
583 None => StackPick {
584 id: Stack::None,
585 detected: false,
586 reason: "no stack tool is installed: add code yourself".to_owned(),
587 },
588 }
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 #[test]
596 fn a_slug_takes_five_significant_words_folded() {
597 assert_eq!(
598 slug("a CLI that turns Markdown tables into CSV"),
599 "cli-turns-markdown-tables-csv"
600 );
601 assert_eq!(slug("Ünïcödé café ☕"), "unicode-cafe");
602 assert_eq!(slug(" "), "project");
603 assert_eq!(slug("the a of"), "project");
604 let long = slug("supercalifragilisticexpialidocious extraordinarily verbose naming");
605 assert!(long.len() <= SLUG_MAX, "{long}");
606 assert_eq!(slug("md2csv"), "md2csv");
607 }
608
609 #[test]
610 fn a_named_language_wins_and_missing_tools_are_never_suggested() {
611 let all = [
612 (Stack::Rust, "1.91.0".to_owned()),
613 (Stack::Python, "0.9.0".to_owned()),
614 (Stack::Node, "11.0.0".to_owned()),
615 (Stack::Go, "1.25".to_owned()),
616 ];
617 let s = suggest_stack("a CLI that turns Markdown tables into CSV", &all);
618 assert_eq!(s.id, Stack::Rust);
619 assert_eq!(s.reason, "\"CLI\" and cargo 1.91.0 is installed");
620 assert_eq!(
621 suggest_stack("a python script for my photos", &all).id,
622 Stack::Python
623 );
624 assert_eq!(suggest_stack("a web dashboard", &all).id, Stack::Node);
625 assert_eq!(suggest_stack("an API in go", &all).id, Stack::Go);
626 let no_python = [(Stack::Rust, "1.91.0".to_owned())];
627 assert_eq!(suggest_stack("a python script", &no_python).id, Stack::Rust);
628 assert_eq!(suggest_stack("anything", &[]).id, Stack::None);
629 }
630
631 #[test]
632 fn extensions_are_classified_and_urls_name_their_host() {
633 assert_eq!(ExtensionChoice::of("bug").kind, ExtensionKind::Bundled);
634 assert_eq!(
635 ExtensionChoice::of("~/x/threatspec").kind,
636 ExtensionKind::Path
637 );
638 let u = ExtensionChoice::of("https://user@example.com:8443/x.zip");
639 assert_eq!(u.kind, ExtensionKind::Url);
640 assert_eq!(u.host().as_deref(), Some("example.com"));
641 }
642
643 #[test]
644 fn a_plan_lists_its_network_hosts_once() {
645 let step = |id: &str, access, hosts: &[&str]| PlanStep {
646 id: id.to_owned(),
647 argv: vec!["x".to_owned()],
648 cwd: "/t".to_owned(),
649 access,
650 hosts: hosts.iter().map(|h| (*h).to_owned()).collect(),
651 writes: String::new(),
652 limit_s: LOCAL_LIMIT_S,
653 note: None,
654 };
655 let plan = ProjectPlan {
656 target: "/t".to_owned(),
657 name: "t".to_owned(),
658 mode: ProjectMode::NewFolder,
659 stack: StackPick {
660 id: Stack::Python,
661 detected: false,
662 reason: String::new(),
663 },
664 stacks: Vec::new(),
665 format: "speckit".to_owned(),
666 formats: Vec::new(),
667 extensions: Vec::new(),
668 preset: true,
669 steps: vec![
670 step("a", StepAccess::Local, &[]),
671 step("b", StepAccess::Network, &["pypi.org"]),
672 step(
673 "c",
674 StepAccess::Network,
675 &["pypi.org", "files.pythonhosted.org"],
676 ),
677 ],
678 sandbox: SandboxNote { backend: None },
679 identity: None,
680 refusal: None,
681 observed: ObservedFolder::default(),
682 digest: String::new(),
683 };
684 assert_eq!(
685 plan.network_hosts(),
686 vec!["pypi.org", "files.pythonhosted.org"]
687 );
688 }
689}